Coverage for sources / agentsmgr / population.py: 19%

163 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-01 15:37 +0000

1# vim: set filetype=python fileencoding=utf-8: 

2# -*- coding: utf-8 -*- 

3 

4#============================================================================# 

5# # 

6# Licensed under the Apache License, Version 2.0 (the "License"); # 

7# you may not use this file except in compliance with the License. # 

8# You may obtain a copy of the License at # 

9# # 

10# http://www.apache.org/licenses/LICENSE-2.0 # 

11# # 

12# Unless required by applicable law or agreed to in writing, software # 

13# distributed under the License is distributed on an "AS IS" BASIS, # 

14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

15# See the License for the specific language governing permissions and # 

16# limitations under the License. # 

17# # 

18#============================================================================# 

19 

20 

21''' Command for populating agent content from data sources. ''' 

22 

23 

24from . import __ 

25from . import cmdbase as _cmdbase 

26from . import core as _core 

27from . import exceptions as _exceptions 

28from . import generator as _generator 

29from . import instructions as _instructions 

30from . import memorylinks as _memorylinks 

31from . import operations as _operations 

32from . import renderers as _renderers 

33from . import results as _results 

34from . import userdata as _userdata 

35 

36 

37_scribe = __.provide_scribe( __name__ ) 

38 

39 

40SourceArgument: __.typx.TypeAlias = __.typx.Annotated[ 

41 __.tyro.conf.Positional[ str ], 

42 __.tyro.conf.arg( help = "Data source (local path or git URL)" ), 

43] 

44TargetArgument: __.typx.TypeAlias = __.typx.Annotated[ 

45 __.tyro.conf.Positional[ __.Path ], 

46 __.tyro.conf.arg( help = "Target directory for content generation" ), 

47] 

48 

49 

50def _filter_coders_by_mode( 

51 coders: __.cabc.Sequence[ str ], 

52 target_mode: _renderers.ExplicitTargetMode, 

53 renderers: __.cabc.Mapping[ str, __.typx.Any ], 

54) -> tuple[ str, ... ]: 

55 ''' Filters coders by their default targeting mode. 

56 

57 Returns coders whose mode_default matches the target mode. 

58 This ensures populate project only handles per-project coders 

59 and populate user only handles per-user coders, respecting each 

60 renderer's designed usage pattern. 

61 ''' 

62 filtered: list[ str ] = [ ] 

63 for coder_name in coders: 

64 try: renderer = renderers[ coder_name ] 

65 except KeyError as exception: 

66 raise _exceptions.CoderAbsence( coder_name ) from exception 

67 if renderer.mode_default == target_mode: 

68 filtered.append( coder_name ) 

69 else: 

70 _scribe.debug( 

71 f"Skipping {coder_name} for {target_mode} mode: " 

72 f"default mode is {renderer.mode_default}" ) 

73 return tuple( filtered ) 

74 

75 

76def _create_all_symlinks( 

77 configuration: __.cabc.Mapping[ str, __.typx.Any ], 

78 target: __.Path, 

79 mode: str, 

80 simulate: bool, 

81) -> tuple[ str, ... ]: 

82 ''' Creates all symlinks and returns their names for git exclude. 

83 

84 Creates memory symlinks for all coders and coder directory 

85 symlinks for per-project mode. Returns list of all symlink 

86 names (both newly created and pre-existing) for git exclude 

87 update. 

88 ''' 

89 all_symlink_names: list[ str ] = [ ] 

90 if mode == 'nowhere': return tuple( all_symlink_names ) 

91 links_attempted, links_created, symlink_names_memory = ( 

92 _memorylinks.create_memory_symlinks_for_coders( 

93 coders = configuration[ 'coders' ], 

94 target = target, 

95 renderers = _renderers.RENDERERS, 

96 simulate = simulate, 

97 ) ) 

98 all_symlink_names.extend( symlink_names_memory ) 

99 if links_created > 0: 

100 _scribe.info( 

101 f"Created {links_created}/{links_attempted} memory symlinks" ) 

102 needs_coder_symlinks = ( 

103 mode == 'per-project' 

104 or ( mode == 'default' and any( 

105 _renderers.RENDERERS[ coder ].mode_default == 'per-project' 

106 for coder in configuration[ 'coders' ] ) ) ) 

107 if needs_coder_symlinks: 

108 ( coder_symlinks_attempted, 

109 coder_symlinks_created, 

110 coder_symlink_names ) = ( 

111 _create_coder_directory_symlinks( 

112 coders = configuration[ 'coders' ], 

113 target = target, 

114 renderers = _renderers.RENDERERS, 

115 simulate = simulate, 

116 ) ) 

117 all_symlink_names.extend( coder_symlink_names ) 

118 if coder_symlinks_created > 0: 

119 _scribe.info( 

120 f"Created {coder_symlinks_created}/" 

121 f"{coder_symlinks_attempted} coder directory symlinks" ) 

122 openspec_link_path = target / 'openspec' 

123 openspec_source_path = ( 

124 target / 'documentation' / 'architecture' / 'openspec' ) 

125 if not simulate: 

126 openspec_source_path.mkdir( parents = True, exist_ok = True ) 

127 _, symlink_name_openspec = _memorylinks.create_memory_symlink( 

128 openspec_source_path, openspec_link_path, simulate ) 

129 all_symlink_names.append( symlink_name_openspec ) 

130 _scribe.info( "Created 1/1 openspec symlink" ) 

131 return tuple( all_symlink_names ) 

132 

133 

134def _populate_instructions_if_configured( 

135 configuration: __.cabc.Mapping[ str, __.typx.Any ], 

136 target: __.Path, 

137 tag_prefix: __.Absential[ str ], 

138 simulate: bool, 

139) -> tuple[ bool, str ]: 

140 ''' Populates instructions if configured and returns status. 

141 

142 Returns tuple of (sources_present, instructions_target_path). 

143 sources_present indicates whether instruction sources were 

144 configured and processed. 

145 ''' 

146 if not configuration.get( 'provide_instructions', False ): 

147 return ( False, '' ) 

148 instructions_sources = configuration.get( 'instructions_sources', [ ] ) 

149 instructions_target = configuration.get( 

150 'instructions_target', '.auxiliary/instructions' ) 

151 if not instructions_sources: 

152 return ( False, instructions_target ) 

153 instructions_attempted, instructions_updated = ( 

154 _instructions.populate_instructions( 

155 instructions_sources, 

156 target / instructions_target, 

157 tag_prefix, 

158 simulate, 

159 ) ) 

160 _scribe.info( 

161 f"Updated {instructions_updated}/{instructions_attempted} " 

162 "instruction files" ) 

163 return ( True, instructions_target ) 

164 

165 

166def _populate_per_user_content( 

167 location: __.Path, 

168 coders: __.cabc.Sequence[ str ], 

169 configuration: __.cabc.Mapping[ str, __.typx.Any ], 

170 application_configuration: __.cabc.Mapping[ str, __.typx.Any ], 

171 simulate: bool, 

172) -> tuple[ int, int ]: 

173 ''' Populates commands and agents for per-user coders. 

174 

175 Generates content to each coder's per-user directory using 

176 renderer's resolve_base_directory() with per-user mode. 

177 Returns tuple of (items_attempted, items_generated). 

178 ''' 

179 items_attempted = 0 

180 items_generated = 0 

181 for coder_name in coders: 

182 try: renderer = _renderers.RENDERERS[ coder_name ] 

183 except KeyError as exception: 

184 raise _exceptions.CoderAbsence( coder_name ) from exception 

185 coder_configuration = { 'coders': [ coder_name ] } 

186 generator = _generator.ContentGenerator( 

187 location = location, 

188 configuration = coder_configuration, 

189 application_configuration = application_configuration, 

190 mode = 'per-user', 

191 ) 

192 target = renderer.resolve_base_directory( 

193 'per-user', __.Path.cwd( ), configuration, dict( __.os.environ ) ) 

194 attempted, generated = _operations.populate_directory( 

195 generator, target, simulate ) 

196 items_attempted += attempted 

197 items_generated += generated 

198 return ( items_attempted, items_generated ) 

199 

200 

201def _create_coder_directory_symlinks( 

202 coders: __.cabc.Sequence[ str ], 

203 target: __.Path, 

204 renderers: __.cabc.Mapping[ str, __.typx.Any ], 

205 simulate: bool = False, 

206) -> tuple[ int, int, tuple[ str, ... ] ]: 

207 ''' Creates symlinks from .{coder} to .auxiliary/configuration/coders/. 

208 

209 For per-project mode, creates symlinks that make coder directories 

210 accessible at their expected locations (.claude, .opencode, etc.) 

211 while keeping actual files organized under 

212 .auxiliary/configuration/coders/. 

213 

214 Each renderer is responsible for specifying its symlink requirements 

215 via provide_project_symlinks(). Population logic simply iterates 

216 coders and asks renderers for their symlinks. 

217 

218 Only creates symlinks for coders whose default mode is per-project. 

219 Coders with per-user default mode are skipped since they do not 

220 use per-project directories. 

221 

222 Returns tuple of (attempted, created, symlink_names) where 

223 symlink_names contains names of all symlinks (both newly created 

224 and pre-existing). 

225 ''' 

226 attempted = 0 

227 created = 0 

228 symlink_names: list[ str ] = [ ] 

229 

230 for coder_name in coders: 

231 try: renderer = renderers[ coder_name ] 

232 except KeyError as exception: 

233 raise _exceptions.CoderAbsence( coder_name ) from exception 

234 if renderer.mode_default != 'per-project': 

235 _scribe.debug( 

236 f"Skipping directory symlink for {coder_name}: " 

237 f"default mode is {renderer.mode_default}" ) 

238 continue 

239 for source, link_path in renderer.provide_project_symlinks( target ): 

240 attempted += 1 

241 was_created, symlink_name = ( 

242 _memorylinks.create_memory_symlink( 

243 source, link_path, simulate ) ) 

244 if was_created: created += 1 

245 symlink_names.append( symlink_name ) 

246 return ( attempted, created, tuple( symlink_names ) ) 

247 

248 

249def _copy_coder_resources( 

250 location: __.Path, 

251 target: __.Path, 

252 coders: __.cabc.Sequence[ str ], 

253 simulate: bool 

254) -> tuple[ int, int ]: 

255 ''' Copies static resources for coders. 

256 

257 Copies resources from defaults/per-project/resources/<coder> 

258 to target directory. Returns tuple of (coders_attempted, 

259 coders_processed). 

260 ''' 

261 resources_source = location / 'per-project' / 'resources' 

262 if not resources_source.exists( ): 

263 _scribe.debug( 

264 f"No per-project resources found at {resources_source}" ) 

265 return ( 0, 0 ) 

266 coders_target = target / '.auxiliary' / 'configuration' / 'coders' 

267 return _operations.copy_coder_resources( 

268 resources_source, coders_target, coders, simulate ) 

269 

270 

271def _manage_project_auxiliaries( 

272 configuration: __.cabc.Mapping[ str, __.typx.Any ], 

273 target: __.Path, 

274 tag_prefix: __.Absential[ str ], 

275 simulate: bool 

276) -> None: 

277 ''' Manages auxiliary project files (instructions, symlinks, excludes). ''' 

278 instructions_populated, instructions_target = ( 

279 _populate_instructions_if_configured( 

280 configuration, target, tag_prefix, simulate ) ) 

281 all_symlink_names: list[ str ] = list( _create_all_symlinks( 

282 configuration, target, 'per-project', simulate ) ) 

283 git_exclude_entries: list[ str ] = [ ] 

284 if instructions_populated: 

285 git_exclude_entries.append( instructions_target ) 

286 git_exclude_entries.extend( all_symlink_names ) 

287 if git_exclude_entries: 

288 entries_count = _operations.update_git_exclude( 

289 target, git_exclude_entries, simulate ) 

290 if entries_count > 0: 

291 _scribe.info( 

292 f"Managing {entries_count} entries in .git/info/exclude" ) 

293 

294 

295class PopulateProjectCommand( __.appcore_cli.Command ): 

296 ''' Generates project-scoped agent content from data sources. 

297 

298 Populates agent commands, definitions, and static resources 

299 from the specified data source. Copies static resources from 

300 defaults/per-project/resources/ to the project configuration. 

301 ''' 

302 

303 source: SourceArgument = '.' 

304 target: TargetArgument = __.dcls.field( default_factory = __.Path.cwd ) 

305 profile: __.typx.Annotated[ 

306 __.typx.Optional[ __.Path ], 

307 __.tyro.conf.arg( 

308 help = ( 

309 "Alternative Copier answers file (defaults to " 

310 "auto-detected)" ), 

311 prefix_name = False ), 

312 ] = None 

313 simulate: __.typx.Annotated[ 

314 bool, 

315 __.tyro.conf.arg( 

316 help = "Dry run mode - show generated content", 

317 prefix_name = False ), 

318 ] = False 

319 tag_prefix: __.typx.Annotated[ 

320 __.typx.Optional[ str ], 

321 __.tyro.conf.arg( 

322 help = ( 

323 "Prefix for version tags (e.g., 'v', 'stable-', 'prod-'); " 

324 "only tags with this prefix are considered and the prefix " 

325 "is stripped before version parsing" ), 

326 prefix_name = False ), 

327 ] = None 

328 

329 @_cmdbase.intercept_errors( ) 

330 async def execute( self, auxdata: __.appcore.state.Globals ) -> None: # pyright: ignore[reportIncompatibleMethodOverride] 

331 ''' Generates project content from data sources. ''' 

332 if not isinstance( auxdata, _core.Globals ): # pragma: no cover 

333 raise _exceptions.ContextInvalidity 

334 _scribe.info( 

335 f"Populating project content from {self.source} to {self.target}" ) 

336 configuration = await _cmdbase.retrieve_configuration( 

337 self.target, self.profile ) 

338 per_project_coders = _filter_coders_by_mode( 

339 configuration[ 'coders' ], 'per-project', _renderers.RENDERERS ) 

340 if not per_project_coders: 

341 _scribe.warning( 

342 "No per-project default coders found in configuration" ) 

343 return 

344 filtered_configuration = dict( configuration ) 

345 filtered_configuration[ 'coders' ] = per_project_coders 

346 prefix = __.absent if self.tag_prefix is None else self.tag_prefix 

347 location = _cmdbase.retrieve_data_location( self.source, prefix ) 

348 _cmdbase.validate_data_source_structure( 

349 location, 

350 ( 'configurations', 'contents', 'templates' ) ) 

351 generator = _generator.ContentGenerator( 

352 location = location, 

353 configuration = filtered_configuration, 

354 application_configuration = auxdata.configuration, 

355 mode = 'per-project', 

356 ) 

357 items_attempted, items_generated = _operations.populate_directory( 

358 generator, self.target, self.simulate ) 

359 _scribe.info( f"Generated {items_generated}/{items_attempted} items" ) 

360 resources_attempted, resources_copied = _copy_coder_resources( 

361 location, 

362 self.target, 

363 filtered_configuration[ 'coders' ], 

364 self.simulate ) 

365 if resources_copied > 0: 

366 _scribe.info( 

367 f"Copied resources for " 

368 f"{resources_copied}/{resources_attempted} coders" ) 

369 _manage_project_auxiliaries( 

370 filtered_configuration, self.target, prefix, self.simulate ) 

371 result = _results.ContentGenerationResult( 

372 source_location = location, 

373 target_location = self.target, 

374 coders = tuple( configuration[ 'coders' ] ), 

375 simulated = self.simulate, 

376 items_generated = items_generated, 

377 ) 

378 await _core.render_and_print_result( 

379 result, auxdata.display, auxdata.exits ) 

380 

381 

382class PopulateUserCommand( __.appcore_cli.Command ): 

383 ''' Populates per-user global settings and executables. ''' 

384 

385 source: SourceArgument = '.' 

386 profile: __.typx.Annotated[ 

387 __.typx.Optional[ __.Path ], 

388 __.tyro.conf.arg( 

389 help = ( 

390 "Alternative Copier answers file (defaults to " 

391 "auto-detected)" ), 

392 prefix_name = False ), 

393 ] = None 

394 simulate: __.typx.Annotated[ 

395 bool, 

396 __.tyro.conf.arg( 

397 help = "Dry run mode - show what would be installed", 

398 prefix_name = False ), 

399 ] = False 

400 tag_prefix: __.typx.Annotated[ 

401 __.typx.Optional[ str ], 

402 __.tyro.conf.arg( 

403 help = ( 

404 "Prefix for version tags (e.g., 'v', 'stable-', 'prod-'); " 

405 "only tags with this prefix are considered and the prefix " 

406 "is stripped before version parsing" ), 

407 prefix_name = False ), 

408 ] = None 

409 

410 @_cmdbase.intercept_errors( ) 

411 async def execute( self, auxdata: __.appcore.state.Globals ) -> None: # pyright: ignore[reportIncompatibleMethodOverride] 

412 ''' Populates user-scoped settings and executables. ''' 

413 if not isinstance( auxdata, _core.Globals ): # pragma: no cover 

414 raise _exceptions.ContextInvalidity 

415 _scribe.info( f"Populating user configuration from {self.source}" ) 

416 configuration = await _cmdbase.retrieve_configuration( 

417 __.Path.cwd( ), self.profile ) 

418 per_user_coders = _filter_coders_by_mode( 

419 configuration[ 'coders' ], 'per-user', _renderers.RENDERERS ) 

420 if not per_user_coders: 

421 _scribe.warning( 

422 "No per-user default coders found in configuration" ) 

423 return 

424 prefix = __.absent if self.tag_prefix is None else self.tag_prefix 

425 location = _cmdbase.retrieve_data_location( self.source, prefix ) 

426 _cmdbase.validate_data_source_structure( 

427 location, 

428 ( 'configurations', 'contents', 'templates', 

429 'user/configurations', 'user/executables' ) ) 

430 content_attempted, content_generated = _populate_per_user_content( 

431 location, 

432 per_user_coders, 

433 configuration, 

434 auxdata.configuration, 

435 self.simulate, 

436 ) 

437 if content_attempted > 0: 

438 _scribe.info( 

439 f"Generated {content_generated}/{content_attempted} items" ) 

440 globals_attempted, globals_updated = _userdata.populate_globals( 

441 location, 

442 per_user_coders, 

443 auxdata.configuration, 

444 self.simulate, 

445 ) 

446 _scribe.info( 

447 f"Updated {globals_updated}/{globals_attempted} global files" ) 

448 wrappers_attempted, wrappers_installed = ( 

449 _userdata.populate_user_wrappers( location, self.simulate ) ) 

450 if wrappers_attempted > 0: 

451 _scribe.info( 

452 f"Installed {wrappers_installed}/{wrappers_attempted} " 

453 "wrapper scripts" ) 

454 total_items = content_generated + globals_updated + wrappers_installed 

455 result = _results.ContentGenerationResult( 

456 source_location = location, 

457 target_location = __.Path.home( ), 

458 coders = per_user_coders, 

459 simulated = self.simulate, 

460 items_generated = total_items, 

461 ) 

462 await _core.render_and_print_result( 

463 result, auxdata.display, auxdata.exits ) 

464 

465 

466class PopulateCommand( __.appcore_cli.Command ): 

467 ''' Populates agent content and configuration. ''' 

468 

469 command: __.typx.Union[ 

470 __.typx.Annotated[ 

471 PopulateProjectCommand, 

472 __.tyro.conf.subcommand( 'project', prefix_name = False ), 

473 ], 

474 __.typx.Annotated[ 

475 PopulateUserCommand, 

476 __.tyro.conf.subcommand( 'user', prefix_name = False ), 

477 ], 

478 ] = __.dcls.field( default_factory = PopulateProjectCommand ) 

479 

480 async def execute( self, auxdata: __.appcore.state.Globals ) -> None: # pyright: ignore[reportIncompatibleMethodOverride] 

481 await self.command( auxdata )