ostruct-cli 0.6.1__py3-none-any.whl → 0.6.2__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/cli/cli.py +116 -70
- ostruct/cli/errors.py +61 -54
- ostruct/cli/model_creation.py +67 -94
- ostruct/cli/security/errors.py +1 -1
- ostruct/cli/security/normalization.py +1 -1
- ostruct/cli/security/security_manager.py +48 -7
- ostruct/cli/template_extensions.py +32 -1
- ostruct/cli/template_utils.py +175 -16
- ostruct/cli/utils.py +3 -1
- ostruct/cli/validators.py +6 -2
- {ostruct_cli-0.6.1.dist-info → ostruct_cli-0.6.2.dist-info}/METADATA +39 -177
- {ostruct_cli-0.6.1.dist-info → ostruct_cli-0.6.2.dist-info}/RECORD +15 -15
- {ostruct_cli-0.6.1.dist-info → ostruct_cli-0.6.2.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.6.1.dist-info → ostruct_cli-0.6.2.dist-info}/WHEEL +0 -0
- {ostruct_cli-0.6.1.dist-info → ostruct_cli-0.6.2.dist-info}/entry_points.txt +0 -0
ostruct/cli/cli.py
CHANGED
@@ -78,7 +78,11 @@ from .path_utils import validate_path_mapping
|
|
78
78
|
from .security import SecurityManager
|
79
79
|
from .serialization import LogSerializer
|
80
80
|
from .template_env import create_jinja_env
|
81
|
-
from .template_utils import
|
81
|
+
from .template_utils import (
|
82
|
+
SystemPromptError,
|
83
|
+
render_template,
|
84
|
+
validate_json_schema,
|
85
|
+
)
|
82
86
|
from .token_utils import estimate_tokens_with_encoding
|
83
87
|
|
84
88
|
# Constants
|
@@ -831,7 +835,7 @@ def validate_schema_file(
|
|
831
835
|
logger.error(msg)
|
832
836
|
raise SchemaFileError(msg, schema_path=path)
|
833
837
|
except Exception as e:
|
834
|
-
if isinstance(e, InvalidJSONError):
|
838
|
+
if isinstance(e, (InvalidJSONError, SchemaValidationError)):
|
835
839
|
raise
|
836
840
|
msg = f"Failed to read schema file {path}: {e}"
|
837
841
|
logger.error(msg)
|
@@ -846,7 +850,13 @@ def validate_schema_file(
|
|
846
850
|
if not isinstance(schema, dict):
|
847
851
|
msg = f"Schema in {path} must be a JSON object"
|
848
852
|
logger.error(msg)
|
849
|
-
raise SchemaValidationError(
|
853
|
+
raise SchemaValidationError(
|
854
|
+
msg,
|
855
|
+
context={
|
856
|
+
"validation_type": "schema",
|
857
|
+
"schema_path": path,
|
858
|
+
},
|
859
|
+
)
|
850
860
|
|
851
861
|
# Validate schema structure
|
852
862
|
if "schema" in schema:
|
@@ -856,7 +866,13 @@ def validate_schema_file(
|
|
856
866
|
if not isinstance(inner_schema, dict):
|
857
867
|
msg = f"Inner schema in {path} must be a JSON object"
|
858
868
|
logger.error(msg)
|
859
|
-
raise SchemaValidationError(
|
869
|
+
raise SchemaValidationError(
|
870
|
+
msg,
|
871
|
+
context={
|
872
|
+
"validation_type": "schema",
|
873
|
+
"schema_path": path,
|
874
|
+
},
|
875
|
+
)
|
860
876
|
if verbose:
|
861
877
|
logger.debug("Inner schema validated successfully")
|
862
878
|
logger.debug(
|
@@ -871,7 +887,20 @@ def validate_schema_file(
|
|
871
887
|
if "type" not in schema.get("schema", schema):
|
872
888
|
msg = f"Schema in {path} must specify a type"
|
873
889
|
logger.error(msg)
|
874
|
-
raise SchemaValidationError(
|
890
|
+
raise SchemaValidationError(
|
891
|
+
msg,
|
892
|
+
context={
|
893
|
+
"validation_type": "schema",
|
894
|
+
"schema_path": path,
|
895
|
+
},
|
896
|
+
)
|
897
|
+
|
898
|
+
# Validate schema against JSON Schema spec
|
899
|
+
try:
|
900
|
+
validate_json_schema(schema)
|
901
|
+
except SchemaValidationError as e:
|
902
|
+
logger.error("Schema validation error: %s", str(e))
|
903
|
+
raise # Re-raise to preserve error chain
|
875
904
|
|
876
905
|
# Return the full schema including wrapper
|
877
906
|
return schema
|
@@ -1225,19 +1254,24 @@ def handle_error(e: Exception) -> None:
|
|
1225
1254
|
Provides enhanced debug logging for CLI errors.
|
1226
1255
|
"""
|
1227
1256
|
# 1. Determine error type and message
|
1228
|
-
if isinstance(e,
|
1257
|
+
if isinstance(e, SchemaValidationError):
|
1258
|
+
msg = str(e) # Already formatted in SchemaValidationError
|
1259
|
+
exit_code = e.exit_code
|
1260
|
+
elif isinstance(e, ModelCreationError):
|
1261
|
+
# Unwrap ModelCreationError that might wrap SchemaValidationError
|
1262
|
+
if isinstance(e.__cause__, SchemaValidationError):
|
1263
|
+
return handle_error(e.__cause__)
|
1264
|
+
msg = f"Model creation error: {str(e)}"
|
1265
|
+
exit_code = ExitCode.SCHEMA_ERROR
|
1266
|
+
elif isinstance(e, click.UsageError):
|
1229
1267
|
msg = f"Usage error: {str(e)}"
|
1230
1268
|
exit_code = ExitCode.USAGE_ERROR
|
1231
1269
|
elif isinstance(e, SchemaFileError):
|
1232
|
-
# Preserve specific schema error handling
|
1233
1270
|
msg = str(e) # Use existing __str__ formatting
|
1234
1271
|
exit_code = ExitCode.SCHEMA_ERROR
|
1235
1272
|
elif isinstance(e, (InvalidJSONError, json.JSONDecodeError)):
|
1236
1273
|
msg = f"Invalid JSON error: {str(e)}"
|
1237
1274
|
exit_code = ExitCode.DATA_ERROR
|
1238
|
-
elif isinstance(e, SchemaValidationError):
|
1239
|
-
msg = f"Schema validation error: {str(e)}"
|
1240
|
-
exit_code = ExitCode.VALIDATION_ERROR
|
1241
1275
|
elif isinstance(e, CLIError):
|
1242
1276
|
msg = str(e) # Use existing __str__ formatting
|
1243
1277
|
exit_code = ExitCode(e.exit_code) # Convert int to ExitCode
|
@@ -1249,7 +1283,7 @@ def handle_error(e: Exception) -> None:
|
|
1249
1283
|
if isinstance(e, CLIError) and logger.isEnabledFor(logging.DEBUG):
|
1250
1284
|
# Format context fields with lowercase keys and simple values
|
1251
1285
|
context_str = ""
|
1252
|
-
if hasattr(e, "context"):
|
1286
|
+
if hasattr(e, "context") and e.context:
|
1253
1287
|
for key, value in sorted(e.context.items()):
|
1254
1288
|
if key not in {
|
1255
1289
|
"timestamp",
|
@@ -1257,13 +1291,18 @@ def handle_error(e: Exception) -> None:
|
|
1257
1291
|
"version",
|
1258
1292
|
"python_version",
|
1259
1293
|
}:
|
1260
|
-
|
1294
|
+
if isinstance(value, dict):
|
1295
|
+
context_str += (
|
1296
|
+
f"{key.lower()}:\n{json.dumps(value, indent=2)}\n"
|
1297
|
+
)
|
1298
|
+
else:
|
1299
|
+
context_str += f"{key.lower()}: {value}\n"
|
1261
1300
|
|
1262
|
-
|
1263
|
-
|
1264
|
-
|
1265
|
-
|
1266
|
-
|
1301
|
+
logger.debug(
|
1302
|
+
"Error details:\n"
|
1303
|
+
f"Type: {type(e).__name__}\n"
|
1304
|
+
f"{context_str.rstrip()}"
|
1305
|
+
)
|
1267
1306
|
elif not isinstance(e, click.UsageError):
|
1268
1307
|
logger.error(msg, exc_info=True)
|
1269
1308
|
else:
|
@@ -1467,30 +1506,11 @@ def run(
|
|
1467
1506
|
) -> None:
|
1468
1507
|
"""Run a structured task with template and schema.
|
1469
1508
|
|
1470
|
-
|
1471
|
-
|
1472
|
-
|
1473
|
-
|
1474
|
-
|
1475
|
-
|
1476
|
-
Examples:
|
1477
|
-
# Basic usage
|
1478
|
-
ostruct run task.j2 schema.json
|
1479
|
-
|
1480
|
-
# Process multiple files
|
1481
|
-
ostruct run task.j2 schema.json -f code main.py -f test tests/test_main.py
|
1482
|
-
|
1483
|
-
# Scan directories recursively
|
1484
|
-
ostruct run task.j2 schema.json -d src ./src -R
|
1485
|
-
|
1486
|
-
# Define variables
|
1487
|
-
ostruct run task.j2 schema.json -V debug=true -J config='{"env":"prod"}'
|
1488
|
-
|
1489
|
-
# Configure model
|
1490
|
-
ostruct run task.j2 schema.json -m gpt-4 --temperature 0.7 --max-output-tokens 1000
|
1491
|
-
|
1492
|
-
# Control output
|
1493
|
-
ostruct run task.j2 schema.json --output-file result.json --verbose
|
1509
|
+
Args:
|
1510
|
+
ctx: Click context
|
1511
|
+
task_template: Path to task template file
|
1512
|
+
schema_file: Path to schema file
|
1513
|
+
**kwargs: Additional CLI options
|
1494
1514
|
"""
|
1495
1515
|
try:
|
1496
1516
|
# Convert Click parameters to typed dict
|
@@ -1511,25 +1531,33 @@ def run(
|
|
1511
1531
|
try:
|
1512
1532
|
exit_code = loop.run_until_complete(run_cli_async(params))
|
1513
1533
|
sys.exit(int(exit_code))
|
1534
|
+
except SchemaValidationError as e:
|
1535
|
+
# Log the error with full context
|
1536
|
+
logger.error("Schema validation error: %s", str(e))
|
1537
|
+
if e.context:
|
1538
|
+
logger.debug(
|
1539
|
+
"Error context: %s", json.dumps(e.context, indent=2)
|
1540
|
+
)
|
1541
|
+
# Re-raise to preserve error chain and exit code
|
1542
|
+
raise
|
1543
|
+
except (CLIError, InvalidJSONError, SchemaFileError) as e:
|
1544
|
+
handle_error(e)
|
1545
|
+
sys.exit(
|
1546
|
+
e.exit_code
|
1547
|
+
if hasattr(e, "exit_code")
|
1548
|
+
else ExitCode.INTERNAL_ERROR
|
1549
|
+
)
|
1550
|
+
except click.UsageError as e:
|
1551
|
+
handle_error(e)
|
1552
|
+
sys.exit(ExitCode.USAGE_ERROR)
|
1553
|
+
except Exception as e:
|
1554
|
+
handle_error(e)
|
1555
|
+
sys.exit(ExitCode.INTERNAL_ERROR)
|
1514
1556
|
finally:
|
1515
1557
|
loop.close()
|
1516
|
-
|
1517
|
-
|
1518
|
-
|
1519
|
-
InvalidJSONError,
|
1520
|
-
SchemaFileError,
|
1521
|
-
SchemaValidationError,
|
1522
|
-
) as e:
|
1523
|
-
handle_error(e)
|
1524
|
-
sys.exit(
|
1525
|
-
e.exit_code if hasattr(e, "exit_code") else ExitCode.INTERNAL_ERROR
|
1526
|
-
)
|
1527
|
-
except click.UsageError as e:
|
1528
|
-
handle_error(e)
|
1529
|
-
sys.exit(ExitCode.USAGE_ERROR)
|
1530
|
-
except Exception as e:
|
1531
|
-
handle_error(e)
|
1532
|
-
sys.exit(ExitCode.INTERNAL_ERROR)
|
1558
|
+
except KeyboardInterrupt:
|
1559
|
+
logger.info("Operation cancelled by user")
|
1560
|
+
raise
|
1533
1561
|
|
1534
1562
|
|
1535
1563
|
# Remove the old @create_click_command() decorator and cli function definition
|
@@ -1582,6 +1610,7 @@ async def validate_inputs(
|
|
1582
1610
|
|
1583
1611
|
Raises:
|
1584
1612
|
CLIError: For various validation errors
|
1613
|
+
SchemaValidationError: When schema is invalid
|
1585
1614
|
"""
|
1586
1615
|
logger.debug("=== Input Validation Phase ===")
|
1587
1616
|
security_manager = validate_security_manager(
|
@@ -1593,10 +1622,22 @@ async def validate_inputs(
|
|
1593
1622
|
task_template = validate_task_template(
|
1594
1623
|
args.get("task"), args.get("task_file")
|
1595
1624
|
)
|
1625
|
+
|
1626
|
+
# Load and validate schema
|
1596
1627
|
logger.debug("Validating schema from %s", args["schema_file"])
|
1597
|
-
|
1598
|
-
|
1599
|
-
|
1628
|
+
try:
|
1629
|
+
schema = validate_schema_file(
|
1630
|
+
args["schema_file"], args.get("verbose", False)
|
1631
|
+
)
|
1632
|
+
|
1633
|
+
# Validate schema structure before any model creation
|
1634
|
+
validate_json_schema(
|
1635
|
+
schema
|
1636
|
+
) # This will raise SchemaValidationError if invalid
|
1637
|
+
except SchemaValidationError as e:
|
1638
|
+
logger.error("Schema validation error: %s", str(e))
|
1639
|
+
raise # Re-raise the SchemaValidationError to preserve the error chain
|
1640
|
+
|
1600
1641
|
template_context = await create_template_context_from_args(
|
1601
1642
|
args, security_manager
|
1602
1643
|
)
|
@@ -1675,6 +1716,7 @@ async def validate_model_and_schema(
|
|
1675
1716
|
ModelCreationError,
|
1676
1717
|
) as e:
|
1677
1718
|
logger.error("Schema error: %s", str(e))
|
1719
|
+
# Pass through the error without additional wrapping
|
1678
1720
|
raise
|
1679
1721
|
|
1680
1722
|
if not supports_structured_output(args["model"]):
|
@@ -1820,19 +1862,21 @@ async def execute_model(
|
|
1820
1862
|
async def run_cli_async(args: CLIParams) -> ExitCode:
|
1821
1863
|
"""Async wrapper for CLI operations.
|
1822
1864
|
|
1865
|
+
Args:
|
1866
|
+
args: CLI parameters.
|
1867
|
+
|
1823
1868
|
Returns:
|
1824
|
-
Exit code
|
1869
|
+
Exit code.
|
1825
1870
|
|
1826
1871
|
Raises:
|
1827
|
-
CLIError: For
|
1828
|
-
KeyboardInterrupt: When operation is cancelled by user
|
1872
|
+
CLIError: For errors during CLI operations.
|
1829
1873
|
"""
|
1830
1874
|
try:
|
1831
1875
|
# 0. Model Parameter Validation
|
1832
1876
|
logger.debug("=== Model Parameter Validation ===")
|
1833
1877
|
params = await validate_model_params(args)
|
1834
1878
|
|
1835
|
-
# 1. Input Validation Phase
|
1879
|
+
# 1. Input Validation Phase (includes schema validation)
|
1836
1880
|
security_manager, task_template, schema, template_context, env = (
|
1837
1881
|
await validate_inputs(args)
|
1838
1882
|
)
|
@@ -1849,15 +1893,12 @@ async def run_cli_async(args: CLIParams) -> ExitCode:
|
|
1849
1893
|
)
|
1850
1894
|
)
|
1851
1895
|
|
1852
|
-
# 4. Dry Run Output Phase
|
1896
|
+
# 4. Dry Run Output Phase - Moved after all validations
|
1853
1897
|
if args.get("dry_run", False):
|
1854
1898
|
logger.info("\n=== Dry Run Summary ===")
|
1899
|
+
# Only log success if we got this far (no validation errors)
|
1855
1900
|
logger.info("✓ Template rendered successfully")
|
1856
1901
|
logger.info("✓ Schema validation passed")
|
1857
|
-
logger.info("✓ Model compatibility validated")
|
1858
|
-
logger.info(
|
1859
|
-
f"✓ Token count: {total_tokens}/{registry.get_capabilities(args['model']).context_window}"
|
1860
|
-
)
|
1861
1902
|
|
1862
1903
|
if args.get("verbose", False):
|
1863
1904
|
logger.info("\nSystem Prompt:")
|
@@ -1867,6 +1908,7 @@ async def run_cli_async(args: CLIParams) -> ExitCode:
|
|
1867
1908
|
logger.info("-" * 40)
|
1868
1909
|
logger.info(user_prompt)
|
1869
1910
|
|
1911
|
+
# Return success only if we got here (no validation errors)
|
1870
1912
|
return ExitCode.SUCCESS
|
1871
1913
|
|
1872
1914
|
# 5. Execution Phase
|
@@ -1877,6 +1919,10 @@ async def run_cli_async(args: CLIParams) -> ExitCode:
|
|
1877
1919
|
except KeyboardInterrupt:
|
1878
1920
|
logger.info("Operation cancelled by user")
|
1879
1921
|
raise
|
1922
|
+
except SchemaValidationError as e:
|
1923
|
+
# Ensure schema validation errors are properly propagated with the correct exit code
|
1924
|
+
logger.error("Schema validation error: %s", str(e))
|
1925
|
+
raise # Re-raise the SchemaValidationError to preserve the error chain
|
1880
1926
|
except Exception as e:
|
1881
1927
|
if isinstance(e, CLIError):
|
1882
1928
|
raise # Let our custom errors propagate
|
ostruct/cli/errors.py
CHANGED
@@ -323,60 +323,6 @@ class SchemaFileError(CLIError):
|
|
323
323
|
return self.context.get("schema_path")
|
324
324
|
|
325
325
|
|
326
|
-
class SchemaValidationError(CLIError):
|
327
|
-
"""Error raised when a schema fails validation."""
|
328
|
-
|
329
|
-
def __init__(
|
330
|
-
self,
|
331
|
-
message: str,
|
332
|
-
context: Optional[Dict[str, Any]] = None,
|
333
|
-
):
|
334
|
-
context = context or {}
|
335
|
-
|
336
|
-
# Format error message with tips
|
337
|
-
formatted_message = [message]
|
338
|
-
|
339
|
-
if "path" in context:
|
340
|
-
formatted_message.append(f"\nLocation: {context['path']}")
|
341
|
-
|
342
|
-
if "found" in context:
|
343
|
-
formatted_message.append(f"Found: {context['found']}")
|
344
|
-
|
345
|
-
if "count" in context:
|
346
|
-
formatted_message.append(f"Count: {context['count']}")
|
347
|
-
|
348
|
-
if "missing_required" in context:
|
349
|
-
formatted_message.append(
|
350
|
-
f"Missing required: {context['missing_required']}"
|
351
|
-
)
|
352
|
-
|
353
|
-
if "extra_required" in context:
|
354
|
-
formatted_message.append(
|
355
|
-
f"Extra required: {context['extra_required']}"
|
356
|
-
)
|
357
|
-
|
358
|
-
if "prohibited_used" in context:
|
359
|
-
formatted_message.append(
|
360
|
-
f"Prohibited keywords used: {context['prohibited_used']}"
|
361
|
-
)
|
362
|
-
|
363
|
-
if "tips" in context:
|
364
|
-
formatted_message.append("\nHow to fix:")
|
365
|
-
for tip in context["tips"]:
|
366
|
-
if isinstance(tip, dict):
|
367
|
-
# Format JSON example
|
368
|
-
formatted_message.append("Example schema:")
|
369
|
-
formatted_message.append(json.dumps(tip, indent=2))
|
370
|
-
else:
|
371
|
-
formatted_message.append(f"- {tip}")
|
372
|
-
|
373
|
-
super().__init__(
|
374
|
-
"\n".join(formatted_message),
|
375
|
-
context=context,
|
376
|
-
exit_code=ExitCode.SCHEMA_ERROR,
|
377
|
-
)
|
378
|
-
|
379
|
-
|
380
326
|
class ModelCreationError(CLIError):
|
381
327
|
"""Base class for model creation errors."""
|
382
328
|
|
@@ -496,6 +442,67 @@ class OpenAIClientError(CLIError):
|
|
496
442
|
super().__init__(message, exit_code=exit_code, context=context)
|
497
443
|
|
498
444
|
|
445
|
+
class SchemaValidationError(ModelCreationError):
|
446
|
+
"""Raised when schema validation fails."""
|
447
|
+
|
448
|
+
def __init__(
|
449
|
+
self,
|
450
|
+
message: str,
|
451
|
+
context: Optional[Dict[str, Any]] = None,
|
452
|
+
exit_code: ExitCode = ExitCode.SCHEMA_ERROR,
|
453
|
+
):
|
454
|
+
context = context or {}
|
455
|
+
# Preserve validation type for error handling
|
456
|
+
context.setdefault("validation_type", "schema")
|
457
|
+
|
458
|
+
# Format error message with tips
|
459
|
+
formatted_message = []
|
460
|
+
|
461
|
+
if "path" in context:
|
462
|
+
formatted_message.append(f"\nLocation: {context['path']}")
|
463
|
+
|
464
|
+
if "found" in context:
|
465
|
+
formatted_message.append(f"Found: {context['found']}")
|
466
|
+
|
467
|
+
if "reference" in context:
|
468
|
+
formatted_message.append(f"Reference: {context['reference']}")
|
469
|
+
|
470
|
+
if "count" in context:
|
471
|
+
formatted_message.append(f"Count: {context['count']}")
|
472
|
+
|
473
|
+
if "missing_required" in context:
|
474
|
+
formatted_message.append(
|
475
|
+
f"Missing required: {context['missing_required']}"
|
476
|
+
)
|
477
|
+
|
478
|
+
if "extra_required" in context:
|
479
|
+
formatted_message.append(
|
480
|
+
f"Extra required: {context['extra_required']}"
|
481
|
+
)
|
482
|
+
|
483
|
+
if "prohibited_used" in context:
|
484
|
+
formatted_message.append(
|
485
|
+
f"Prohibited keywords used: {context['prohibited_used']}"
|
486
|
+
)
|
487
|
+
|
488
|
+
if "tips" in context:
|
489
|
+
formatted_message.append("\nHow to fix:")
|
490
|
+
for tip in context["tips"]:
|
491
|
+
if isinstance(tip, dict):
|
492
|
+
# Format JSON example
|
493
|
+
formatted_message.append("Example schema:")
|
494
|
+
formatted_message.append(json.dumps(tip, indent=2))
|
495
|
+
else:
|
496
|
+
formatted_message.append(f"- {tip}")
|
497
|
+
|
498
|
+
# Combine message with details
|
499
|
+
final_message = message
|
500
|
+
if formatted_message:
|
501
|
+
final_message += "\n" + "\n".join(formatted_message)
|
502
|
+
|
503
|
+
super().__init__(final_message, context=context, exit_code=exit_code)
|
504
|
+
|
505
|
+
|
499
506
|
# Export public API
|
500
507
|
__all__ = [
|
501
508
|
"VariableError",
|
ostruct/cli/model_creation.py
CHANGED
@@ -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
|
301
|
+
"""Create a Pydantic model from a JSON Schema.
|
301
302
|
|
302
303
|
Args:
|
303
|
-
schema: JSON
|
304
|
-
base_name:
|
305
|
-
show_schema: Whether to show the generated
|
306
|
-
debug_validation: Whether to show
|
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
|
-
|
310
|
+
Generated Pydantic model class
|
310
311
|
|
311
312
|
Raises:
|
312
|
-
|
313
|
-
|
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
|
-
#
|
321
|
-
|
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
|
-
|
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
|
-
#
|
446
|
-
model
|
379
|
+
# Create model class
|
380
|
+
model = create_model(base_name, __base__=BaseModel, **field_defs)
|
447
381
|
|
448
|
-
|
449
|
-
|
450
|
-
|
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
|
-
"
|
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
|
-
|
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
|
-
|
474
|
-
|
475
|
-
|
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
|
-
|
478
|
-
|
479
|
-
|
480
|
-
|
481
|
-
|
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:
|
ostruct/cli/security/errors.py
CHANGED
@@ -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"
|
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"\\")
|