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.
@@ -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)