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
« 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 -*-
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 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
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.
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 )
179 def _parse_template_extension( self, template_name: str ) -> str:
180 ''' Extracts output extension from template filename.
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 )
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.
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 }
227 def _produce_jinja_environment( self ) -> _jinja2.Environment:
228 ''' Produces Jinja2 environment configured for templates directory.
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 )
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 )