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

119 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-30 00:03 +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''' Content generation for coder-specific templates and items. 

22 

23 This module implements the ContentGenerator class which handles 

24 template-based content generation from structured data sources, 

25 including content fallback logic for compatible coders. 

26''' 

27 

28 

29import jinja2 as _jinja2 

30 

31from . import __ 

32from . import cmdbase as _cmdbase 

33from . import context as _context 

34from . import exceptions as _exceptions 

35from . import renderers as _renderers 

36 

37 

38CoderFallbackMap: __.typx.TypeAlias = __.immut.Dictionary[ str, str ] 

39PluralMappings: __.typx.TypeAlias = __.immut.Dictionary[ str, str ] 

40 

41_TEMPLATE_PARTS_MINIMUM = 3 

42 

43_PLURAL_TO_SINGULAR_MAP: PluralMappings = __.immut.Dictionary( { 

44 'commands': 'command', 

45 'agents': 'agent', 

46} ) 

47 

48 

49_scribe = __.provide_scribe( __name__ ) 

50 

51 

52class RenderedItem( __.immut.DataclassObject ): 

53 ''' Single rendered item with location and content. ''' 

54 

55 content: str 

56 location: __.Path 

57 

58 

59class ContentGenerator( __.immut.DataclassObject ): 

60 ''' Generates coder-specific content from data sources. 

61 

62 Provides template-based content generation with intelligent 

63 fallback logic for compatible coders (Claude ↔ OpenCode). 

64 Supports configurable targeting modes (per-user or per-project). 

65 ''' 

66 

67 location: __.Path 

68 configuration: _cmdbase.CoderConfiguration 

69 application_configuration: __.cabc.Mapping[ str, __.typx.Any ] = ( 

70 __.dcls.field( 

71 default_factory = __.immut.Dictionary[ str, __.typx.Any ] ) ) 

72 mode: _renderers.TargetMode = 'per-project' 

73 jinja_environment: _jinja2.Environment = __.dcls.field( init = False ) 

74 

75 def __post_init__( self ) -> None: 

76 self.jinja_environment = ( # pyright: ignore[reportAttributeAccessIssue] 

77 self._produce_jinja_environment( ) ) 

78 

79 

80 def _retrieve_fallback_mappings( self ) -> CoderFallbackMap: 

81 ''' Retrieves coder fallback mappings from configuration. ''' 

82 content_config = self.application_configuration.get( 'content', { } ) 

83 fallbacks = content_config.get( 'fallbacks', { } ) 

84 return __.immut.Dictionary( fallbacks ) 

85 

86 def render_single_item( 

87 self, item_type: str, item_name: str, coder: str, target: __.Path 

88 ) -> RenderedItem: 

89 ''' Renders a single item (command or agent) for a coder. 

90 

91 Combines TOML metadata, content body, and template to produce 

92 final coder-specific file. Returns RenderedItem with content 

93 and location. 

94 ''' 

95 try: renderer = _renderers.RENDERERS[ coder ] 

96 except KeyError as exception: 

97 raise _exceptions.CoderAbsence( coder ) from exception 

98 if self.mode == 'default': 

99 actual_mode = renderer.mode_default 

100 elif self.mode in ( 'per-user', 'per-project' ): 

101 actual_mode = self.mode 

102 renderer.validate_mode( actual_mode ) 

103 else: 

104 raise _exceptions.TargetModeNoSupport( coder, self.mode ) 

105 body = self._retrieve_content_with_fallback( 

106 item_type, item_name, coder ) 

107 metadata = self._load_item_metadata( item_type, item_name, coder ) 

108 template_name = self._select_template_for_coder( item_type, coder ) 

109 template = self.jinja_environment.get_template( template_name ) 

110 normalized = _context.normalize_render_context( 

111 metadata[ 'context' ], metadata[ 'coder' ] ) 

112 variables: dict[ str, __.typx.Any ] = { 

113 'content': body, 

114 **normalized, 

115 } 

116 content = template.render( **variables ) 

117 extension = self._parse_template_extension( template_name ) 

118 base_directory = renderer.resolve_base_directory( 

119 mode = actual_mode, 

120 target = target, 

121 configuration = self.application_configuration, 

122 environment = __.os.environ, 

123 ) 

124 category = metadata[ 'context' ].get( 'category' ) 

125 if category is None: 

126 category = __.absent 

127 dirname = renderer.produce_output_structure( item_type, category ) 

128 location = base_directory / dirname / f"{item_name}.{extension}" 

129 return RenderedItem( content = content, location = location ) 

130 

131 def _survey_available_templates( 

132 self, item_type: str, coder: str 

133 ) -> list[ str ]: 

134 directory = self.location / "templates" 

135 # Validate coder exists in registry 

136 if coder not in _renderers.RENDERERS: 

137 raise _exceptions.CoderAbsence( coder ) 

138 # Template directories always use plural form (commands, agents) 

139 source_dir = directory / item_type 

140 if not source_dir.exists(): 

141 raise _exceptions.TemplateError.for_missing_template( 

142 coder, item_type 

143 ) 

144 templates = [ 

145 f"{item_type}/{p.name}" 

146 for p in source_dir.glob( "*.jinja" ) 

147 ] 

148 if not templates: 

149 raise _exceptions.TemplateError.for_missing_template( 

150 coder, item_type 

151 ) 

152 return templates 

153 

154 def resolve_content_paths( 

155 self, item_type: str, item_name: str, coder: str 

156 ) -> tuple[ __.Path, __.typx.Optional[ __.Path ] ]: 

157 ''' Resolves primary and fallback content paths. 

158 

159 Returns tuple of (primary_path, fallback_path) where fallback_path 

160 is None if no fallback coder is configured. 

161 

162 This method is public to allow operations module to pre-check 

163 content availability without loading files. 

164 ''' 

165 primary_path = ( 

166 self.location / "contents" / item_type / coder / 

167 f"{item_name}.md" ) 

168 fallback_path = None 

169 fallback_mappings = self._retrieve_fallback_mappings( ) 

170 fallback_coder = fallback_mappings.get( coder ) 

171 if fallback_coder: 

172 fallback_path = ( 

173 self.location / "contents" / item_type / 

174 fallback_coder / f"{item_name}.md" ) 

175 return ( primary_path, fallback_path ) 

176 

177 def _retrieve_content_with_fallback( 

178 self, item_type: str, item_name: str, coder: str 

179 ) -> str: 

180 ''' Retrieves content with fallback logic for compatible coders. 

181 

182 Attempts to read content from coder-specific location first, 

183 then falls back to compatible coder if content is missing. 

184 ''' 

185 primary_path, fallback_path = self.resolve_content_paths( 

186 item_type, item_name, coder ) 

187 if primary_path.exists( ): 

188 return primary_path.read_text( encoding = 'utf-8' ) 

189 if fallback_path and fallback_path.exists( ): 

190 fallback_coder = self._retrieve_fallback_mappings( ).get( coder ) 

191 _scribe.debug( f"Using {fallback_coder} content for {coder}" ) 

192 return fallback_path.read_text( encoding = 'utf-8' ) 

193 raise _exceptions.ContentAbsence( item_type, item_name, coder ) 

194 

195 def _parse_template_extension( self, template_name: str ) -> str: 

196 ''' Extracts output extension from template filename. 

197 

198 Template names follow pattern: item.extension.jinja 

199 This extracts the middle component as output extension. 

200 ''' 

201 parts = template_name.split( '.' ) 

202 if len( parts ) >= _TEMPLATE_PARTS_MINIMUM and parts[ -1 ] == 'jinja': 

203 return parts[ -2 ] 

204 raise _exceptions.TemplateError.for_extension_parse( template_name ) 

205 

206 def _load_item_metadata( 

207 self, item_type: str, item_name: str, coder: str 

208 ) -> dict[ str, __.typx.Any ]: 

209 ''' Loads TOML metadata and extracts context and coder config. 

210 

211 Reads item configuration file and separates context fields 

212 from coder-specific configuration. 

213 ''' 

214 configuration_file = ( 

215 self.location / 'configurations' / item_type 

216 / f"{item_name}.toml" ) 

217 if not configuration_file.exists( ): 

218 raise _exceptions.ConfigurationAbsence( configuration_file ) 

219 try: toml_content = configuration_file.read_bytes( ) 

220 except ( OSError, IOError ) as exception: 

221 raise _exceptions.ConfigurationAbsence( ) from exception 

222 try: toml_data: dict[ str, __.typx.Any ] = __.tomli.loads( 

223 toml_content.decode( 'utf-8' ) ) 

224 except __.tomli.TOMLDecodeError as exception: 

225 raise _exceptions.ConfigurationInvalidity( 

226 exception 

227 ) from exception 

228 context = toml_data.get( 'context', { } ) 

229 coders_list: list[ dict[ str, __.typx.Any ] ] = ( 

230 toml_data.get( 'coders', [ ] ) ) 

231 # Normalize coders table array to dict keyed by name 

232 # TOML [[coders]] tables are optional; minimal config if absent 

233 coders_dict: dict[ str, dict[ str, __.typx.Any ] ] = { } 

234 for entry in coders_list: 

235 if not isinstance( entry, __.cabc.Mapping ): continue 

236 name_value = entry.get( 'name' ) 

237 if not isinstance( name_value, str ): continue 

238 coders_dict[ name_value ] = entry 

239 # Look up coder config from YAML, fallback to minimal config 

240 coder_config = coders_dict.get( coder, { 'name': coder } ) 

241 return { 'context': context, 'coder': coder_config } 

242 

243 def _produce_jinja_environment( self ) -> _jinja2.Environment: 

244 ''' Produces Jinja2 environment configured for templates directory. 

245 

246 Creates new Jinja2 environment instance with FileSystemLoader 

247 pointing to data source templates directory. 

248 ''' 

249 directory = self.location / "templates" 

250 loader = _jinja2.FileSystemLoader( directory ) 

251 return _jinja2.Environment( 

252 loader = loader, 

253 autoescape = False, # noqa: S701 Markdown output, not HTML 

254 ) 

255 

256 

257 def _select_template_for_coder( self, item_type: str, coder: str ) -> str: 

258 try: renderer = _renderers.RENDERERS[ coder ] 

259 except KeyError as exception: 

260 raise _exceptions.CoderAbsence( coder ) from exception 

261 flavor = renderer.get_template_flavor( item_type ) 

262 available = self._survey_available_templates( item_type, coder ) 

263 # Template paths always use plural item_type (commands, agents) 

264 for extension in [ 'md', 'toml' ]: 

265 organized_path = ( 

266 f"{item_type}/{flavor}.{extension}.jinja" ) 

267 if organized_path in available: 

268 return organized_path 

269 raise _exceptions.TemplateError.for_missing_template( 

270 coder, item_type 

271 )