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,565 @@
1
+ """Schema validation and proxy objects for template processing.
2
+
3
+ Provides proxy objects that validate attribute/key access during template validation
4
+ by checking against actual data structures passed to the proxies.
5
+
6
+ Classes:
7
+ - ValidationProxy: Base proxy class for validation
8
+ - FileInfoProxy: Proxy for file information objects with standard attributes
9
+ - DictProxy: Proxy for dictionary objects that validates against actual structure
10
+ - ListProxy: Proxy for list/iterable objects that validates indices and content
11
+ - StdinProxy: Proxy for lazy stdin access
12
+ - LazyValidationProxy: Proxy that delays attribute access until string conversion
13
+ - DotDict: Dictionary wrapper that supports both dot notation and dictionary access
14
+
15
+ Examples:
16
+ Create validation context with actual data:
17
+ >>> data = {
18
+ ... 'config': {'debug': True, 'settings': {'mode': 'test'}},
19
+ ... 'source_file': FileInfo('test.txt')
20
+ ... }
21
+ >>> context = create_validation_context(data)
22
+ >>> # config will be a DictProxy validating against actual structure
23
+ >>> # source_file will be a FileInfoProxy with standard attributes
24
+
25
+ Access validation:
26
+ >>> # Valid access patterns:
27
+ >>> config = context['config']
28
+ >>> debug_value = config.debug # OK - debug exists in data
29
+ >>> mode = config.settings.mode # OK - settings.mode exists in data
30
+ >>>
31
+ >>> # Invalid access raises ValueError:
32
+ >>> config.invalid # Raises ValueError - key doesn't exist
33
+ >>> config.settings.invalid # Raises ValueError - nested key doesn't exist
34
+
35
+ File info validation:
36
+ >>> file = context['source_file']
37
+ >>> name = file.name # OK - standard attribute
38
+ >>> content = file.content # OK - standard attribute
39
+ >>> file.invalid # Raises ValueError - invalid attribute
40
+
41
+ Notes:
42
+ - DictProxy validates against actual dictionary structure
43
+ - FileInfoProxy validates standard file attributes
44
+ - ListProxy validates indices and returns appropriate proxies
45
+ - All invalid attribute/key access raises ValueError with details
46
+ """
47
+
48
+ import logging
49
+ import sys
50
+ from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, Union, cast
51
+
52
+ from .file_utils import FileInfo
53
+
54
+ logger = logging.getLogger(__name__)
55
+
56
+
57
+ class ValidationProxy:
58
+ """Proxy object that validates attribute/key access during template validation."""
59
+
60
+ def __init__(
61
+ self,
62
+ var_name: str,
63
+ value: Any = None,
64
+ valid_attrs: Optional[Set[str]] = None,
65
+ nested_attrs: Optional[Dict[str, Any]] = None,
66
+ allow_nested: bool = True,
67
+ ) -> None:
68
+ """Initialize the proxy.
69
+
70
+ Args:
71
+ var_name: The name of the variable being proxied.
72
+ value: The value being proxied (optional).
73
+ valid_attrs: Set of valid attribute names.
74
+ nested_attrs: Dictionary of nested attributes and their allowed values.
75
+ allow_nested: Whether to allow nested attribute access.
76
+ """
77
+ self._var_name = var_name
78
+ self._value = value
79
+ self._valid_attrs = valid_attrs or set()
80
+ self._nested_attrs = nested_attrs or {}
81
+ self._allow_nested = allow_nested
82
+ self._accessed_attributes: Set[str] = set()
83
+ logger.debug(
84
+ "Created ValidationProxy for %s with valid_attrs=%s, nested_attrs=%s, allow_nested=%s",
85
+ var_name,
86
+ valid_attrs,
87
+ nested_attrs,
88
+ allow_nested,
89
+ )
90
+
91
+ def __getattr__(self, name: str) -> Union["ValidationProxy", "DictProxy"]:
92
+ """Validate attribute access during template validation."""
93
+ logger.debug("\n=== ValidationProxy.__getattr__ ===")
94
+ logger.debug("Called for: %s.%s", self._var_name, name)
95
+ logger.debug(
96
+ "State: valid_attrs=%s, nested_attrs=%s, allow_nested=%s",
97
+ self._valid_attrs,
98
+ self._nested_attrs,
99
+ self._allow_nested,
100
+ )
101
+
102
+ self._accessed_attributes.add(name)
103
+
104
+ # Allow HTML escaping attributes for all variables
105
+ if name in {"__html__", "__html_format__"}:
106
+ logger.debug(
107
+ "Allowing HTML escape attribute %s for %s",
108
+ name,
109
+ self._var_name,
110
+ )
111
+ return ValidationProxy(f"{self._var_name}.{name}", value="")
112
+
113
+ # Check nested attributes
114
+ if name in self._nested_attrs:
115
+ nested_value = self._nested_attrs[name]
116
+ if isinstance(nested_value, dict):
117
+ return ValidationProxy(
118
+ f"{self._var_name}.{name}",
119
+ nested_attrs=nested_value,
120
+ allow_nested=True,
121
+ )
122
+ elif isinstance(nested_value, set):
123
+ if (
124
+ not nested_value
125
+ ): # Empty set means "any nested keys allowed"
126
+ return ValidationProxy(
127
+ f"{self._var_name}.{name}", allow_nested=True
128
+ )
129
+ return ValidationProxy(
130
+ f"{self._var_name}.{name}",
131
+ valid_attrs=nested_value,
132
+ allow_nested=False,
133
+ )
134
+
135
+ # Validate against valid_attrs if present
136
+ if self._valid_attrs:
137
+ if name not in self._valid_attrs:
138
+ raise ValueError(
139
+ f"Task template uses undefined attribute '{self._var_name}.{name}'"
140
+ )
141
+ return ValidationProxy(
142
+ f"{self._var_name}.{name}", allow_nested=False
143
+ )
144
+
145
+ # Check nesting allowance and get actual value
146
+ if not self._allow_nested:
147
+ raise ValueError(
148
+ f"Task template uses undefined attribute '{self._var_name}.{name}'"
149
+ )
150
+
151
+ value = None
152
+ if self._value is not None and hasattr(self._value, name):
153
+ value = getattr(self._value, name)
154
+
155
+ return ValidationProxy(
156
+ f"{self._var_name}.{name}", value=value, allow_nested=True
157
+ )
158
+
159
+ def __getitem__(self, key: Any) -> Union["ValidationProxy", "DictProxy"]:
160
+ """Support item access for validation."""
161
+ key_str = f"['{key}']" if isinstance(key, str) else f"[{key}]"
162
+ return ValidationProxy(
163
+ f"{self._var_name}{key_str}",
164
+ valid_attrs=self._valid_attrs,
165
+ allow_nested=self._allow_nested,
166
+ )
167
+
168
+ def __str__(self) -> str:
169
+ """Convert the proxy value to a string."""
170
+ return str(self._value) if self._value is not None else ""
171
+
172
+ def __html__(self) -> str:
173
+ """Return HTML representation."""
174
+ return str(self)
175
+
176
+ def __html_format__(self, format_spec: str) -> str:
177
+ """Return formatted HTML representation."""
178
+ return str(self)
179
+
180
+ def __iter__(self) -> Iterator[Union["ValidationProxy", "DictProxy"]]:
181
+ """Support iteration for validation."""
182
+ yield ValidationProxy(
183
+ f"{self._var_name}[0]", valid_attrs=self._valid_attrs
184
+ )
185
+
186
+ def get_accessed_attributes(self) -> Set[str]:
187
+ """Get the set of accessed attributes."""
188
+ return self._accessed_attributes.copy()
189
+
190
+
191
+ class FileInfoProxy:
192
+ """Proxy for FileInfo that provides validation during template rendering.
193
+
194
+ This class wraps FileInfo to provide validation during template rendering.
195
+ It ensures that only valid attributes are accessed and returns empty strings
196
+ for content to support filtering in templates.
197
+
198
+ Attributes:
199
+ _var_name: Base variable name for error messages
200
+ _value: The wrapped FileInfo object
201
+ _accessed_attrs: Set of attributes that have been accessed
202
+ _valid_attrs: Set of valid attribute names
203
+ """
204
+
205
+ def __init__(self, var_name: str, value: FileInfo) -> None:
206
+ """Initialize FileInfoProxy.
207
+
208
+ Args:
209
+ var_name: Base variable name for error messages
210
+ value: FileInfo object to validate
211
+ """
212
+ self._var_name = var_name
213
+ self._value = value
214
+ self._accessed_attrs: Set[str] = set()
215
+ self._valid_attrs = {
216
+ "name",
217
+ "path",
218
+ "content",
219
+ "ext",
220
+ "basename",
221
+ "dirname",
222
+ "abs_path",
223
+ "exists",
224
+ "is_file",
225
+ "is_dir",
226
+ "size",
227
+ "mtime",
228
+ "encoding",
229
+ "hash",
230
+ "extension",
231
+ "parent",
232
+ "stem",
233
+ "suffix",
234
+ "__html__",
235
+ "__html_format__",
236
+ }
237
+
238
+ def __getattr__(self, name: str) -> str:
239
+ """Get attribute value with validation.
240
+
241
+ Args:
242
+ name: Attribute name to get
243
+
244
+ Returns:
245
+ Empty string for content, actual value for other attributes
246
+
247
+ Raises:
248
+ ValueError: If attribute name is not valid
249
+ """
250
+ if name not in self._valid_attrs:
251
+ raise ValueError(
252
+ f"undefined attribute '{name}' for file {self._var_name}"
253
+ )
254
+
255
+ self._accessed_attrs.add(name)
256
+
257
+ # Return empty string for content and HTML methods to support filtering
258
+ if name in ("content", "__html__", "__html_format__"):
259
+ return ""
260
+
261
+ # Return actual value for all other attributes
262
+ return str(getattr(self._value, name))
263
+
264
+ def __str__(self) -> str:
265
+ """Convert to string.
266
+
267
+ Returns:
268
+ Empty string to support filtering
269
+ """
270
+ return ""
271
+
272
+ def __html__(self) -> str:
273
+ """Convert to HTML-safe string.
274
+
275
+ Returns:
276
+ Empty string to support filtering
277
+ """
278
+ return ""
279
+
280
+
281
+ class DictProxy:
282
+ """Proxy for dictionary access during validation.
283
+
284
+ Validates all attribute/key access against the actual dictionary structure.
285
+ Provides standard dictionary methods (get, items, keys, values).
286
+ Supports HTML escaping for Jinja2 compatibility.
287
+ """
288
+
289
+ def __init__(self, name: str, value: Dict[str, Any]) -> None:
290
+ """Initialize the proxy.
291
+
292
+ Args:
293
+ name: The name of the variable being proxied.
294
+ value: The dictionary being proxied.
295
+ """
296
+ self._name = name
297
+ self._value = value
298
+
299
+ def __getattr__(self, name: str) -> Union["DictProxy", ValidationProxy]:
300
+ """Validate attribute access against actual dictionary structure."""
301
+ if name in {"get", "items", "keys", "values"}:
302
+ return cast(
303
+ Union["DictProxy", ValidationProxy], getattr(self, f"_{name}")
304
+ )
305
+
306
+ if name not in self._value:
307
+ raise ValueError(
308
+ f"Task template uses undefined attribute '{self._name}.{name}'"
309
+ )
310
+
311
+ if isinstance(self._value[name], dict):
312
+ return DictProxy(f"{self._name}.{name}", self._value[name])
313
+ return ValidationProxy(f"{self._name}.{name}")
314
+
315
+ def __getitem__(self, key: Any) -> Union["DictProxy", ValidationProxy]:
316
+ """Validate dictionary key access."""
317
+ if isinstance(key, int):
318
+ key = str(key)
319
+
320
+ if key not in self._value:
321
+ raise ValueError(
322
+ f"Task template uses undefined key '{self._name}['{key}']'"
323
+ )
324
+
325
+ if isinstance(self._value[key], dict):
326
+ return DictProxy(f"{self._name}['{key}']", self._value[key])
327
+ return ValidationProxy(f"{self._name}['{key}']")
328
+
329
+ def __contains__(self, key: Any) -> bool:
330
+ """Support 'in' operator for validation."""
331
+ if isinstance(key, int):
332
+ key = str(key)
333
+ return key in self._value
334
+
335
+ def _get(self, key: str, default: Any = None) -> Any:
336
+ """Implement dict.get() method."""
337
+ try:
338
+ return self[key]
339
+ except ValueError:
340
+ return default
341
+
342
+ def _items(
343
+ self,
344
+ ) -> Iterator[Tuple[str, Union["DictProxy", ValidationProxy]]]:
345
+ """Implement dict.items() method."""
346
+ for key, value in self._value.items():
347
+ if isinstance(value, dict):
348
+ yield (key, DictProxy(f"{self._name}['{key}']", value))
349
+ else:
350
+ yield (key, ValidationProxy(f"{self._name}['{key}']"))
351
+
352
+ def _keys(self) -> Iterator[str]:
353
+ """Implement dict.keys() method."""
354
+ for key in self._value.keys():
355
+ yield key
356
+
357
+ def _values(self) -> Iterator[Union["DictProxy", ValidationProxy]]:
358
+ """Implement dict.values() method."""
359
+ for key, value in self._value.items():
360
+ if isinstance(value, dict):
361
+ yield DictProxy(f"{self._name}['{key}']", value)
362
+ else:
363
+ yield ValidationProxy(f"{self._name}['{key}']")
364
+
365
+ def __html__(self) -> str:
366
+ """Support HTML escaping."""
367
+ return ""
368
+
369
+ def __html_format__(self, spec: str) -> str:
370
+ """Support HTML formatting."""
371
+ return ""
372
+
373
+
374
+ class ListProxy(ValidationProxy):
375
+ """Proxy for list/iterable objects during validation.
376
+
377
+ For file lists (from --files or --dir), validates that only valid file attributes
378
+ are accessed. For other lists, validates indices and returns appropriate proxies
379
+ based on the actual content type.
380
+ """
381
+
382
+ def __init__(self, var_name: str, value: List[Any]) -> None:
383
+ """Initialize the proxy.
384
+
385
+ Args:
386
+ var_name: The name of the variable being proxied.
387
+ value: The list being proxied.
388
+ """
389
+ super().__init__(var_name)
390
+ self._value = value
391
+ # Determine if this is a list of files
392
+ self._is_file_list = value and all(
393
+ isinstance(item, FileInfo) for item in value
394
+ )
395
+ self._file_attrs = {
396
+ "name",
397
+ "path",
398
+ "abs_path",
399
+ "content",
400
+ "size",
401
+ "extension",
402
+ "exists",
403
+ "mtime",
404
+ "encoding",
405
+ "dir",
406
+ "hash",
407
+ "is_file",
408
+ "is_dir",
409
+ "parent",
410
+ "stem",
411
+ "suffix",
412
+ }
413
+
414
+ def __len__(self) -> int:
415
+ """Support len() for validation."""
416
+ return len(self._value)
417
+
418
+ def __iter__(self) -> Iterator[Union[ValidationProxy, DictProxy]]:
419
+ """Support iteration, returning appropriate proxies."""
420
+ if self._is_file_list:
421
+ # For file lists, return FileInfoProxy for validation
422
+ for i in range(len(self._value)):
423
+ yield cast(
424
+ Union[ValidationProxy, DictProxy],
425
+ FileInfoProxy(f"{self._var_name}[{i}]", self._value[i]),
426
+ )
427
+ else:
428
+ # For other lists, return basic ValidationProxy
429
+ for i in range(len(self._value)):
430
+ if isinstance(self._value[i], dict):
431
+ yield DictProxy(f"{self._var_name}[{i}]", self._value[i])
432
+ else:
433
+ yield ValidationProxy(f"{self._var_name}[{i}]")
434
+
435
+ def __getitem__(self, key: Any) -> Union[ValidationProxy, DictProxy]:
436
+ """Validate list index access and return appropriate proxy."""
437
+ if isinstance(key, int) and (key < 0 or key >= len(self._value)):
438
+ raise ValueError(
439
+ f"List index {key} out of range for {self._var_name}"
440
+ )
441
+
442
+ key_str = f"['{key}']" if isinstance(key, str) else f"[{key}]"
443
+
444
+ if self._is_file_list:
445
+ return cast(
446
+ Union[ValidationProxy, DictProxy],
447
+ FileInfoProxy(f"{self._var_name}{key_str}", self._value[key]),
448
+ )
449
+ else:
450
+ value = self._value[key]
451
+ if isinstance(value, dict):
452
+ return DictProxy(f"{self._var_name}{key_str}", value)
453
+ return ValidationProxy(f"{self._var_name}{key_str}")
454
+
455
+
456
+ class StdinProxy:
457
+ """Proxy for lazy stdin access.
458
+
459
+ This proxy only reads from stdin when the content is actually accessed.
460
+ This prevents unnecessary stdin reads when the template doesn't use stdin.
461
+ """
462
+
463
+ def __init__(self) -> None:
464
+ """Initialize the proxy."""
465
+ self._content: Optional[str] = None
466
+
467
+ def __str__(self) -> str:
468
+ """Return stdin content when converted to string."""
469
+ if self._content is None:
470
+ if sys.stdin.isatty():
471
+ raise ValueError("No input available on stdin")
472
+ self._content = sys.stdin.read()
473
+ return self._content or ""
474
+
475
+ def __html__(self) -> str:
476
+ """Support HTML escaping."""
477
+ return str(self)
478
+
479
+ def __html_format__(self, spec: str) -> str:
480
+ """Support HTML formatting."""
481
+ return str(self)
482
+
483
+
484
+ class DotDict:
485
+ """Dictionary wrapper that supports both dot notation and dictionary access."""
486
+
487
+ def __init__(self, data: Dict[str, Any]):
488
+ self._data = data
489
+
490
+ def __getattr__(self, name: str) -> Any:
491
+ try:
492
+ value = self._data[name]
493
+ return DotDict(value) if isinstance(value, dict) else value
494
+ except KeyError:
495
+ raise AttributeError(f"'DotDict' object has no attribute '{name}'")
496
+
497
+ def __getitem__(self, key: str) -> Any:
498
+ value = self._data[key]
499
+ return DotDict(value) if isinstance(value, dict) else value
500
+
501
+ def __contains__(self, key: str) -> bool:
502
+ return key in self._data
503
+
504
+ def get(self, key: str, default: Any = None) -> Any:
505
+ value = self._data.get(key, default)
506
+ return DotDict(value) if isinstance(value, dict) else value
507
+
508
+ def items(self) -> List[Tuple[str, Any]]:
509
+ return [
510
+ (k, DotDict(v) if isinstance(v, dict) else v)
511
+ for k, v in self._data.items()
512
+ ]
513
+
514
+ def keys(self) -> List[str]:
515
+ return list(self._data.keys())
516
+
517
+ def values(self) -> List[Any]:
518
+ return [
519
+ DotDict(v) if isinstance(v, dict) else v
520
+ for v in self._data.values()
521
+ ]
522
+
523
+
524
+ def create_validation_context(
525
+ template_context: Dict[str, Any]
526
+ ) -> Dict[str, Any]:
527
+ """Create validation context with proxy objects.
528
+
529
+ Creates appropriate proxy objects based on the actual type and content
530
+ of each value in the mappings. Validates all attribute/key access
531
+ against the actual data structures.
532
+
533
+ Args:
534
+ template_context: Original template context with actual values
535
+
536
+ Returns:
537
+ Dictionary with proxy objects for validation
538
+
539
+ Example:
540
+ >>> data = {'config': {'debug': True}, 'files': [FileInfo('test.txt')]}
541
+ >>> context = create_validation_context(data)
542
+ >>> # context['config'] will be DictProxy validating against {'debug': True}
543
+ >>> # context['files'] will be ListProxy validating file attributes
544
+ """
545
+ logger.debug("Creating validation context for: %s", template_context)
546
+ validation_context: Dict[str, Any] = {}
547
+
548
+ # Add stdin proxy by default - it will only read if accessed
549
+ validation_context["stdin"] = StdinProxy()
550
+
551
+ for name, value in template_context.items():
552
+ if isinstance(value, FileInfo):
553
+ validation_context[name] = FileInfoProxy(name, value)
554
+ elif isinstance(value, dict):
555
+ validation_context[name] = DictProxy(name, value)
556
+ elif isinstance(value, list):
557
+ validation_context[name] = ListProxy(name, value)
558
+ else:
559
+ # For primitive values, create a ValidationProxy that disallows attribute access
560
+ validation_context[name] = ValidationProxy(
561
+ name, value=value, allow_nested=False
562
+ )
563
+
564
+ logger.debug("Created validation context: %s", validation_context)
565
+ return validation_context