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

144 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-28 17:44 +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 tuple 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 = ( 

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 ) 

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 return tuple( all_symlink_names ) 

123 

124 

125def _populate_instructions_if_configured( 

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

127 target: __.Path, 

128 tag_prefix: __.Absential[ str ], 

129 simulate: bool, 

130) -> tuple[ bool, str ]: 

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

132 

133 Returns tuple of (sources_present, instructions_target_path). 

134 sources_present indicates whether instruction sources were 

135 configured and processed. 

136 ''' 

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

138 return ( False, '' ) 

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

140 instructions_target = configuration.get( 

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

142 if not instructions_sources: 

143 return ( False, instructions_target ) 

144 instructions_attempted, instructions_updated = ( 

145 _instructions.populate_instructions( 

146 instructions_sources, 

147 target / instructions_target, 

148 tag_prefix, 

149 simulate, 

150 ) ) 

151 _scribe.info( 

152 f"Updated {instructions_updated}/" 

153 f"{instructions_attempted} instruction files" ) 

154 return ( True, instructions_target ) 

155 

156 

157def _populate_per_user_content( 

158 location: __.Path, 

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

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

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

162 simulate: bool, 

163) -> tuple[ int, int ]: 

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

165 

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

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

168 Returns tuple of (items_attempted, items_generated). 

169 ''' 

170 items_attempted = 0 

171 items_generated = 0 

172 for coder_name in coders: 

173 try: renderer = _renderers.RENDERERS[ coder_name ] 

174 except KeyError as exception: 

175 raise _exceptions.CoderAbsence( coder_name ) from exception 

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

177 generator = _generator.ContentGenerator( 

178 location = location, 

179 configuration = coder_configuration, 

180 application_configuration = application_configuration, 

181 mode = 'per-user', 

182 ) 

183 target = renderer.resolve_base_directory( 

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

185 attempted, generated = _operations.populate_directory( 

186 generator, target, simulate ) 

187 items_attempted += attempted 

188 items_generated += generated 

189 return ( items_attempted, items_generated ) 

190 

191 

192def _create_coder_directory_symlinks( 

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

194 target: __.Path, 

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

196 simulate: bool = False, 

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

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

199 

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

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

202 while keeping actual files organized under 

203 .auxiliary/configuration/coders/. 

204 

205 Each renderer is responsible for specifying its symlink requirements 

206 via provide_project_symlinks(). Population logic simply iterates 

207 coders and asks renderers for their symlinks. 

208 

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

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

211 use per-project directories. 

212 

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

214 symlink_names contains names of all symlinks (both newly created 

215 and pre-existing). 

216 ''' 

217 attempted = 0 

218 created = 0 

219 symlink_names: list[ str ] = [ ] 

220 

221 for coder_name in coders: 

222 try: renderer = renderers[ coder_name ] 

223 except KeyError as exception: 

224 raise _exceptions.CoderAbsence( coder_name ) from exception 

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

226 _scribe.debug( 

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

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

229 continue 

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

231 attempted += 1 

232 was_created, symlink_name = ( 

233 _memorylinks.create_memory_symlink( 

234 source, link_path, simulate ) ) 

235 if was_created: created += 1 

236 symlink_names.append( symlink_name ) 

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

238 

239 

240class PopulateProjectCommand( __.appcore_cli.Command ): 

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

242 

243 source: SourceArgument = '.' 

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

245 profile: __.typx.Annotated[ 

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

247 __.tyro.conf.arg( 

248 help = ( 

249 "Alternative Copier answers file (defaults to " 

250 "auto-detected)" ), 

251 prefix_name = False ), 

252 ] = None 

253 simulate: __.typx.Annotated[ 

254 bool, 

255 __.tyro.conf.arg( 

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

257 prefix_name = False ), 

258 ] = False 

259 tag_prefix: __.typx.Annotated[ 

260 __.typx.Optional[ str ], 

261 __.tyro.conf.arg( 

262 help = ( 

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

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

265 "is stripped before version parsing" ), 

266 prefix_name = False ), 

267 ] = None 

268 

269 @_cmdbase.intercept_errors( ) 

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

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

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

273 raise _exceptions.ContextInvalidity 

274 _scribe.info( 

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

276 configuration = await _cmdbase.retrieve_configuration( 

277 self.target, self.profile ) 

278 per_project_coders = _filter_coders_by_mode( 

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

280 if not per_project_coders: 

281 _scribe.warning( 

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

283 return 

284 filtered_configuration = dict( configuration ) 

285 filtered_configuration[ 'coders' ] = per_project_coders 

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

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

288 _cmdbase.validate_data_source_structure( 

289 location, 

290 ( 'configurations', 'contents', 'templates' ) ) 

291 generator = _generator.ContentGenerator( 

292 location = location, 

293 configuration = filtered_configuration, 

294 application_configuration = auxdata.configuration, 

295 mode = 'per-project', 

296 ) 

297 items_attempted, items_generated = _operations.populate_directory( 

298 generator, self.target, self.simulate ) 

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

300 instructions_populated, instructions_target = ( 

301 _populate_instructions_if_configured( 

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

303 all_symlink_names = _create_all_symlinks( 

304 filtered_configuration, self.target, 'per-project', self.simulate ) 

305 git_exclude_entries: list[ str ] = [ ] 

306 if instructions_populated: 

307 git_exclude_entries.append( instructions_target ) 

308 git_exclude_entries.extend( all_symlink_names ) 

309 if git_exclude_entries: 

310 entries_count = _operations.update_git_exclude( 

311 self.target, git_exclude_entries, self.simulate ) 

312 if entries_count > 0: 

313 _scribe.info( 

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

315 result = _results.ContentGenerationResult( 

316 source_location = location, 

317 target_location = self.target, 

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

319 simulated = self.simulate, 

320 items_generated = items_generated, 

321 ) 

322 await _core.render_and_print_result( 

323 result, auxdata.display, auxdata.exits ) 

324 

325 

326class PopulateUserCommand( __.appcore_cli.Command ): 

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

328 

329 source: SourceArgument = '.' 

330 profile: __.typx.Annotated[ 

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

332 __.tyro.conf.arg( 

333 help = ( 

334 "Alternative Copier answers file (defaults to " 

335 "auto-detected)" ), 

336 prefix_name = False ), 

337 ] = None 

338 simulate: __.typx.Annotated[ 

339 bool, 

340 __.tyro.conf.arg( 

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

342 prefix_name = False ), 

343 ] = False 

344 tag_prefix: __.typx.Annotated[ 

345 __.typx.Optional[ str ], 

346 __.tyro.conf.arg( 

347 help = ( 

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

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

350 "is stripped before version parsing" ), 

351 prefix_name = False ), 

352 ] = None 

353 

354 @_cmdbase.intercept_errors( ) 

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

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

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

358 raise _exceptions.ContextInvalidity 

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

360 configuration = await _cmdbase.retrieve_configuration( 

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

362 per_user_coders = _filter_coders_by_mode( 

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

364 if not per_user_coders: 

365 _scribe.warning( 

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

367 return 

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

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

370 _cmdbase.validate_data_source_structure( 

371 location, 

372 ( 'configurations', 'contents', 'templates', 

373 'user/configurations', 'user/executables' ) ) 

374 content_attempted, content_generated = _populate_per_user_content( 

375 location, 

376 per_user_coders, 

377 configuration, 

378 auxdata.configuration, 

379 self.simulate, 

380 ) 

381 if content_attempted > 0: 

382 _scribe.info( 

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

384 globals_attempted, globals_updated = _userdata.populate_globals( 

385 location, 

386 per_user_coders, 

387 auxdata.configuration, 

388 self.simulate, 

389 ) 

390 _scribe.info( 

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

392 wrappers_attempted, wrappers_installed = ( 

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

394 if wrappers_attempted > 0: 

395 _scribe.info( 

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

397 "wrapper scripts" ) 

398 total_items = content_generated + globals_updated + wrappers_installed 

399 result = _results.ContentGenerationResult( 

400 source_location = location, 

401 target_location = __.Path.home( ), 

402 coders = per_user_coders, 

403 simulated = self.simulate, 

404 items_generated = total_items, 

405 ) 

406 await _core.render_and_print_result( 

407 result, auxdata.display, auxdata.exits ) 

408 

409 

410class PopulateCommand( __.appcore_cli.Command ): 

411 ''' Populates agent content and configuration. ''' 

412 

413 command: __.typx.Union[ 

414 __.typx.Annotated[ 

415 PopulateProjectCommand, 

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

417 ], 

418 __.typx.Annotated[ 

419 PopulateUserCommand, 

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

421 ], 

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

423 

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

425 await self.command( auxdata )