ostruct-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ostruct/__init__.py +0 -0
- ostruct/cli/__init__.py +19 -0
- ostruct/cli/cache_manager.py +175 -0
- ostruct/cli/cli.py +2033 -0
- ostruct/cli/errors.py +329 -0
- ostruct/cli/file_info.py +316 -0
- ostruct/cli/file_list.py +151 -0
- ostruct/cli/file_utils.py +518 -0
- ostruct/cli/path_utils.py +123 -0
- ostruct/cli/progress.py +105 -0
- ostruct/cli/security.py +311 -0
- ostruct/cli/security_types.py +49 -0
- ostruct/cli/template_env.py +55 -0
- ostruct/cli/template_extensions.py +51 -0
- ostruct/cli/template_filters.py +650 -0
- ostruct/cli/template_io.py +261 -0
- ostruct/cli/template_rendering.py +347 -0
- ostruct/cli/template_schema.py +565 -0
- ostruct/cli/template_utils.py +288 -0
- ostruct/cli/template_validation.py +375 -0
- ostruct/cli/utils.py +31 -0
- ostruct/py.typed +0 -0
- ostruct_cli-0.1.0.dist-info/LICENSE +21 -0
- ostruct_cli-0.1.0.dist-info/METADATA +182 -0
- ostruct_cli-0.1.0.dist-info/RECORD +27 -0
- ostruct_cli-0.1.0.dist-info/WHEEL +4 -0
- ostruct_cli-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,261 @@
|
|
1
|
+
"""File I/O operations for template processing.
|
2
|
+
|
3
|
+
This module provides functionality for file operations related to template processing:
|
4
|
+
1. Reading files with encoding detection and caching
|
5
|
+
2. Extracting metadata from files and templates
|
6
|
+
3. Managing file content caching and eviction
|
7
|
+
4. Progress tracking for file operations
|
8
|
+
|
9
|
+
Key Components:
|
10
|
+
- read_file: Main function for reading files
|
11
|
+
- extract_metadata: Extract metadata from files
|
12
|
+
- extract_template_metadata: Extract metadata from templates
|
13
|
+
- Cache management for file content
|
14
|
+
|
15
|
+
Examples:
|
16
|
+
Basic file reading:
|
17
|
+
>>> file_info = read_file('example.txt')
|
18
|
+
>>> print(file_info.name) # 'example.txt'
|
19
|
+
>>> print(file_info.content) # File contents
|
20
|
+
>>> print(file_info.encoding) # Detected encoding
|
21
|
+
|
22
|
+
Lazy loading:
|
23
|
+
>>> file_info = read_file('large_file.txt', lazy=True)
|
24
|
+
>>> # Content not loaded yet
|
25
|
+
>>> print(file_info.content) # Now content is loaded
|
26
|
+
>>> print(file_info.size) # File size in bytes
|
27
|
+
|
28
|
+
Metadata extraction:
|
29
|
+
>>> metadata = extract_metadata(file_info)
|
30
|
+
>>> print(metadata['size']) # File size
|
31
|
+
>>> print(metadata['encoding']) # File encoding
|
32
|
+
>>> print(metadata['mtime']) # Last modified time
|
33
|
+
|
34
|
+
Template metadata:
|
35
|
+
>>> template = "Hello {{ name }}, files: {% for f in files %}{{ f.name }}{% endfor %}"
|
36
|
+
>>> metadata = extract_template_metadata(template)
|
37
|
+
>>> print(metadata['variables']) # ['name', 'files']
|
38
|
+
>>> print(metadata['has_loops']) # True
|
39
|
+
>>> print(metadata['filters']) # []
|
40
|
+
|
41
|
+
Cache management:
|
42
|
+
>>> # Files are automatically cached
|
43
|
+
>>> file_info1 = read_file('example.txt')
|
44
|
+
>>> file_info2 = read_file('example.txt') # Uses cached content
|
45
|
+
>>> # Cache is invalidated if file changes
|
46
|
+
>>> # Large files evicted from cache based on size
|
47
|
+
|
48
|
+
Notes:
|
49
|
+
- Automatically detects file encoding
|
50
|
+
- Caches file content for performance
|
51
|
+
- Tracks file modifications
|
52
|
+
- Provides progress updates for large files
|
53
|
+
- Handles various error conditions gracefully
|
54
|
+
"""
|
55
|
+
|
56
|
+
import logging
|
57
|
+
import os
|
58
|
+
from typing import Any, Dict, Optional
|
59
|
+
|
60
|
+
from jinja2 import Environment
|
61
|
+
|
62
|
+
from .cache_manager import FileCache
|
63
|
+
from .file_utils import FileInfo
|
64
|
+
from .progress import ProgressContext
|
65
|
+
from .security import SecurityManager
|
66
|
+
|
67
|
+
logger = logging.getLogger(__name__)
|
68
|
+
|
69
|
+
# Global cache instance
|
70
|
+
_file_cache = FileCache()
|
71
|
+
|
72
|
+
|
73
|
+
def read_file(
|
74
|
+
file_path: str,
|
75
|
+
security_manager: Optional["SecurityManager"] = None,
|
76
|
+
encoding: Optional[str] = None,
|
77
|
+
progress_enabled: bool = True,
|
78
|
+
chunk_size: int = 1024 * 1024, # 1MB chunks
|
79
|
+
) -> FileInfo:
|
80
|
+
"""Read file with caching and progress tracking.
|
81
|
+
|
82
|
+
Args:
|
83
|
+
file_path: Path to file to read
|
84
|
+
security_manager: Optional security manager for path validation
|
85
|
+
encoding: Optional encoding to use for reading file
|
86
|
+
progress_enabled: Whether to show progress bar
|
87
|
+
chunk_size: Size of chunks to read in bytes
|
88
|
+
|
89
|
+
Returns:
|
90
|
+
FileInfo object with file metadata and content
|
91
|
+
|
92
|
+
Raises:
|
93
|
+
ValueError: If file not found or cannot be read
|
94
|
+
PathSecurityError: If path is not allowed
|
95
|
+
"""
|
96
|
+
# Create security manager if not provided
|
97
|
+
if security_manager is None:
|
98
|
+
from .security import SecurityManager
|
99
|
+
|
100
|
+
security_manager = SecurityManager()
|
101
|
+
logger.debug("Created default SecurityManager")
|
102
|
+
|
103
|
+
# Create progress context
|
104
|
+
with ProgressContext(
|
105
|
+
level="basic" if progress_enabled else "none"
|
106
|
+
) as progress:
|
107
|
+
try:
|
108
|
+
# Get absolute path and check file exists
|
109
|
+
abs_path = os.path.abspath(file_path)
|
110
|
+
logger.debug(
|
111
|
+
"Reading file: path=%s, abs_path=%s", file_path, abs_path
|
112
|
+
)
|
113
|
+
|
114
|
+
if not os.path.isfile(abs_path):
|
115
|
+
logger.error("File not found: %s", abs_path)
|
116
|
+
raise ValueError(f"File not found: {file_path}")
|
117
|
+
|
118
|
+
# Get file stats for cache validation
|
119
|
+
try:
|
120
|
+
stats = os.stat(abs_path)
|
121
|
+
logger.debug(
|
122
|
+
"File stats: size=%d, mtime=%d, mtime_ns=%d, mode=%o",
|
123
|
+
stats.st_size,
|
124
|
+
stats.st_mtime,
|
125
|
+
stats.st_mtime_ns,
|
126
|
+
stats.st_mode,
|
127
|
+
)
|
128
|
+
except OSError as e:
|
129
|
+
logger.error("Failed to get file stats: %s", e)
|
130
|
+
raise ValueError(f"Cannot read file stats: {e}")
|
131
|
+
|
132
|
+
mtime_ns = stats.st_mtime_ns
|
133
|
+
size = stats.st_size
|
134
|
+
|
135
|
+
# Check if file is in cache and up to date
|
136
|
+
cache_entry = _file_cache.get(abs_path, mtime_ns, size)
|
137
|
+
|
138
|
+
if cache_entry is not None:
|
139
|
+
logger.debug(
|
140
|
+
"Using cached content for %s: encoding=%s, hash=%s",
|
141
|
+
abs_path,
|
142
|
+
cache_entry.encoding,
|
143
|
+
cache_entry.hash_value,
|
144
|
+
)
|
145
|
+
if progress.enabled:
|
146
|
+
progress.update(1)
|
147
|
+
# Create FileInfo and update from cache
|
148
|
+
file_info = FileInfo.from_path(
|
149
|
+
path=file_path, security_manager=security_manager
|
150
|
+
)
|
151
|
+
file_info.update_cache(
|
152
|
+
content=cache_entry.content,
|
153
|
+
encoding=cache_entry.encoding,
|
154
|
+
hash_value=cache_entry.hash_value,
|
155
|
+
)
|
156
|
+
return file_info
|
157
|
+
|
158
|
+
# Create new FileInfo - content will be loaded immediately
|
159
|
+
logger.debug("Reading fresh content for %s", abs_path)
|
160
|
+
file_info = FileInfo.from_path(
|
161
|
+
path=file_path, security_manager=security_manager
|
162
|
+
)
|
163
|
+
|
164
|
+
# Update cache with loaded content
|
165
|
+
logger.debug(
|
166
|
+
"Caching new content: path=%s, size=%d, encoding=%s, hash=%s",
|
167
|
+
abs_path,
|
168
|
+
size,
|
169
|
+
file_info.encoding,
|
170
|
+
file_info.hash,
|
171
|
+
)
|
172
|
+
_file_cache.put(
|
173
|
+
abs_path,
|
174
|
+
file_info.content,
|
175
|
+
file_info.encoding,
|
176
|
+
file_info.hash,
|
177
|
+
mtime_ns,
|
178
|
+
size,
|
179
|
+
)
|
180
|
+
|
181
|
+
if progress.enabled:
|
182
|
+
progress.update(1)
|
183
|
+
|
184
|
+
return file_info
|
185
|
+
|
186
|
+
except Exception as e:
|
187
|
+
logger.error(
|
188
|
+
"Error reading file %s: %s (%s)",
|
189
|
+
file_path,
|
190
|
+
str(e),
|
191
|
+
type(e).__name__,
|
192
|
+
exc_info=True,
|
193
|
+
)
|
194
|
+
raise
|
195
|
+
|
196
|
+
|
197
|
+
def extract_metadata(file_info: FileInfo) -> Dict[str, Any]:
|
198
|
+
"""Extract metadata from a FileInfo object.
|
199
|
+
|
200
|
+
This function respects lazy loading - it will not force content loading
|
201
|
+
if the content hasn't been loaded yet.
|
202
|
+
"""
|
203
|
+
metadata: Dict[str, Any] = {
|
204
|
+
"name": os.path.basename(file_info.path),
|
205
|
+
"path": file_info.path,
|
206
|
+
"abs_path": os.path.realpath(file_info.path),
|
207
|
+
"mtime": file_info.mtime,
|
208
|
+
}
|
209
|
+
|
210
|
+
# Only include content-related fields if content has been explicitly accessed
|
211
|
+
if (
|
212
|
+
hasattr(file_info, "_FileInfo__content")
|
213
|
+
and file_info.content is not None
|
214
|
+
):
|
215
|
+
metadata["content"] = file_info.content
|
216
|
+
metadata["size"] = file_info.size
|
217
|
+
|
218
|
+
return metadata
|
219
|
+
|
220
|
+
|
221
|
+
def extract_template_metadata(
|
222
|
+
template_str: str,
|
223
|
+
context: Dict[str, Any],
|
224
|
+
jinja_env: Optional[Environment] = None,
|
225
|
+
progress_enabled: bool = True,
|
226
|
+
) -> Dict[str, Dict[str, Any]]:
|
227
|
+
"""Extract metadata about a template string."""
|
228
|
+
metadata: Dict[str, Dict[str, Any]] = {
|
229
|
+
"template": {"is_file": True, "path": template_str},
|
230
|
+
"context": {
|
231
|
+
"variables": sorted(context.keys()),
|
232
|
+
"dict_vars": [],
|
233
|
+
"list_vars": [],
|
234
|
+
"file_info_vars": [],
|
235
|
+
"other_vars": [],
|
236
|
+
},
|
237
|
+
}
|
238
|
+
|
239
|
+
with ProgressContext(
|
240
|
+
description="Analyzing template",
|
241
|
+
level="basic" if progress_enabled else "none",
|
242
|
+
) as progress:
|
243
|
+
# Categorize variables by type
|
244
|
+
for key, value in context.items():
|
245
|
+
if isinstance(value, dict):
|
246
|
+
metadata["context"]["dict_vars"].append(key)
|
247
|
+
elif isinstance(value, list):
|
248
|
+
metadata["context"]["list_vars"].append(key)
|
249
|
+
elif isinstance(value, FileInfo):
|
250
|
+
metadata["context"]["file_info_vars"].append(key)
|
251
|
+
else:
|
252
|
+
metadata["context"]["other_vars"].append(key)
|
253
|
+
|
254
|
+
# Sort lists for consistent output
|
255
|
+
for key in ["dict_vars", "list_vars", "file_info_vars", "other_vars"]:
|
256
|
+
metadata["context"][key].sort()
|
257
|
+
|
258
|
+
if progress.enabled:
|
259
|
+
progress.current = 1
|
260
|
+
|
261
|
+
return metadata
|
@@ -0,0 +1,347 @@
|
|
1
|
+
"""Template rendering with Jinja2.
|
2
|
+
|
3
|
+
This module provides functionality for rendering Jinja2 templates with support for:
|
4
|
+
1. Custom filters and functions
|
5
|
+
2. Dot notation access for dictionaries
|
6
|
+
3. Error handling and reporting
|
7
|
+
|
8
|
+
Key Components:
|
9
|
+
- render_template: Main rendering function
|
10
|
+
- DotDict: Dictionary wrapper for dot notation access
|
11
|
+
- Custom filters for code formatting and data manipulation
|
12
|
+
|
13
|
+
Examples:
|
14
|
+
Basic template rendering:
|
15
|
+
>>> template = "Hello {{ name }}!"
|
16
|
+
>>> context = {'name': 'World'}
|
17
|
+
>>> result = render_template(template, context)
|
18
|
+
>>> print(result)
|
19
|
+
Hello World!
|
20
|
+
|
21
|
+
Dictionary access with dot notation:
|
22
|
+
>>> template = '''
|
23
|
+
... Debug: {{ config.debug }}
|
24
|
+
... Mode: {{ config.settings.mode }}
|
25
|
+
... '''
|
26
|
+
>>> config = {
|
27
|
+
... 'debug': True,
|
28
|
+
... 'settings': {'mode': 'test'}
|
29
|
+
... }
|
30
|
+
>>> result = render_template(template, {'config': config})
|
31
|
+
>>> print(result)
|
32
|
+
Debug: True
|
33
|
+
Mode: test
|
34
|
+
|
35
|
+
Using custom filters:
|
36
|
+
>>> template = '''
|
37
|
+
... {{ code | format_code('python') }}
|
38
|
+
... {{ data | dict_to_table }}
|
39
|
+
... '''
|
40
|
+
>>> context = {
|
41
|
+
... 'code': 'def hello(): print("Hello")',
|
42
|
+
... 'data': {'name': 'test', 'value': 42}
|
43
|
+
... }
|
44
|
+
>>> result = render_template(template, context)
|
45
|
+
|
46
|
+
File content rendering:
|
47
|
+
>>> template = "Content: {{ file.content }}"
|
48
|
+
>>> context = {'file': FileInfo('test.txt')}
|
49
|
+
>>> result = render_template(template, context)
|
50
|
+
|
51
|
+
Notes:
|
52
|
+
- All dictionaries are wrapped in DotDict for dot notation access
|
53
|
+
- Custom filters are registered automatically
|
54
|
+
- Provides detailed error messages for rendering failures
|
55
|
+
"""
|
56
|
+
|
57
|
+
import logging
|
58
|
+
import os
|
59
|
+
from typing import Any, Dict, List, Optional, Union
|
60
|
+
|
61
|
+
import jinja2
|
62
|
+
from jinja2 import Environment
|
63
|
+
|
64
|
+
from .errors import TemplateValidationError
|
65
|
+
from .file_utils import FileInfo
|
66
|
+
from .template_env import create_jinja_env
|
67
|
+
from .template_schema import DotDict, StdinProxy
|
68
|
+
|
69
|
+
__all__ = [
|
70
|
+
"create_jinja_env",
|
71
|
+
"render_template",
|
72
|
+
"render_template_file",
|
73
|
+
"DotDict",
|
74
|
+
]
|
75
|
+
|
76
|
+
logger = logging.getLogger("ostruct")
|
77
|
+
|
78
|
+
# Type alias for template context values
|
79
|
+
TemplateContextValue = Union[
|
80
|
+
str,
|
81
|
+
int,
|
82
|
+
float,
|
83
|
+
bool,
|
84
|
+
Dict[str, Any],
|
85
|
+
List[Any],
|
86
|
+
FileInfo,
|
87
|
+
DotDict,
|
88
|
+
StdinProxy,
|
89
|
+
]
|
90
|
+
|
91
|
+
|
92
|
+
def render_template(
|
93
|
+
template_str: str,
|
94
|
+
context: Dict[str, Any],
|
95
|
+
jinja_env: Optional[Environment] = None,
|
96
|
+
progress_enabled: bool = True,
|
97
|
+
) -> str:
|
98
|
+
"""Render a task template with the given context.
|
99
|
+
|
100
|
+
Args:
|
101
|
+
template_str: Task template string or path to task template file
|
102
|
+
context: Task template variables
|
103
|
+
jinja_env: Optional Jinja2 environment to use
|
104
|
+
progress_enabled: Whether to show progress indicators
|
105
|
+
|
106
|
+
Returns:
|
107
|
+
Rendered task template string
|
108
|
+
|
109
|
+
Raises:
|
110
|
+
TemplateValidationError: If task template cannot be loaded or rendered. The original error
|
111
|
+
will be chained using `from` for proper error context.
|
112
|
+
"""
|
113
|
+
from .progress import ( # Import here to avoid circular dependency
|
114
|
+
ProgressContext,
|
115
|
+
)
|
116
|
+
|
117
|
+
with ProgressContext(
|
118
|
+
description="Rendering task template",
|
119
|
+
level="basic" if progress_enabled else "none",
|
120
|
+
) as progress:
|
121
|
+
try:
|
122
|
+
if progress:
|
123
|
+
progress.update(1) # Update progress for setup
|
124
|
+
|
125
|
+
if jinja_env is None:
|
126
|
+
jinja_env = create_jinja_env(
|
127
|
+
loader=jinja2.FileSystemLoader(".")
|
128
|
+
)
|
129
|
+
|
130
|
+
logger.debug("=== Raw Input ===")
|
131
|
+
logger.debug(
|
132
|
+
"Template string type: %s", type(template_str).__name__
|
133
|
+
)
|
134
|
+
logger.debug("Template string length: %d", len(template_str))
|
135
|
+
logger.debug(
|
136
|
+
"Template string first 500 chars: %r", template_str[:500]
|
137
|
+
)
|
138
|
+
logger.debug("Raw context keys: %r", list(context.keys()))
|
139
|
+
|
140
|
+
logger.debug("=== Template Details ===")
|
141
|
+
logger.debug("Raw template string:\n%s", template_str)
|
142
|
+
logger.debug("Context keys and types:")
|
143
|
+
for key, value in context.items():
|
144
|
+
if isinstance(value, list):
|
145
|
+
logger.debug(
|
146
|
+
" %s (list[%d]): %s",
|
147
|
+
key,
|
148
|
+
len(value),
|
149
|
+
[type(x).__name__ for x in value],
|
150
|
+
)
|
151
|
+
else:
|
152
|
+
logger.debug(" %s: %s", key, type(value).__name__)
|
153
|
+
|
154
|
+
# Wrap JSON variables in DotDict and handle special cases
|
155
|
+
wrapped_context: Dict[str, TemplateContextValue] = {}
|
156
|
+
for key, value in context.items():
|
157
|
+
if isinstance(value, dict):
|
158
|
+
wrapped_context[key] = DotDict(value)
|
159
|
+
else:
|
160
|
+
wrapped_context[key] = value
|
161
|
+
|
162
|
+
# Add stdin only if not already in context
|
163
|
+
if "stdin" not in wrapped_context:
|
164
|
+
wrapped_context["stdin"] = StdinProxy()
|
165
|
+
|
166
|
+
# Load file content for FileInfo objects
|
167
|
+
for key, value in context.items():
|
168
|
+
if isinstance(value, FileInfo):
|
169
|
+
# Access content property to trigger loading
|
170
|
+
_ = value.content
|
171
|
+
elif (
|
172
|
+
isinstance(value, list)
|
173
|
+
and value
|
174
|
+
and isinstance(value[0], FileInfo)
|
175
|
+
):
|
176
|
+
for file_info in value:
|
177
|
+
# Access content property to trigger loading
|
178
|
+
_ = file_info.content
|
179
|
+
|
180
|
+
if progress:
|
181
|
+
progress.update(1) # Update progress for template creation
|
182
|
+
|
183
|
+
# Create template from string or file
|
184
|
+
template: Optional[jinja2.Template] = None
|
185
|
+
if template_str.endswith((".j2", ".jinja2", ".md")):
|
186
|
+
if not os.path.isfile(template_str):
|
187
|
+
raise TemplateValidationError(
|
188
|
+
f"Task template file not found: {template_str}"
|
189
|
+
)
|
190
|
+
try:
|
191
|
+
template = jinja_env.get_template(template_str)
|
192
|
+
except jinja2.TemplateNotFound as e:
|
193
|
+
raise TemplateValidationError(
|
194
|
+
f"Task template file not found: {e.name}"
|
195
|
+
) from e
|
196
|
+
else:
|
197
|
+
logger.debug(
|
198
|
+
"Creating template from string. Template string: %r",
|
199
|
+
template_str,
|
200
|
+
)
|
201
|
+
try:
|
202
|
+
template = jinja_env.from_string(template_str)
|
203
|
+
|
204
|
+
# Add debug log for loop rendering
|
205
|
+
def debug_file_render(f: FileInfo) -> str:
|
206
|
+
logger.info("Rendering file: %s", f.path)
|
207
|
+
return ""
|
208
|
+
|
209
|
+
template.globals["debug_file_render"] = debug_file_render
|
210
|
+
except jinja2.TemplateSyntaxError as e:
|
211
|
+
raise TemplateValidationError(
|
212
|
+
f"Task template syntax error: {str(e)}"
|
213
|
+
) from e
|
214
|
+
|
215
|
+
if template is None:
|
216
|
+
raise TemplateValidationError("Failed to create task template")
|
217
|
+
assert template is not None # Help mypy understand control flow
|
218
|
+
|
219
|
+
# Add template globals
|
220
|
+
template.globals["template_name"] = getattr(
|
221
|
+
template, "name", "<string>"
|
222
|
+
)
|
223
|
+
template.globals["template_path"] = getattr(
|
224
|
+
template, "filename", None
|
225
|
+
)
|
226
|
+
logger.debug("Template globals: %r", template.globals)
|
227
|
+
|
228
|
+
try:
|
229
|
+
# Attempt to render the template
|
230
|
+
logger.debug("=== Starting template render ===")
|
231
|
+
logger.info("=== Template Context ===")
|
232
|
+
for key, value in wrapped_context.items():
|
233
|
+
if isinstance(value, list):
|
234
|
+
logger.info(
|
235
|
+
" %s: list with %d items", key, len(value)
|
236
|
+
)
|
237
|
+
if value and isinstance(value[0], FileInfo):
|
238
|
+
logger.info(
|
239
|
+
" First file: %s (content length: %d)",
|
240
|
+
value[0].path,
|
241
|
+
(
|
242
|
+
len(value[0].content)
|
243
|
+
if hasattr(value[0], "content")
|
244
|
+
else -1
|
245
|
+
),
|
246
|
+
)
|
247
|
+
elif isinstance(value, FileInfo):
|
248
|
+
logger.info(
|
249
|
+
" %s: FileInfo(%s) content length: %d",
|
250
|
+
key,
|
251
|
+
value.path,
|
252
|
+
(
|
253
|
+
len(value.content)
|
254
|
+
if hasattr(value, "content")
|
255
|
+
else -1
|
256
|
+
),
|
257
|
+
)
|
258
|
+
else:
|
259
|
+
logger.info(" %s: %s", key, type(value).__name__)
|
260
|
+
logger.debug(
|
261
|
+
"Template source: %r",
|
262
|
+
(
|
263
|
+
template.source
|
264
|
+
if hasattr(template, "source")
|
265
|
+
else "<no source>"
|
266
|
+
),
|
267
|
+
)
|
268
|
+
logger.debug("Wrapped context before render:")
|
269
|
+
for key, value in wrapped_context.items():
|
270
|
+
if isinstance(value, list):
|
271
|
+
logger.debug(
|
272
|
+
" %s is a list with %d items", key, len(value)
|
273
|
+
)
|
274
|
+
for i, item in enumerate(value):
|
275
|
+
if isinstance(item, FileInfo):
|
276
|
+
logger.debug(" [%d] FileInfo details:", i)
|
277
|
+
logger.debug(" path: %r", item.path)
|
278
|
+
logger.debug(
|
279
|
+
" exists: %r",
|
280
|
+
os.path.exists(item.path),
|
281
|
+
)
|
282
|
+
logger.debug(
|
283
|
+
" content length: %d",
|
284
|
+
(
|
285
|
+
len(item.content)
|
286
|
+
if hasattr(item, "content")
|
287
|
+
else -1
|
288
|
+
),
|
289
|
+
)
|
290
|
+
else:
|
291
|
+
logger.debug(
|
292
|
+
" %s: %s (%r)", key, type(value).__name__, value
|
293
|
+
)
|
294
|
+
result = template.render(**wrapped_context)
|
295
|
+
logger.info(
|
296
|
+
"Template render result (first 100 chars): %r",
|
297
|
+
result[:100],
|
298
|
+
)
|
299
|
+
logger.debug(
|
300
|
+
"=== Rendered result (first 1000 chars) ===\n%s",
|
301
|
+
result[:1000],
|
302
|
+
)
|
303
|
+
if "## File:" not in result:
|
304
|
+
logger.error(
|
305
|
+
"WARNING: File headers missing from rendered output!"
|
306
|
+
)
|
307
|
+
logger.error(
|
308
|
+
"Template string excerpt: %r", template_str[:200]
|
309
|
+
)
|
310
|
+
logger.error("Result excerpt: %r", result[:200])
|
311
|
+
if progress:
|
312
|
+
progress.update(1)
|
313
|
+
return result # type: ignore[no-any-return]
|
314
|
+
except (jinja2.TemplateError, Exception) as e:
|
315
|
+
logger.error("Template rendering failed: %s", str(e))
|
316
|
+
raise TemplateValidationError(
|
317
|
+
f"Template rendering failed: {str(e)}"
|
318
|
+
) from e
|
319
|
+
|
320
|
+
except ValueError as e:
|
321
|
+
# Re-raise with original context
|
322
|
+
raise e
|
323
|
+
|
324
|
+
|
325
|
+
def render_template_file(
|
326
|
+
template_path: str,
|
327
|
+
context: Dict[str, Any],
|
328
|
+
jinja_env: Optional[Environment] = None,
|
329
|
+
progress_enabled: bool = True,
|
330
|
+
) -> str:
|
331
|
+
"""Render a template file with the given context.
|
332
|
+
|
333
|
+
Args:
|
334
|
+
template_path: Path to the template file
|
335
|
+
context: Dictionary containing template variables
|
336
|
+
jinja_env: Optional Jinja2 environment to use
|
337
|
+
progress_enabled: Whether to show progress indicators
|
338
|
+
|
339
|
+
Returns:
|
340
|
+
The rendered template string
|
341
|
+
|
342
|
+
Raises:
|
343
|
+
TemplateValidationError: If template rendering fails
|
344
|
+
"""
|
345
|
+
with open(template_path, "r", encoding="utf-8") as f:
|
346
|
+
template_str = f.read()
|
347
|
+
return render_template(template_str, context, jinja_env, progress_enabled)
|