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.
ostruct/cli/__init__.py CHANGED
@@ -8,6 +8,7 @@ from .cli import (
8
8
  validate_variable_mapping,
9
9
  )
10
10
  from .path_utils import validate_path_mapping
11
+ from .registry_updates import get_update_notification
11
12
 
12
13
  __all__ = [
13
14
  "ExitCode",
@@ -16,4 +17,5 @@ __all__ = [
16
17
  "validate_schema_file",
17
18
  "validate_task_template",
18
19
  "validate_variable_mapping",
20
+ "get_update_notification",
19
21
  ]
ostruct/cli/cli.py CHANGED
@@ -45,7 +45,10 @@ from openai_structured.errors import (
45
45
  OpenAIClientError,
46
46
  StreamBufferError,
47
47
  )
48
- from openai_structured.model_registry import ModelRegistry
48
+ from openai_structured.model_registry import (
49
+ ModelRegistry,
50
+ RegistryUpdateStatus,
51
+ )
49
52
  from pydantic import AnyUrl, BaseModel, EmailStr, Field
50
53
  from pydantic.fields import FieldInfo as FieldInfoType
51
54
  from pydantic.functional_validators import BeforeValidator
@@ -75,10 +78,15 @@ from .errors import (
75
78
  from .file_utils import FileInfoList, collect_files
76
79
  from .model_creation import _create_enum_type, create_dynamic_model
77
80
  from .path_utils import validate_path_mapping
81
+ from .registry_updates import get_update_notification
78
82
  from .security import SecurityManager
79
83
  from .serialization import LogSerializer
80
84
  from .template_env import create_jinja_env
81
- from .template_utils import SystemPromptError, render_template
85
+ from .template_utils import (
86
+ SystemPromptError,
87
+ render_template,
88
+ validate_json_schema,
89
+ )
82
90
  from .token_utils import estimate_tokens_with_encoding
83
91
 
84
92
  # Constants
@@ -831,7 +839,7 @@ def validate_schema_file(
831
839
  logger.error(msg)
832
840
  raise SchemaFileError(msg, schema_path=path)
833
841
  except Exception as e:
834
- if isinstance(e, InvalidJSONError):
842
+ if isinstance(e, (InvalidJSONError, SchemaValidationError)):
835
843
  raise
836
844
  msg = f"Failed to read schema file {path}: {e}"
837
845
  logger.error(msg)
@@ -846,7 +854,13 @@ def validate_schema_file(
846
854
  if not isinstance(schema, dict):
847
855
  msg = f"Schema in {path} must be a JSON object"
848
856
  logger.error(msg)
849
- raise SchemaValidationError(msg, context={"path": path})
857
+ raise SchemaValidationError(
858
+ msg,
859
+ context={
860
+ "validation_type": "schema",
861
+ "schema_path": path,
862
+ },
863
+ )
850
864
 
851
865
  # Validate schema structure
852
866
  if "schema" in schema:
@@ -856,7 +870,13 @@ def validate_schema_file(
856
870
  if not isinstance(inner_schema, dict):
857
871
  msg = f"Inner schema in {path} must be a JSON object"
858
872
  logger.error(msg)
859
- raise SchemaValidationError(msg, context={"path": path})
873
+ raise SchemaValidationError(
874
+ msg,
875
+ context={
876
+ "validation_type": "schema",
877
+ "schema_path": path,
878
+ },
879
+ )
860
880
  if verbose:
861
881
  logger.debug("Inner schema validated successfully")
862
882
  logger.debug(
@@ -871,7 +891,20 @@ def validate_schema_file(
871
891
  if "type" not in schema.get("schema", schema):
872
892
  msg = f"Schema in {path} must specify a type"
873
893
  logger.error(msg)
874
- raise SchemaValidationError(msg, context={"path": path})
894
+ raise SchemaValidationError(
895
+ msg,
896
+ context={
897
+ "validation_type": "schema",
898
+ "schema_path": path,
899
+ },
900
+ )
901
+
902
+ # Validate schema against JSON Schema spec
903
+ try:
904
+ validate_json_schema(schema)
905
+ except SchemaValidationError as e:
906
+ logger.error("Schema validation error: %s", str(e))
907
+ raise # Re-raise to preserve error chain
875
908
 
876
909
  # Return the full schema including wrapper
877
910
  return schema
@@ -1225,19 +1258,24 @@ def handle_error(e: Exception) -> None:
1225
1258
  Provides enhanced debug logging for CLI errors.
1226
1259
  """
1227
1260
  # 1. Determine error type and message
1228
- if isinstance(e, click.UsageError):
1261
+ if isinstance(e, SchemaValidationError):
1262
+ msg = str(e) # Already formatted in SchemaValidationError
1263
+ exit_code = e.exit_code
1264
+ elif isinstance(e, ModelCreationError):
1265
+ # Unwrap ModelCreationError that might wrap SchemaValidationError
1266
+ if isinstance(e.__cause__, SchemaValidationError):
1267
+ return handle_error(e.__cause__)
1268
+ msg = f"Model creation error: {str(e)}"
1269
+ exit_code = ExitCode.SCHEMA_ERROR
1270
+ elif isinstance(e, click.UsageError):
1229
1271
  msg = f"Usage error: {str(e)}"
1230
1272
  exit_code = ExitCode.USAGE_ERROR
1231
1273
  elif isinstance(e, SchemaFileError):
1232
- # Preserve specific schema error handling
1233
1274
  msg = str(e) # Use existing __str__ formatting
1234
1275
  exit_code = ExitCode.SCHEMA_ERROR
1235
1276
  elif isinstance(e, (InvalidJSONError, json.JSONDecodeError)):
1236
1277
  msg = f"Invalid JSON error: {str(e)}"
1237
1278
  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
1279
  elif isinstance(e, CLIError):
1242
1280
  msg = str(e) # Use existing __str__ formatting
1243
1281
  exit_code = ExitCode(e.exit_code) # Convert int to ExitCode
@@ -1249,7 +1287,7 @@ def handle_error(e: Exception) -> None:
1249
1287
  if isinstance(e, CLIError) and logger.isEnabledFor(logging.DEBUG):
1250
1288
  # Format context fields with lowercase keys and simple values
1251
1289
  context_str = ""
1252
- if hasattr(e, "context"):
1290
+ if hasattr(e, "context") and e.context:
1253
1291
  for key, value in sorted(e.context.items()):
1254
1292
  if key not in {
1255
1293
  "timestamp",
@@ -1257,13 +1295,18 @@ def handle_error(e: Exception) -> None:
1257
1295
  "version",
1258
1296
  "python_version",
1259
1297
  }:
1260
- context_str += f"{key.lower()}: {value}\n"
1298
+ if isinstance(value, dict):
1299
+ context_str += (
1300
+ f"{key.lower()}:\n{json.dumps(value, indent=2)}\n"
1301
+ )
1302
+ else:
1303
+ context_str += f"{key.lower()}: {value}\n"
1261
1304
 
1262
- logger.debug(
1263
- "Error details:\n"
1264
- f"Type: {type(e).__name__}\n"
1265
- f"{context_str.rstrip()}"
1266
- )
1305
+ logger.debug(
1306
+ "Error details:\n"
1307
+ f"Type: {type(e).__name__}\n"
1308
+ f"{context_str.rstrip()}"
1309
+ )
1267
1310
  elif not isinstance(e, click.UsageError):
1268
1311
  logger.error(msg, exc_info=True)
1269
1312
  else:
@@ -1451,7 +1494,14 @@ def cli() -> None:
1451
1494
 
1452
1495
  ostruct run task.j2 schema.json -J config='{"env":"prod"}' -m o3-mini
1453
1496
  """
1454
- pass
1497
+ # Check for registry updates in a non-intrusive way
1498
+ try:
1499
+ update_message = get_update_notification()
1500
+ if update_message:
1501
+ click.secho(f"Note: {update_message}", fg="blue", err=True)
1502
+ except Exception:
1503
+ # Ensure any errors don't affect normal operation
1504
+ pass
1455
1505
 
1456
1506
 
1457
1507
  @cli.command()
@@ -1467,30 +1517,11 @@ def run(
1467
1517
  ) -> None:
1468
1518
  """Run a structured task with template and schema.
1469
1519
 
1470
- TASK_TEMPLATE is the path to your Jinja2 template file that defines the task.
1471
- SCHEMA_FILE is the path to your JSON schema file that defines the expected output structure.
1472
-
1473
- The command supports various options for file handling, variable definition,
1474
- model configuration, and output control. Use --help to see all available options.
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
1520
+ Args:
1521
+ ctx: Click context
1522
+ task_template: Path to task template file
1523
+ schema_file: Path to schema file
1524
+ **kwargs: Additional CLI options
1494
1525
  """
1495
1526
  try:
1496
1527
  # Convert Click parameters to typed dict
@@ -1511,29 +1542,107 @@ def run(
1511
1542
  try:
1512
1543
  exit_code = loop.run_until_complete(run_cli_async(params))
1513
1544
  sys.exit(int(exit_code))
1545
+ except SchemaValidationError as e:
1546
+ # Log the error with full context
1547
+ logger.error("Schema validation error: %s", str(e))
1548
+ if e.context:
1549
+ logger.debug(
1550
+ "Error context: %s", json.dumps(e.context, indent=2)
1551
+ )
1552
+ # Re-raise to preserve error chain and exit code
1553
+ raise
1554
+ except (CLIError, InvalidJSONError, SchemaFileError) as e:
1555
+ handle_error(e)
1556
+ sys.exit(
1557
+ e.exit_code
1558
+ if hasattr(e, "exit_code")
1559
+ else ExitCode.INTERNAL_ERROR
1560
+ )
1561
+ except click.UsageError as e:
1562
+ handle_error(e)
1563
+ sys.exit(ExitCode.USAGE_ERROR)
1564
+ except Exception as e:
1565
+ handle_error(e)
1566
+ sys.exit(ExitCode.INTERNAL_ERROR)
1514
1567
  finally:
1515
1568
  loop.close()
1569
+ except KeyboardInterrupt:
1570
+ logger.info("Operation cancelled by user")
1571
+ raise
1516
1572
 
1517
- except (
1518
- CLIError,
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)
1533
1573
 
1574
+ @cli.command("update-registry")
1575
+ @click.option(
1576
+ "--url",
1577
+ help="URL to fetch the registry from. Defaults to official repository.",
1578
+ default=None,
1579
+ )
1580
+ @click.option(
1581
+ "--force",
1582
+ is_flag=True,
1583
+ help="Force update even if the registry is already up to date.",
1584
+ default=False,
1585
+ )
1586
+ def update_registry(url: Optional[str] = None, force: bool = False) -> None:
1587
+ """Update the model registry with the latest model definitions.
1588
+
1589
+ This command fetches the latest model registry from the official repository
1590
+ or a custom URL if provided, and updates the local registry file.
1591
+
1592
+ Example:
1593
+ ostruct update-registry
1594
+ ostruct update-registry --url https://example.com/models.yml
1595
+ """
1596
+ try:
1597
+ registry = ModelRegistry()
1598
+
1599
+ # Show current registry config path
1600
+ config_path = registry._config_path
1601
+ click.echo(f"Current registry file: {config_path}")
1602
+
1603
+ if force:
1604
+ click.echo("Forcing registry update...")
1605
+ success = registry.refresh_from_remote(url)
1606
+ if success:
1607
+ click.echo("✅ Registry successfully updated!")
1608
+ else:
1609
+ click.echo(
1610
+ "❌ Failed to update registry. See logs for details."
1611
+ )
1612
+ sys.exit(ExitCode.SUCCESS.value)
1613
+
1614
+ if config_path is None or not os.path.exists(config_path):
1615
+ click.echo("Registry file not found. Creating new one...")
1616
+ success = registry.refresh_from_remote(url)
1617
+ if success:
1618
+ click.echo("✅ Registry successfully created!")
1619
+ else:
1620
+ click.echo(
1621
+ "❌ Failed to create registry. See logs for details."
1622
+ )
1623
+ sys.exit(ExitCode.SUCCESS.value)
1624
+
1625
+ # Use the built-in update checking functionality
1626
+ click.echo("Checking for updates...")
1627
+ update_result = registry.check_for_updates()
1534
1628
 
1535
- # Remove the old @create_click_command() decorator and cli function definition
1536
- # Keep all the other functions and code below this point
1629
+ if update_result.status == RegistryUpdateStatus.UPDATE_AVAILABLE:
1630
+ click.echo(
1631
+ f"{click.style('✓', fg='green')} {update_result.message}"
1632
+ )
1633
+ exit_code = ExitCode.SUCCESS
1634
+ elif update_result.status == RegistryUpdateStatus.ALREADY_CURRENT:
1635
+ click.echo(
1636
+ f"{click.style('✓', fg='green')} Registry is up to date"
1637
+ )
1638
+ exit_code = ExitCode.SUCCESS
1639
+ else:
1640
+ click.echo("❓ Unable to determine if updates are available.")
1641
+
1642
+ sys.exit(exit_code)
1643
+ except Exception as e:
1644
+ click.echo(f"❌ Error updating registry: {str(e)}")
1645
+ sys.exit(ExitCode.API_ERROR.value)
1537
1646
 
1538
1647
 
1539
1648
  async def validate_model_params(args: CLIParams) -> Dict[str, Any]:
@@ -1582,6 +1691,7 @@ async def validate_inputs(
1582
1691
 
1583
1692
  Raises:
1584
1693
  CLIError: For various validation errors
1694
+ SchemaValidationError: When schema is invalid
1585
1695
  """
1586
1696
  logger.debug("=== Input Validation Phase ===")
1587
1697
  security_manager = validate_security_manager(
@@ -1593,10 +1703,22 @@ async def validate_inputs(
1593
1703
  task_template = validate_task_template(
1594
1704
  args.get("task"), args.get("task_file")
1595
1705
  )
1706
+
1707
+ # Load and validate schema
1596
1708
  logger.debug("Validating schema from %s", args["schema_file"])
1597
- schema = validate_schema_file(
1598
- args["schema_file"], args.get("verbose", False)
1599
- )
1709
+ try:
1710
+ schema = validate_schema_file(
1711
+ args["schema_file"], args.get("verbose", False)
1712
+ )
1713
+
1714
+ # Validate schema structure before any model creation
1715
+ validate_json_schema(
1716
+ schema
1717
+ ) # This will raise SchemaValidationError if invalid
1718
+ except SchemaValidationError as e:
1719
+ logger.error("Schema validation error: %s", str(e))
1720
+ raise # Re-raise the SchemaValidationError to preserve the error chain
1721
+
1600
1722
  template_context = await create_template_context_from_args(
1601
1723
  args, security_manager
1602
1724
  )
@@ -1675,6 +1797,7 @@ async def validate_model_and_schema(
1675
1797
  ModelCreationError,
1676
1798
  ) as e:
1677
1799
  logger.error("Schema error: %s", str(e))
1800
+ # Pass through the error without additional wrapping
1678
1801
  raise
1679
1802
 
1680
1803
  if not supports_structured_output(args["model"]):
@@ -1820,19 +1943,21 @@ async def execute_model(
1820
1943
  async def run_cli_async(args: CLIParams) -> ExitCode:
1821
1944
  """Async wrapper for CLI operations.
1822
1945
 
1946
+ Args:
1947
+ args: CLI parameters.
1948
+
1823
1949
  Returns:
1824
- Exit code to return from the CLI
1950
+ Exit code.
1825
1951
 
1826
1952
  Raises:
1827
- CLIError: For various error conditions
1828
- KeyboardInterrupt: When operation is cancelled by user
1953
+ CLIError: For errors during CLI operations.
1829
1954
  """
1830
1955
  try:
1831
1956
  # 0. Model Parameter Validation
1832
1957
  logger.debug("=== Model Parameter Validation ===")
1833
1958
  params = await validate_model_params(args)
1834
1959
 
1835
- # 1. Input Validation Phase
1960
+ # 1. Input Validation Phase (includes schema validation)
1836
1961
  security_manager, task_template, schema, template_context, env = (
1837
1962
  await validate_inputs(args)
1838
1963
  )
@@ -1849,15 +1974,12 @@ async def run_cli_async(args: CLIParams) -> ExitCode:
1849
1974
  )
1850
1975
  )
1851
1976
 
1852
- # 4. Dry Run Output Phase
1977
+ # 4. Dry Run Output Phase - Moved after all validations
1853
1978
  if args.get("dry_run", False):
1854
1979
  logger.info("\n=== Dry Run Summary ===")
1980
+ # Only log success if we got this far (no validation errors)
1855
1981
  logger.info("✓ Template rendered successfully")
1856
1982
  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
1983
 
1862
1984
  if args.get("verbose", False):
1863
1985
  logger.info("\nSystem Prompt:")
@@ -1867,6 +1989,7 @@ async def run_cli_async(args: CLIParams) -> ExitCode:
1867
1989
  logger.info("-" * 40)
1868
1990
  logger.info(user_prompt)
1869
1991
 
1992
+ # Return success only if we got here (no validation errors)
1870
1993
  return ExitCode.SUCCESS
1871
1994
 
1872
1995
  # 5. Execution Phase
@@ -1877,6 +2000,10 @@ async def run_cli_async(args: CLIParams) -> ExitCode:
1877
2000
  except KeyboardInterrupt:
1878
2001
  logger.info("Operation cancelled by user")
1879
2002
  raise
2003
+ except SchemaValidationError as e:
2004
+ # Ensure schema validation errors are properly propagated with the correct exit code
2005
+ logger.error("Schema validation error: %s", str(e))
2006
+ raise # Re-raise the SchemaValidationError to preserve the error chain
1880
2007
  except Exception as e:
1881
2008
  if isinstance(e, CLIError):
1882
2009
  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",