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

117 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-22 02:08 +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 try: renderer = _renderers.RENDERERS[ coder ] 

136 except KeyError as exception: 

137 raise _exceptions.CoderAbsence( coder ) from exception 

138 target_dir_name = renderer.calculate_directory_location( item_type ) 

139 # Always plural (e.g., commands, agents) 

140 source_dir = directory / item_type 

141 if not source_dir.exists(): 

142 raise _exceptions.TemplateError.for_missing_template( 

143 coder, item_type 

144 ) 

145 templates = [ 

146 f"{target_dir_name}/{p.name}" 

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

148 ] 

149 if not templates: 

150 raise _exceptions.TemplateError.for_missing_template( 

151 coder, item_type 

152 ) 

153 return templates 

154 

155 def _retrieve_content_with_fallback( 

156 self, item_type: str, item_name: str, coder: str 

157 ) -> str: 

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

159 

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

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

162 ''' 

163 primary_path = ( 

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

165 f"{item_name}.md" ) 

166 if primary_path.exists( ): 

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

168 fallback_mappings = self._retrieve_fallback_mappings( ) 

169 fallback_coder = fallback_mappings.get( coder ) 

170 if fallback_coder: 

171 fallback_path = ( 

172 self.location / "contents" / item_type / 

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

174 if fallback_path.exists( ): 

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

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

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

178 

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

180 ''' Extracts output extension from template filename. 

181 

182 Template names follow pattern: item.extension.jinja 

183 This extracts the middle component as output extension. 

184 ''' 

185 parts = template_name.split( '.' ) 

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

187 return parts[ -2 ] 

188 raise _exceptions.TemplateError.for_extension_parse( template_name ) 

189 

190 def _load_item_metadata( 

191 self, item_type: str, item_name: str, coder: str 

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

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

194 

195 Reads item configuration file and separates context fields 

196 from coder-specific configuration. 

197 ''' 

198 configuration_file = ( 

199 self.location / 'configurations' / item_type 

200 / f"{item_name}.toml" ) 

201 if not configuration_file.exists( ): 

202 raise _exceptions.ConfigurationAbsence( configuration_file ) 

203 try: toml_content = configuration_file.read_bytes( ) 

204 except ( OSError, IOError ) as exception: 

205 raise _exceptions.ConfigurationAbsence( ) from exception 

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

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

208 except __.tomli.TOMLDecodeError as exception: 

209 raise _exceptions.ConfigurationInvalidity( 

210 exception 

211 ) from exception 

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

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

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

215 # Normalize coders table array to dict keyed by name 

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

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

218 for entry in coders_list: 

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

220 name_value = entry.get( 'name' ) 

221 if not isinstance( name_value, str ): continue 

222 coders_dict[ name_value ] = entry 

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

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

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

226 

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

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

229 

230 Creates new Jinja2 environment instance with FileSystemLoader 

231 pointing to data source templates directory. 

232 ''' 

233 directory = self.location / "templates" 

234 loader = _jinja2.FileSystemLoader( directory ) 

235 return _jinja2.Environment( 

236 loader = loader, 

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

238 ) 

239 

240 

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

242 try: renderer = _renderers.RENDERERS[ coder ] 

243 except KeyError as exception: 

244 raise _exceptions.CoderAbsence( coder ) from exception 

245 flavor = renderer.get_template_flavor( item_type ) 

246 available = self._survey_available_templates( item_type, coder ) 

247 directory_name = renderer.calculate_directory_location( item_type ) 

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

249 organized_path = ( 

250 f"{directory_name}/{flavor}.{extension}.jinja" ) 

251 if organized_path in available: 

252 return organized_path 

253 raise _exceptions.TemplateError.for_missing_template( 

254 coder, item_type 

255 )