Coverage for sources / agentsmgr / generator.py: 20%
119 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-01 15:37 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-01 15:37 +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 cmdbase as _cmdbase
33from . import context as _context
34from . import exceptions as _exceptions
35from . import renderers as _renderers
38CoderFallbackMap: __.typx.TypeAlias = __.immut.Dictionary[ str, str ]
39PluralMappings: __.typx.TypeAlias = __.immut.Dictionary[ str, str ]
41_TEMPLATE_PARTS_MINIMUM = 3
43_PLURAL_TO_SINGULAR_MAP: PluralMappings = __.immut.Dictionary( {
44 'commands': 'command',
45 'agents': 'agent',
46} )
49_scribe = __.provide_scribe( __name__ )
52class RenderedItem( __.immut.DataclassObject ):
53 ''' Single rendered item with location and content. '''
55 content: str
56 location: __.Path
59class ContentGenerator( __.immut.DataclassObject ):
60 ''' Generates coder-specific content from data sources.
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 '''
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 )
75 def __post_init__( self ) -> None:
76 self.jinja_environment = ( # pyright: ignore[reportAttributeAccessIssue]
77 self._produce_jinja_environment( ) )
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 )
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.
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 )
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
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.
159 Returns tuple of (primary_path, fallback_path) where fallback_path
160 is None if no fallback coder is configured.
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 )
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.
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 )
195 def _parse_template_extension( self, template_name: str ) -> str:
196 ''' Extracts output extension from template filename.
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 )
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.
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 }
243 def _produce_jinja_environment( self ) -> _jinja2.Environment:
244 ''' Produces Jinja2 environment configured for templates directory.
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 )
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 )