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
« 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 -*-
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#============================================================================#
21''' Content generation for coder-specific templates and items.
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'''
29import jinja2 as _jinja2
31from . import __
32from . import base as _base
33from . import context as _context
36CoderFallbackMap: __.typx.TypeAlias = __.immut.Dictionary[ str, str ]
37PluralMappings: __.typx.TypeAlias = __.immut.Dictionary[ str, str ]
39_TEMPLATE_PARTS_MINIMUM = 3
41_PLURAL_TO_SINGULAR_MAP: PluralMappings = __.immut.Dictionary( {
42 'commands': 'command',
43 'agents': 'agent',
44} )
47_scribe = __.provide_scribe( __name__ )
50class RenderedItem( __.immut.DataclassObject ):
51 ''' Single rendered item with location and content. '''
53 content: str
54 location: __.Path
57class ContentGenerator( __.immut.DataclassObject ):
58 ''' Generates coder-specific content from data sources.
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 '''
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 )
73 def __post_init__( self ) -> None:
74 self.jinja_environment = ( # pyright: ignore[reportAttributeAccessIssue]
75 self._produce_jinja_environment( ) )
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 )
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.
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 )
126 def _survey_available_templates( self, item_type: str ) -> list[ str ]:
127 ''' Lists available templates for item type.
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 ) ]
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.
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 )
162 def _parse_template_extension( self, template_name: str ) -> str:
163 ''' Extracts output extension from template filename.
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 )
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.
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 }
208 def _produce_jinja_environment( self ) -> _jinja2.Environment:
209 ''' Produces Jinja2 environment configured for templates directory.
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 )
222 def _select_template_for_coder( self, item_type: str, coder: str ) -> str:
223 ''' Selects appropriate template based on coder capabilities.
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 )