ostruct-cli 0.6.1__py3-none-any.whl → 0.7.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.
@@ -40,6 +40,7 @@ from .errors import (
40
40
  NestedModelError,
41
41
  SchemaValidationError,
42
42
  )
43
+ from .exit_codes import ExitCode
43
44
 
44
45
  logger = logging.getLogger(__name__)
45
46
 
@@ -297,90 +298,26 @@ def create_dynamic_model(
297
298
  show_schema: bool = False,
298
299
  debug_validation: bool = False,
299
300
  ) -> Type[BaseModel]:
300
- """Create a Pydantic model from a JSON schema.
301
+ """Create a Pydantic model from a JSON Schema.
301
302
 
302
303
  Args:
303
- schema: JSON schema to create model from
304
- base_name: Name for the model class
305
- show_schema: Whether to show the generated model schema
306
- debug_validation: Whether to show detailed validation errors
304
+ schema: JSON Schema to create model from
305
+ base_name: Base name for the model class
306
+ show_schema: Whether to show the generated schema
307
+ debug_validation: Whether to show debug validation info
307
308
 
308
309
  Returns:
309
- Type[BaseModel]: The generated Pydantic model class
310
+ Generated Pydantic model class
310
311
 
311
312
  Raises:
312
- ModelValidationError: If the schema is invalid
313
- SchemaValidationError: If the schema violates OpenAI requirements
313
+ SchemaValidationError: If schema validation fails
314
+ ModelCreationError: If model creation fails
314
315
  """
315
- if debug_validation:
316
- logger.info("Creating dynamic model from schema:")
317
- logger.info(json.dumps(schema, indent=2))
318
-
319
316
  try:
320
- # Handle our wrapper format if present
321
- if "schema" in schema:
322
- if debug_validation:
323
- logger.info("Found schema wrapper, extracting inner schema")
324
- logger.info(
325
- "Original schema: %s", json.dumps(schema, indent=2)
326
- )
327
- inner_schema = schema["schema"]
328
- if not isinstance(inner_schema, dict):
329
- if debug_validation:
330
- logger.info(
331
- "Inner schema must be a dictionary, got %s",
332
- type(inner_schema),
333
- )
334
- raise SchemaValidationError(
335
- "Inner schema must be a dictionary"
336
- )
337
- if debug_validation:
338
- logger.info("Using inner schema:")
339
- logger.info(json.dumps(inner_schema, indent=2))
340
- schema = inner_schema
341
-
342
- # Validate against OpenAI requirements
343
- from .schema_validation import validate_openai_schema
344
-
345
- validate_openai_schema(schema)
346
-
347
- # Create model configuration
348
- config = ConfigDict(
349
- title=schema.get("title", base_name),
350
- extra="forbid", # OpenAI requires additionalProperties: false
351
- validate_default=True,
352
- use_enum_values=True,
353
- arbitrary_types_allowed=True,
354
- json_schema_extra={
355
- k: v
356
- for k, v in schema.items()
357
- if k
358
- not in {
359
- "type",
360
- "properties",
361
- "required",
362
- "title",
363
- "description",
364
- "additionalProperties",
365
- "readOnly",
366
- }
367
- },
368
- )
317
+ # Validate schema structure before model creation
318
+ from .template_utils import validate_json_schema
369
319
 
370
- if debug_validation:
371
- logger.info("Created model configuration:")
372
- logger.info(" Title: %s", config.get("title"))
373
- logger.info(" Extra: %s", config.get("extra"))
374
- logger.info(
375
- " Validate Default: %s", config.get("validate_default")
376
- )
377
- logger.info(" Use Enum Values: %s", config.get("use_enum_values"))
378
- logger.info(
379
- " Arbitrary Types: %s", config.get("arbitrary_types_allowed")
380
- )
381
- logger.info(
382
- " JSON Schema Extra: %s", config.get("json_schema_extra")
383
- )
320
+ validate_json_schema(schema)
384
321
 
385
322
  # Process schema properties into fields
386
323
  properties = schema.get("properties", {})
@@ -438,23 +375,25 @@ def create_dynamic_model(
438
375
  )
439
376
  for name, (field_type, field) in field_definitions.items()
440
377
  }
441
- model: Type[BaseModel] = create_model(
442
- base_name, __config__=config, **field_defs
443
- )
444
378
 
445
- # Set the model config after creation
446
- model.model_config = config
379
+ # Create model class
380
+ model = create_model(base_name, __base__=BaseModel, **field_defs)
447
381
 
448
- if debug_validation:
449
- logger.info("Successfully created model: %s", model.__name__)
450
- logger.info("Model config: %s", dict(model.model_config))
382
+ # Set model config
383
+ model.model_config = ConfigDict(
384
+ title=schema.get("title", base_name),
385
+ extra="forbid",
386
+ )
387
+
388
+ if show_schema:
451
389
  logger.info(
452
- "Model schema: %s",
390
+ "Generated schema for %s:\n%s",
391
+ base_name,
453
392
  json.dumps(model.model_json_schema(), indent=2),
454
393
  )
455
394
 
456
- # Validate the model's JSON schema
457
395
  try:
396
+ # Validate model schema
458
397
  model.model_json_schema()
459
398
  except ValidationError as e:
460
399
  validation_errors = (
@@ -467,18 +406,52 @@ def create_dynamic_model(
467
406
  logger.error(" Error type: %s", type(e).__name__)
468
407
  logger.error(" Error message: %s", str(e))
469
408
  raise ModelValidationError(base_name, validation_errors)
409
+ except KeyError as e:
410
+ # Handle Pydantic schema generation errors, particularly for recursive references
411
+ error_msg = str(e).strip(
412
+ "'\""
413
+ ) # Strip quotes from KeyError message
414
+ if error_msg.startswith("#/definitions/"):
415
+ context = {
416
+ "schema_path": schema.get("$id", "unknown"),
417
+ "reference": error_msg,
418
+ "found": "circular reference or missing definition",
419
+ "tips": [
420
+ "Add explicit $ref definitions for recursive structures",
421
+ "Use Pydantic's deferred annotations with typing.Self",
422
+ "Limit recursion depth with max_depth validator",
423
+ "Flatten nested structures using reference IDs",
424
+ ],
425
+ }
470
426
 
471
- return model
427
+ error_msg = (
428
+ f"Invalid schema reference: {error_msg}\n"
429
+ "Detected circular reference or missing definition.\n"
430
+ "Solutions:\n"
431
+ "1. Add missing $ref definitions to your schema\n"
432
+ "2. Use explicit ID references instead of nested objects\n"
433
+ "3. Implement depth limits for recursive structures"
434
+ )
472
435
 
473
- except SchemaValidationError as e:
474
- # Always log basic error info
475
- logger.error("Schema validation error: %s", str(e))
436
+ if debug_validation:
437
+ logger.error("Schema reference error:")
438
+ logger.error(" Error type: %s", type(e).__name__)
439
+ logger.error(" Error message: %s", error_msg)
476
440
 
477
- # Log additional debug info if requested
478
- if debug_validation:
479
- logger.error(" Error type: %s", type(e).__name__)
480
- logger.error(" Error details: %s", str(e))
481
- # Always raise schema validation errors directly
441
+ raise SchemaValidationError(
442
+ error_msg, context=context, exit_code=ExitCode.SCHEMA_ERROR
443
+ ) from e
444
+
445
+ # For other KeyErrors, preserve the original error
446
+ raise ModelCreationError(
447
+ f"Failed to create model {base_name}",
448
+ context={"error": str(e)},
449
+ ) from e
450
+
451
+ return model
452
+
453
+ except SchemaValidationError:
454
+ # Re-raise schema validation errors without wrapping
482
455
  raise
483
456
 
484
457
  except Exception as e:
@@ -0,0 +1,162 @@
1
+ """Registry update checks for ostruct CLI.
2
+
3
+ This module provides functionality to check for updates to the model registry
4
+ and notify users when updates are available.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import os
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Optional, Tuple
13
+
14
+ from openai_structured.model_registry import (
15
+ ModelRegistry,
16
+ RegistryUpdateStatus,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Constants
22
+ UPDATE_CHECK_ENV_VAR = "OSTRUCT_DISABLE_UPDATE_CHECKS"
23
+ UPDATE_CHECK_INTERVAL_SECONDS = (
24
+ 86400 # Check for updates once per day (24 hours)
25
+ )
26
+ LAST_CHECK_CACHE_FILE = ".ostruct_registry_check"
27
+
28
+
29
+ def _get_cache_dir() -> Path:
30
+ """Get the cache directory for ostruct.
31
+
32
+ Returns:
33
+ Path: Path to the cache directory
34
+ """
35
+ # Use XDG_CACHE_HOME if available, otherwise use ~/.cache
36
+ xdg_cache_home = os.environ.get("XDG_CACHE_HOME")
37
+ if xdg_cache_home:
38
+ base_dir = Path(xdg_cache_home)
39
+ else:
40
+ base_dir = Path.home() / ".cache"
41
+
42
+ cache_dir = base_dir / "ostruct"
43
+ cache_dir.mkdir(parents=True, exist_ok=True)
44
+ return cache_dir
45
+
46
+
47
+ def _get_last_check_time() -> Optional[float]:
48
+ """Get the timestamp of the last update check.
49
+
50
+ Returns:
51
+ Optional[float]: Timestamp of the last check, or None if never checked
52
+ """
53
+ cache_file = _get_cache_dir() / LAST_CHECK_CACHE_FILE
54
+
55
+ if not cache_file.exists():
56
+ return None
57
+
58
+ try:
59
+ with open(cache_file, "r") as f:
60
+ data = json.load(f)
61
+ last_check_time = data.get("last_check_time")
62
+ return (
63
+ float(last_check_time) if last_check_time is not None else None
64
+ )
65
+ except (json.JSONDecodeError, IOError, OSError):
66
+ return None
67
+
68
+
69
+ def _save_last_check_time() -> None:
70
+ """Save the current time as the last update check time."""
71
+ cache_file = _get_cache_dir() / LAST_CHECK_CACHE_FILE
72
+
73
+ try:
74
+ data = {"last_check_time": time.time()}
75
+ with open(cache_file, "w") as f:
76
+ json.dump(data, f)
77
+ except (IOError, OSError) as e:
78
+ logger.debug(f"Failed to save last check time: {e}")
79
+
80
+
81
+ def should_check_for_updates() -> bool:
82
+ """Determine if we should check for registry updates.
83
+
84
+ Returns:
85
+ bool: True if update checks are enabled, False otherwise
86
+ """
87
+ # Allow users to disable update checks via environment variable
88
+ if os.environ.get(UPDATE_CHECK_ENV_VAR, "").lower() in (
89
+ "1",
90
+ "true",
91
+ "yes",
92
+ ):
93
+ logger.debug(
94
+ "Registry update checks disabled via environment variable"
95
+ )
96
+ return False
97
+
98
+ # Check if we've checked recently
99
+ last_check_time = _get_last_check_time()
100
+ if last_check_time is not None:
101
+ time_since_last_check = time.time() - last_check_time
102
+ if time_since_last_check < UPDATE_CHECK_INTERVAL_SECONDS:
103
+ logger.debug(
104
+ f"Skipping update check, last check was {time_since_last_check:.1f} seconds ago"
105
+ )
106
+ return False
107
+
108
+ return True
109
+
110
+
111
+ def check_for_registry_updates() -> Tuple[bool, Optional[str]]:
112
+ """Check if there are updates available for the model registry.
113
+
114
+ This function is designed to be non-intrusive and fail gracefully.
115
+
116
+ Returns:
117
+ Tuple[bool, Optional[str]]: (update_available, message)
118
+ - update_available: True if an update is available
119
+ - message: A message to display to the user, or None if no update is available
120
+ """
121
+ if not should_check_for_updates():
122
+ return False, None
123
+
124
+ try:
125
+ registry = ModelRegistry()
126
+ result = registry.check_for_updates()
127
+
128
+ # Save the check time regardless of the result
129
+ _save_last_check_time()
130
+
131
+ if result.status == RegistryUpdateStatus.UPDATE_AVAILABLE:
132
+ return True, (
133
+ "A new model registry is available. "
134
+ "This may include support for new models or features. "
135
+ "The registry will be automatically updated when needed."
136
+ )
137
+
138
+ return False, None
139
+ except Exception as e:
140
+ # Ensure any errors don't affect normal operation
141
+ logger.debug(f"Error checking for registry updates: {e}")
142
+ return False, None
143
+
144
+
145
+ def get_update_notification() -> Optional[str]:
146
+ """Get a notification message if registry updates are available.
147
+
148
+ This function is designed to be called from the CLI to provide
149
+ a non-intrusive notification to users.
150
+
151
+ Returns:
152
+ Optional[str]: A notification message, or None if no notification is needed
153
+ """
154
+ try:
155
+ update_available, message = check_for_registry_updates()
156
+ if update_available and message:
157
+ return message
158
+ return None
159
+ except Exception as e:
160
+ # Ensure any errors don't affect normal operation
161
+ logger.debug(f"Error getting update notification: {e}")
162
+ return None
@@ -63,7 +63,7 @@ class PathSecurityError(SecurityErrorBase):
63
63
  @property
64
64
  def details(self) -> str:
65
65
  """Get the detailed explanation of the error."""
66
- return self.details
66
+ return str(self.context.get("details", ""))
67
67
 
68
68
  @classmethod
69
69
  def from_expanded_paths(
@@ -61,7 +61,7 @@ from .errors import PathSecurityError, SecurityErrorReasons
61
61
  # Patterns for path normalization and validation
62
62
  _UNICODE_SAFETY_PATTERN = re.compile(
63
63
  r"[\u0000-\u001F\u007F-\u009F\u2028-\u2029\u0085]" # Control chars and line separators
64
- r"|\.{2,}" # Directory traversal attempts
64
+ r"|(?:^|/)\.\.(?:/|$)" # Directory traversal attempts (only ".." as a path component)
65
65
  r"|[\u2024\u2025\uFE52\u2024\u2025\u2026\uFE19\uFE30\uFE52\uFF0E\uFF61]" # Alternative dots and separators
66
66
  )
67
67
  _BACKSLASH_PATTERN = re.compile(r"\\")
@@ -39,10 +39,16 @@ class SecurityManager:
39
39
 
40
40
  The security model is based on:
41
41
  1. A base directory that serves as the root for all file operations
42
+ (typically set to the current working directory by higher-level functions)
42
43
  2. A set of explicitly allowed directories that can be accessed outside the base directory
43
44
  3. Special handling for temporary directories that are always allowed
44
45
  4. Case-sensitive or case-insensitive path handling based on platform
45
46
 
47
+ Note:
48
+ While the SecurityManager class itself requires base_dir to be explicitly provided,
49
+ higher-level functions in the CLI layer (like validate_security_manager and file_utils)
50
+ will automatically use the current working directory as the base_dir if none is specified.
51
+
46
52
  Example:
47
53
  >>> sm = SecurityManager("/base/dir")
48
54
  >>> sm.add_allowed_directory("/tmp")
@@ -62,7 +68,9 @@ class SecurityManager:
62
68
  """Initialize the SecurityManager.
63
69
 
64
70
  Args:
65
- base_dir: The root directory for file operations.
71
+ base_dir: The root directory for file operations. While this parameter is required here,
72
+ note that higher-level functions in the CLI layer will automatically use the
73
+ current working directory if no base_dir is specified.
66
74
  allowed_dirs: Additional directories allowed for access.
67
75
  allow_temp_paths: Whether to allow temporary directory paths.
68
76
  max_symlink_depth: Maximum depth for symlink resolution.
@@ -234,20 +242,53 @@ class SecurityManager:
234
242
  context={"reason": SecurityErrorReasons.SYMLINK_ERROR},
235
243
  ) from e
236
244
 
237
- # For non-symlinks, just check if the normalized path is allowed
245
+ # Check for directory traversal attempts
246
+ if ".." in str(norm_path):
247
+ logger.error("Directory traversal attempt detected: %s", path)
248
+ raise PathSecurityError(
249
+ "Directory traversal attempt blocked",
250
+ path=str(path),
251
+ context={
252
+ "reason": SecurityErrorReasons.PATH_TRAVERSAL,
253
+ "base_dir": str(self._base_dir),
254
+ "allowed_dirs": [str(d) for d in self._allowed_dirs],
255
+ },
256
+ )
257
+
258
+ # Check for suspicious Unicode characters
259
+ if any(
260
+ c in str(norm_path)
261
+ for c in [
262
+ "\u2024",
263
+ "\u2025",
264
+ "\u2026",
265
+ "\u0085",
266
+ "\u2028",
267
+ "\u2029",
268
+ ]
269
+ ):
270
+ logger.error("Suspicious Unicode characters detected: %s", path)
271
+ raise PathSecurityError(
272
+ "Suspicious characters detected in path",
273
+ path=str(path),
274
+ context={
275
+ "reason": SecurityErrorReasons.UNSAFE_UNICODE,
276
+ "base_dir": str(self._base_dir),
277
+ "allowed_dirs": [str(d) for d in self._allowed_dirs],
278
+ },
279
+ )
280
+
281
+ # For non-symlinks, check if the normalized path is allowed
238
282
  logger.debug("Checking if path is allowed: %s", norm_path)
239
283
  if not self.is_path_allowed(norm_path):
240
284
  logger.error(
241
- "Security violation: Path %s is outside allowed directories (base_dir=%s, allowed_dirs=%s)",
285
+ "Path outside allowed directories: %s (base_dir=%s, allowed_dirs=%s)",
242
286
  path,
243
287
  self._base_dir,
244
288
  self._allowed_dirs,
245
289
  )
246
290
  raise PathSecurityError(
247
- (
248
- f"Access denied: {os.path.basename(str(path))} is outside "
249
- "base directory and not in allowed directories"
250
- ),
291
+ "Path outside allowed directories",
251
292
  path=str(path),
252
293
  context={
253
294
  "reason": SecurityErrorReasons.PATH_OUTSIDE_ALLOWED,
@@ -16,6 +16,26 @@ class CommentExtension(Extension):
16
16
  1. Contents of comment blocks are completely ignored during parsing
17
17
  2. Variables inside comments are not validated or processed
18
18
  3. Comments are stripped from the output
19
+ 4. Nested comments are not allowed (will raise a syntax error)
20
+
21
+ Example:
22
+ Valid usage:
23
+ ```jinja
24
+ {% comment %}
25
+ This is a comment
26
+ {{ some_var }} # This variable will be ignored
27
+ {% endcomment %}
28
+ ```
29
+
30
+ Invalid usage (will raise error):
31
+ ```jinja
32
+ {% comment %}
33
+ Outer comment
34
+ {% comment %} # Error: Nested comments are not allowed
35
+ Inner comment
36
+ {% endcomment %}
37
+ {% endcomment %}
38
+ ```
19
39
  """
20
40
 
21
41
  tags = {"comment"}
@@ -23,6 +43,9 @@ class CommentExtension(Extension):
23
43
  def parse(self, parser: Parser) -> nodes.Node:
24
44
  """Parse a comment block, ignoring its contents.
25
45
 
46
+ Nested comments are not allowed and will raise a syntax error.
47
+ This keeps the template syntax simpler and more predictable.
48
+
26
49
  Args:
27
50
  parser: The Jinja2 parser instance
28
51
 
@@ -31,6 +54,7 @@ class CommentExtension(Extension):
31
54
 
32
55
  Raises:
33
56
  TemplateSyntaxError: If the comment block is not properly closed
57
+ or if a nested comment is found
34
58
  """
35
59
  # Get the line number for error reporting
36
60
  lineno = parser.stream.current.lineno
@@ -38,10 +62,17 @@ class CommentExtension(Extension):
38
62
  # Skip the opening comment tag
39
63
  next(parser.stream)
40
64
 
41
- # Skip until we find {% endcomment %}
65
+ # Skip until we find {% endcomment %}, rejecting nested comments
42
66
  while not parser.stream.current.test("name:endcomment"):
43
67
  if parser.stream.current.type == "eof":
44
68
  raise parser.fail("Unclosed comment block", lineno)
69
+
70
+ # Explicitly reject nested comments
71
+ if parser.stream.current.test("name:comment"):
72
+ raise parser.fail(
73
+ "Nested comments are not allowed. Use separate comment blocks instead.",
74
+ parser.stream.current.lineno,
75
+ )
45
76
  next(parser.stream)
46
77
 
47
78
  # Skip the endcomment tag