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

106 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-13 00:43 +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 base as _base 

33from . import context as _context 

34 

35 

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

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

38 

39_TEMPLATE_PARTS_MINIMUM = 3 

40 

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

42 'commands': 'command', 

43 'agents': 'agent', 

44} ) 

45 

46 

47_scribe = __.provide_scribe( __name__ ) 

48 

49 

50class RenderedItem( __.immut.DataclassObject ): 

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

52 

53 content: str 

54 location: __.Path 

55 

56 

57class ContentGenerator( __.immut.DataclassObject ): 

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

59 

60 Provides template-based content generation with intelligent 

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

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

63 ''' 

64 

65 location: __.Path 

66 configuration: _base.CoderConfiguration 

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

68 __.dcls.field( 

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

70 mode: __.TargetMode = 'per-project' 

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

72 

73 def __post_init__( self ) -> None: 

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

75 self._produce_jinja_environment( ) ) 

76 

77 

78 def _retrieve_fallback_mappings( self ) -> CoderFallbackMap: 

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

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

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

82 return __.immut.Dictionary( fallbacks ) 

83 

84 def render_single_item( 

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

86 ) -> RenderedItem: 

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

88 

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

90 final coder-specific file. Returns RenderedItem with content 

91 and location. 

92 ''' 

93 try: renderer = __.RENDERERS[ coder ] 

94 except KeyError as exception: 

95 raise __.CoderAbsence( coder ) from exception 

96 if self.mode == 'default': 

97 actual_mode = renderer.mode_default 

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

99 actual_mode = self.mode 

100 renderer.validate_mode( actual_mode ) 

101 else: 

102 raise __.TargetModeNoSupport( coder, self.mode ) 

103 body = self._retrieve_content_with_fallback( 

104 item_type, item_name, coder ) 

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

106 template_name = self._select_template_for_coder( item_type, coder ) 

107 template = self.jinja_environment.get_template( template_name ) 

108 normalized = _context.normalize_render_context( 

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

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

111 'content': body, 

112 **normalized, 

113 } 

114 content = template.render( **variables ) 

115 extension = self._parse_template_extension( template_name ) 

116 base_directory = renderer.resolve_base_directory( 

117 mode = actual_mode, 

118 target = target, 

119 configuration = self.application_configuration, 

120 environment = __.os.environ, 

121 ) 

122 dirname = renderer.produce_output_structure( item_type ) 

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

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

125 

126 def _survey_available_templates( self, item_type: str ) -> list[ str ]: 

127 ''' Lists available templates for item type. 

128 

129 Surveys template directory for files matching item type pattern. 

130 ''' 

131 directory = self.location / "templates" 

132 try: singular_type = _PLURAL_TO_SINGULAR_MAP[ item_type ] 

133 except KeyError as exception: 

134 raise __.ConfigurationInvalidity( item_type ) from exception 

135 pattern = f"{singular_type}.*.jinja" 

136 return [ p.name for p in directory.glob( pattern ) ] 

137 

138 def _retrieve_content_with_fallback( 

139 self, item_type: str, item_name: str, coder: str 

140 ) -> str: 

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

142 

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

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

145 ''' 

146 primary_path = ( 

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

148 f"{item_name}.md" ) 

149 if primary_path.exists( ): 

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

151 fallback_mappings = self._retrieve_fallback_mappings( ) 

152 fallback_coder = fallback_mappings.get( coder ) 

153 if fallback_coder: 

154 fallback_path = ( 

155 self.location / "contents" / item_type / 

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

157 if fallback_path.exists( ): 

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

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

160 raise __.ContentAbsence( item_type, item_name, coder ) 

161 

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

163 ''' Extracts output extension from template filename. 

164 

165 Template names follow pattern: item.extension.jinja 

166 This extracts the middle component as output extension. 

167 ''' 

168 parts = template_name.split( '.' ) 

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

170 return parts[ -2 ] 

171 raise __.TemplateError.for_extension_parse( template_name ) 

172 

173 def _load_item_metadata( 

174 self, item_type: str, item_name: str, coder: str 

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

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

177 

178 Reads item configuration file and separates context fields 

179 from coder-specific configuration. 

180 ''' 

181 configuration_file = ( 

182 self.location / 'configurations' / item_type 

183 / f"{item_name}.toml" ) 

184 if not configuration_file.exists( ): 

185 raise __.ConfigurationAbsence( configuration_file ) 

186 try: toml_content = configuration_file.read_bytes( ) 

187 except ( OSError, IOError ) as exception: 

188 raise __.ConfigurationAbsence( ) from exception 

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

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

191 except __.tomli.TOMLDecodeError as exception: 

192 raise __.ConfigurationInvalidity( exception ) from exception 

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

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

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

196 # Normalize coders table array to dict keyed by name 

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

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

199 for entry in coders_list: 

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

201 name_value = entry.get( 'name' ) 

202 if not isinstance( name_value, str ): continue 

203 coders_dict[ name_value ] = entry 

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

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

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

207 

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

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

210 

211 Creates new Jinja2 environment instance with FileSystemLoader 

212 pointing to data source templates directory. 

213 ''' 

214 directory = self.location / "templates" 

215 loader = _jinja2.FileSystemLoader( directory ) 

216 return _jinja2.Environment( 

217 loader = loader, 

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

219 ) 

220 

221 

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

223 ''' Selects appropriate template based on coder capabilities. 

224 

225 Maps coder to preferred template format. Claude and OpenCode 

226 use markdown templates, while Gemini uses TOML templates. 

227 ''' 

228 available = self._survey_available_templates( item_type ) 

229 try: singular_type = _PLURAL_TO_SINGULAR_MAP[ item_type ] 

230 except KeyError as exception: 

231 raise __.ConfigurationInvalidity( item_type ) from exception 

232 preferences = { 

233 "claude": [ f"{singular_type}.md.jinja" ], 

234 "opencode": [ f"{singular_type}.md.jinja" ], 

235 "gemini": [ f"{singular_type}.toml.jinja" ], 

236 } 

237 for preferred in preferences.get( coder, [ ] ): 

238 if preferred in available: 

239 return preferred 

240 if coder not in preferences: 

241 raise __.CoderAbsence( coder ) 

242 raise __.TemplateError.for_missing_template( coder, item_type )