ostruct-cli 0.7.1__py3-none-any.whl → 0.8.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.
Files changed (46) hide show
  1. ostruct/cli/__init__.py +21 -3
  2. ostruct/cli/base_errors.py +1 -1
  3. ostruct/cli/cli.py +66 -1983
  4. ostruct/cli/click_options.py +460 -28
  5. ostruct/cli/code_interpreter.py +238 -0
  6. ostruct/cli/commands/__init__.py +32 -0
  7. ostruct/cli/commands/list_models.py +128 -0
  8. ostruct/cli/commands/quick_ref.py +50 -0
  9. ostruct/cli/commands/run.py +137 -0
  10. ostruct/cli/commands/update_registry.py +71 -0
  11. ostruct/cli/config.py +277 -0
  12. ostruct/cli/cost_estimation.py +134 -0
  13. ostruct/cli/errors.py +310 -6
  14. ostruct/cli/exit_codes.py +1 -0
  15. ostruct/cli/explicit_file_processor.py +548 -0
  16. ostruct/cli/field_utils.py +69 -0
  17. ostruct/cli/file_info.py +42 -9
  18. ostruct/cli/file_list.py +301 -102
  19. ostruct/cli/file_search.py +455 -0
  20. ostruct/cli/file_utils.py +47 -13
  21. ostruct/cli/mcp_integration.py +541 -0
  22. ostruct/cli/model_creation.py +150 -1
  23. ostruct/cli/model_validation.py +204 -0
  24. ostruct/cli/progress_reporting.py +398 -0
  25. ostruct/cli/registry_updates.py +14 -9
  26. ostruct/cli/runner.py +1418 -0
  27. ostruct/cli/schema_utils.py +113 -0
  28. ostruct/cli/services.py +626 -0
  29. ostruct/cli/template_debug.py +748 -0
  30. ostruct/cli/template_debug_help.py +162 -0
  31. ostruct/cli/template_env.py +15 -6
  32. ostruct/cli/template_filters.py +55 -3
  33. ostruct/cli/template_optimizer.py +474 -0
  34. ostruct/cli/template_processor.py +1080 -0
  35. ostruct/cli/template_rendering.py +69 -34
  36. ostruct/cli/token_validation.py +286 -0
  37. ostruct/cli/types.py +78 -0
  38. ostruct/cli/unattended_operation.py +269 -0
  39. ostruct/cli/validators.py +386 -3
  40. {ostruct_cli-0.7.1.dist-info → ostruct_cli-0.8.0.dist-info}/LICENSE +2 -0
  41. ostruct_cli-0.8.0.dist-info/METADATA +633 -0
  42. ostruct_cli-0.8.0.dist-info/RECORD +69 -0
  43. {ostruct_cli-0.7.1.dist-info → ostruct_cli-0.8.0.dist-info}/WHEEL +1 -1
  44. ostruct_cli-0.7.1.dist-info/METADATA +0 -369
  45. ostruct_cli-0.7.1.dist-info/RECORD +0 -45
  46. {ostruct_cli-0.7.1.dist-info → ostruct_cli-0.8.0.dist-info}/entry_points.txt +0 -0
ostruct/cli/cli.py CHANGED
@@ -1,2014 +1,99 @@
1
- """Command-line interface for making structured OpenAI API calls."""
1
+ """Minimal CLI entry point for ostruct."""
2
2
 
3
- import asyncio
4
- import json
5
- import logging
6
- import os
7
3
  import sys
8
- from typing import (
9
- Any,
10
- AsyncGenerator,
11
- Dict,
12
- List,
13
- Literal,
14
- Optional,
15
- Set,
16
- Tuple,
17
- Type,
18
- TypedDict,
19
- TypeVar,
20
- Union,
21
- cast,
22
- overload,
23
- )
24
-
25
- if sys.version_info >= (3, 11):
26
- pass
27
-
28
- from datetime import date, datetime, time
29
- from pathlib import Path
4
+ from typing import Optional
30
5
 
31
6
  import click
32
- import jinja2
33
- import yaml
34
- from openai import AsyncOpenAI
35
- from openai_structured.client import (
36
- async_openai_structured_stream,
37
- supports_structured_output,
38
- )
39
- from openai_structured.errors import (
40
- APIResponseError,
41
- EmptyResponseError,
42
- InvalidResponseFormatError,
43
- ModelNotSupportedError,
44
- ModelVersionError,
45
- OpenAIClientError,
46
- StreamBufferError,
47
- )
48
- from openai_structured.model_registry import (
49
- ModelRegistry,
50
- RegistryUpdateStatus,
51
- )
52
- from pydantic import AnyUrl, BaseModel, EmailStr, Field
53
- from pydantic.fields import FieldInfo as FieldInfoType
54
- from pydantic.functional_validators import BeforeValidator
55
- from pydantic.types import constr
56
- from typing_extensions import TypeAlias
57
-
58
- from ostruct.cli.click_options import all_options
59
- from ostruct.cli.exit_codes import ExitCode
60
7
 
61
- from .. import __version__ # noqa: F401 - Used in package metadata
8
+ from .. import __version__
9
+ from .commands import create_command_group
10
+ from .config import OstructConfig
62
11
  from .errors import (
63
12
  CLIError,
64
- DirectoryNotFoundError,
65
13
  InvalidJSONError,
66
- ModelCreationError,
67
- OstructFileNotFoundError,
68
- PathSecurityError,
69
14
  SchemaFileError,
70
15
  SchemaValidationError,
71
- StreamInterruptedError,
72
- StreamParseError,
73
- TaskTemplateSyntaxError,
74
- TaskTemplateVariableError,
75
- VariableNameError,
76
- VariableValueError,
16
+ handle_error,
77
17
  )
78
- from .file_utils import FileInfoList, collect_files
79
- from .model_creation import _create_enum_type, create_dynamic_model
80
- from .path_utils import validate_path_mapping
18
+ from .exit_codes import ExitCode
81
19
  from .registry_updates import get_update_notification
82
- from .security import SecurityManager
83
- from .serialization import LogSerializer
84
- from .template_env import create_jinja_env
85
- from .template_utils import (
86
- SystemPromptError,
87
- render_template,
88
- validate_json_schema,
89
- )
90
- from .token_utils import estimate_tokens_with_encoding
91
-
92
- # Constants
93
- DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant."
94
-
95
-
96
- # Validation functions
97
- def pattern(regex: str) -> Any:
98
- return constr(pattern=regex)
99
-
100
-
101
- def min_length(length: int) -> Any:
102
- return BeforeValidator(lambda v: v if len(str(v)) >= length else None)
103
-
104
-
105
- def max_length(length: int) -> Any:
106
- return BeforeValidator(lambda v: v if len(str(v)) <= length else None)
107
-
108
-
109
- def ge(value: Union[int, float]) -> Any:
110
- return BeforeValidator(lambda v: v if float(v) >= value else None)
111
-
112
-
113
- def le(value: Union[int, float]) -> Any:
114
- return BeforeValidator(lambda v: v if float(v) <= value else None)
115
-
116
-
117
- def gt(value: Union[int, float]) -> Any:
118
- return BeforeValidator(lambda v: v if float(v) > value else None)
119
-
120
-
121
- def lt(value: Union[int, float]) -> Any:
122
- return BeforeValidator(lambda v: v if float(v) < value else None)
123
-
124
-
125
- def multiple_of(value: Union[int, float]) -> Any:
126
- return BeforeValidator(lambda v: v if float(v) % value == 0 else None)
127
-
128
-
129
- def create_template_context(
130
- files: Optional[
131
- Dict[str, Union[FileInfoList, str, List[str], Dict[str, str]]]
132
- ] = None,
133
- variables: Optional[Dict[str, str]] = None,
134
- json_variables: Optional[Dict[str, Any]] = None,
135
- security_manager: Optional[SecurityManager] = None,
136
- stdin_content: Optional[str] = None,
137
- ) -> Dict[str, Any]:
138
- """Create template context from files and variables."""
139
- context: Dict[str, Any] = {}
140
20
 
141
- # Add file variables
142
- if files:
143
- for name, file_list in files.items():
144
- context[name] = file_list # Always keep FileInfoList wrapper
145
21
 
146
- # Add simple variables
147
- if variables:
148
- context.update(variables)
149
-
150
- # Add JSON variables
151
- if json_variables:
152
- context.update(json_variables)
153
-
154
- # Add stdin if provided
155
- if stdin_content is not None:
156
- context["stdin"] = stdin_content
157
-
158
- return context
159
-
160
-
161
- class CLIParams(TypedDict, total=False):
162
- """Type-safe CLI parameters."""
163
-
164
- files: List[
165
- Tuple[str, str]
166
- ] # List of (name, path) tuples from Click's nargs=2
167
- dir: List[
168
- Tuple[str, str]
169
- ] # List of (name, dir) tuples from Click's nargs=2
170
- patterns: List[
171
- Tuple[str, str]
172
- ] # List of (name, pattern) tuples from Click's nargs=2
173
- allowed_dirs: List[str]
174
- base_dir: str
175
- allowed_dir_file: Optional[str]
176
- recursive: bool
177
- var: List[str]
178
- json_var: List[str]
179
- system_prompt: Optional[str]
180
- system_prompt_file: Optional[str]
181
- ignore_task_sysprompt: bool
182
- model: str
183
- timeout: float
184
- output_file: Optional[str]
185
- dry_run: bool
186
- no_progress: bool
187
- api_key: Optional[str]
188
- verbose: bool
189
- debug_openai_stream: bool
190
- show_model_schema: bool
191
- debug_validation: bool
192
- temperature: Optional[float]
193
- max_output_tokens: Optional[int]
194
- top_p: Optional[float]
195
- frequency_penalty: Optional[float]
196
- presence_penalty: Optional[float]
197
- reasoning_effort: Optional[str]
198
- progress_level: str
199
- task_file: Optional[str]
200
- task: Optional[str]
201
- schema_file: str
202
-
203
-
204
- # Set up logging
205
- logger = logging.getLogger(__name__)
206
-
207
- # Configure openai_structured logging based on debug flag
208
- openai_logger = logging.getLogger("openai_structured")
209
- openai_logger.setLevel(logging.DEBUG) # Allow all messages through to handlers
210
- openai_logger.propagate = False # Prevent propagation to root logger
211
-
212
- # Remove any existing handlers
213
- for handler in openai_logger.handlers:
214
- openai_logger.removeHandler(handler)
215
-
216
- # Create a file handler for openai_structured logger that captures all levels
217
- log_dir = os.path.expanduser("~/.ostruct/logs")
218
- os.makedirs(log_dir, exist_ok=True)
219
- openai_file_handler = logging.FileHandler(
220
- os.path.join(log_dir, "openai_stream.log")
221
- )
222
- openai_file_handler.setLevel(logging.DEBUG) # Always capture debug in file
223
- openai_file_handler.setFormatter(
224
- logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
225
- )
226
- openai_logger.addHandler(openai_file_handler)
227
-
228
- # Create a file handler for the main logger that captures all levels
229
- ostruct_file_handler = logging.FileHandler(
230
- os.path.join(log_dir, "ostruct.log")
231
- )
232
- ostruct_file_handler.setLevel(logging.DEBUG) # Always capture debug in file
233
- ostruct_file_handler.setFormatter(
234
- logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
235
- )
236
- logger.addHandler(ostruct_file_handler)
237
-
238
-
239
- # Type aliases
240
- FieldType = (
241
- Any # Changed from Type[Any] to allow both concrete types and generics
242
- )
243
- FieldDefinition = Tuple[FieldType, FieldInfoType]
244
- ModelType = TypeVar("ModelType", bound=BaseModel)
245
- ItemType: TypeAlias = Type[BaseModel]
246
- ValueType: TypeAlias = Type[Any]
247
-
248
-
249
- def _create_field(**kwargs: Any) -> FieldInfoType:
250
- """Create a Pydantic Field with the given kwargs."""
251
- field: FieldInfoType = Field(**kwargs)
252
- return field
253
-
254
-
255
- def _get_type_with_constraints(
256
- field_schema: Dict[str, Any], field_name: str, base_name: str
257
- ) -> FieldDefinition:
258
- """Get type with constraints from field schema.
259
-
260
- Args:
261
- field_schema: Field schema dict
262
- field_name: Name of the field
263
- base_name: Base name for nested models
264
-
265
- Returns:
266
- Tuple of (type, field)
267
- """
268
- field_kwargs: Dict[str, Any] = {}
22
+ def create_cli_group() -> click.Group:
23
+ """Create the main CLI group with all commands."""
269
24
 
270
- # Add common field metadata
271
- if "title" in field_schema:
272
- field_kwargs["title"] = field_schema["title"]
273
- if "description" in field_schema:
274
- field_kwargs["description"] = field_schema["description"]
275
- if "default" in field_schema:
276
- field_kwargs["default"] = field_schema["default"]
277
- if "readOnly" in field_schema:
278
- field_kwargs["frozen"] = field_schema["readOnly"]
279
-
280
- field_type = field_schema.get("type")
281
-
282
- # Handle array type
283
- if field_type == "array":
284
- items_schema = field_schema.get("items", {})
285
- if not items_schema:
286
- return (List[Any], Field(**field_kwargs))
287
-
288
- # Create nested model for object items
289
- if (
290
- isinstance(items_schema, dict)
291
- and items_schema.get("type") == "object"
292
- ):
293
- array_item_model = create_dynamic_model(
294
- items_schema,
295
- base_name=f"{base_name}_{field_name}_Item",
296
- show_schema=False,
297
- debug_validation=False,
298
- )
299
- array_type: Type[List[Any]] = List[array_item_model] # type: ignore
300
- return (array_type, Field(**field_kwargs))
301
-
302
- # For non-object items, use the type directly
303
- item_type = items_schema.get("type", "string")
304
- if item_type == "string":
305
- return (List[str], Field(**field_kwargs))
306
- elif item_type == "integer":
307
- return (List[int], Field(**field_kwargs))
308
- elif item_type == "number":
309
- return (List[float], Field(**field_kwargs))
310
- elif item_type == "boolean":
311
- return (List[bool], Field(**field_kwargs))
312
- else:
313
- return (List[Any], Field(**field_kwargs))
314
-
315
- # Handle object type
316
- if field_type == "object":
317
- # Create nested model with explicit type annotation
318
- object_model = create_dynamic_model(
319
- field_schema,
320
- base_name=f"{base_name}_{field_name}",
321
- show_schema=False,
322
- debug_validation=False,
323
- )
324
- return (object_model, Field(**field_kwargs))
325
-
326
- # Handle additionalProperties
327
- if "additionalProperties" in field_schema and isinstance(
328
- field_schema["additionalProperties"], dict
329
- ):
330
- # Create nested model with explicit type annotation
331
- dict_value_model = create_dynamic_model(
332
- field_schema["additionalProperties"],
333
- base_name=f"{base_name}_{field_name}_Value",
334
- show_schema=False,
335
- debug_validation=False,
336
- )
337
- dict_type: Type[Dict[str, Any]] = Dict[str, dict_value_model] # type: ignore[valid-type]
338
- return (dict_type, Field(**field_kwargs))
339
-
340
- # Handle other types
341
- if field_type == "string":
342
- field_type_cls: Type[Any] = str
343
-
344
- # Add string-specific constraints to field_kwargs
345
- if "pattern" in field_schema:
346
- field_kwargs["pattern"] = field_schema["pattern"]
347
- if "minLength" in field_schema:
348
- field_kwargs["min_length"] = field_schema["minLength"]
349
- if "maxLength" in field_schema:
350
- field_kwargs["max_length"] = field_schema["maxLength"]
351
-
352
- # Handle special string formats
353
- if "format" in field_schema:
354
- if field_schema["format"] == "date-time":
355
- field_type_cls = datetime
356
- elif field_schema["format"] == "date":
357
- field_type_cls = date
358
- elif field_schema["format"] == "time":
359
- field_type_cls = time
360
- elif field_schema["format"] == "email":
361
- field_type_cls = EmailStr
362
- elif field_schema["format"] == "uri":
363
- field_type_cls = AnyUrl
364
-
365
- return (field_type_cls, Field(**field_kwargs))
366
-
367
- if field_type == "number":
368
- field_type_cls = float
369
-
370
- # Add number-specific constraints to field_kwargs
371
- if "minimum" in field_schema:
372
- field_kwargs["ge"] = field_schema["minimum"]
373
- if "maximum" in field_schema:
374
- field_kwargs["le"] = field_schema["maximum"]
375
- if "exclusiveMinimum" in field_schema:
376
- field_kwargs["gt"] = field_schema["exclusiveMinimum"]
377
- if "exclusiveMaximum" in field_schema:
378
- field_kwargs["lt"] = field_schema["exclusiveMaximum"]
379
- if "multipleOf" in field_schema:
380
- field_kwargs["multiple_of"] = field_schema["multipleOf"]
381
-
382
- return (field_type_cls, Field(**field_kwargs))
383
-
384
- if field_type == "integer":
385
- field_type_cls = int
386
-
387
- # Add integer-specific constraints to field_kwargs
388
- if "minimum" in field_schema:
389
- field_kwargs["ge"] = field_schema["minimum"]
390
- if "maximum" in field_schema:
391
- field_kwargs["le"] = field_schema["maximum"]
392
- if "exclusiveMinimum" in field_schema:
393
- field_kwargs["gt"] = field_schema["exclusiveMinimum"]
394
- if "exclusiveMaximum" in field_schema:
395
- field_kwargs["lt"] = field_schema["exclusiveMaximum"]
396
- if "multipleOf" in field_schema:
397
- field_kwargs["multiple_of"] = field_schema["multipleOf"]
398
-
399
- return (field_type_cls, Field(**field_kwargs))
400
-
401
- if field_type == "boolean":
402
- return (bool, Field(**field_kwargs))
403
-
404
- if field_type == "null":
405
- return (type(None), Field(**field_kwargs))
406
-
407
- # Handle enum
408
- if "enum" in field_schema:
409
- enum_type = _create_enum_type(field_schema["enum"], field_name)
410
- return (cast(Type[Any], enum_type), Field(**field_kwargs))
411
-
412
- # Default to Any for unknown types
413
- return (Any, Field(**field_kwargs))
414
-
415
-
416
- T = TypeVar("T")
417
- K = TypeVar("K")
418
- V = TypeVar("V")
419
-
420
-
421
- def validate_token_limits(
422
- model: str, total_tokens: int, max_token_limit: Optional[int] = None
423
- ) -> None:
424
- """Validate token counts against model limits."""
425
- registry = ModelRegistry()
426
- capabilities = registry.get_capabilities(model)
427
- context_limit = capabilities.context_window
428
- output_limit = (
429
- max_token_limit
430
- if max_token_limit is not None
431
- else capabilities.max_output_tokens
25
+ @click.group()
26
+ @click.version_option(version=__version__)
27
+ @click.option(
28
+ "--config",
29
+ type=click.Path(exists=True),
30
+ help="Configuration file path (default: ostruct.yaml)",
432
31
  )
32
+ @click.pass_context
33
+ def cli_group(ctx: click.Context, config: Optional[str] = None) -> None:
34
+ """ostruct - AI-powered structured output with multi-tool integration.
433
35
 
434
- # Check if total tokens exceed context window
435
- if total_tokens >= context_limit:
436
- raise ValueError(
437
- f"Total tokens ({total_tokens:,}) exceed model's context window limit "
438
- f"of {context_limit:,} tokens"
439
- )
36
+ ostruct transforms unstructured inputs into structured JSON using OpenAI APIs,
37
+ Jinja2 templates, and powerful tool integrations including Code Interpreter,
38
+ File Search, and MCP servers.
440
39
 
441
- # Check if there's enough room for output tokens
442
- remaining_tokens = context_limit - total_tokens
443
- if remaining_tokens < output_limit:
444
- raise ValueError(
445
- f"Only {remaining_tokens:,} tokens remaining in context window, but "
446
- f"output may require up to {output_limit:,} tokens"
447
- )
40
+ 🚀 QUICK START:
41
+ ostruct run template.j2 schema.json -V name=value
448
42
 
43
+ 📁 FILE ROUTING (explicit tool assignment):
44
+ -ft/--file-for-template Template access only
45
+ -fc/--file-for-code-interpreter Code execution & analysis
46
+ -fs/--file-for-file-search Document search & retrieval
449
47
 
450
- def process_system_prompt(
451
- task_template: str,
452
- system_prompt: Optional[str],
453
- system_prompt_file: Optional[str],
454
- template_context: Dict[str, Any],
455
- env: jinja2.Environment,
456
- ignore_task_sysprompt: bool = False,
457
- ) -> str:
458
- """Process system prompt from various sources.
48
+ EXAMPLES:
49
+ # Basic usage (unchanged)
50
+ ostruct run template.j2 schema.json -f config.yaml
459
51
 
460
- Args:
461
- task_template: The task template string
462
- system_prompt: Optional system prompt string
463
- system_prompt_file: Optional path to system prompt file
464
- template_context: Template context for rendering
465
- env: Jinja2 environment
466
- ignore_task_sysprompt: Whether to ignore system prompt in task template
52
+ # Multi-tool explicit routing
53
+ ostruct run analysis.j2 schema.json -fc data.csv -fs docs.pdf -ft config.yaml
467
54
 
468
- Returns:
469
- The final system prompt string
55
+ # Advanced routing with --file-for
56
+ ostruct run task.j2 schema.json --file-for code-interpreter shared.json --file-for file-search shared.json
470
57
 
471
- Raises:
472
- SystemPromptError: If the system prompt cannot be loaded or rendered
473
- FileNotFoundError: If a prompt file does not exist
474
- PathSecurityError: If a prompt file path violates security constraints
475
- """
476
- # Default system prompt
477
- default_prompt = "You are a helpful assistant."
58
+ # MCP server integration
59
+ ostruct run template.j2 schema.json --mcp-server deepwiki@https://mcp.deepwiki.com/sse
478
60
 
479
- # Check for conflicting arguments
480
- if system_prompt is not None and system_prompt_file is not None:
481
- raise SystemPromptError(
482
- "Cannot specify both --system-prompt and --system-prompt-file"
483
- )
484
-
485
- # Try to get system prompt from CLI argument first
486
- if system_prompt_file is not None:
61
+ 📖 For detailed documentation: https://ostruct.readthedocs.io
62
+ """
63
+ # Load configuration
487
64
  try:
488
- name, path = validate_path_mapping(
489
- f"system_prompt={system_prompt_file}"
490
- )
491
- with open(path, "r", encoding="utf-8") as f:
492
- system_prompt = f.read().strip()
493
- except OstructFileNotFoundError as e:
494
- raise SystemPromptError(
495
- f"Failed to load system prompt file: {e}"
496
- ) from e
497
- except PathSecurityError as e:
498
- raise SystemPromptError(f"Invalid system prompt file: {e}") from e
499
-
500
- if system_prompt is not None:
501
- # Render system prompt with template context
502
- try:
503
- template = env.from_string(system_prompt)
504
- return cast(str, template.render(**template_context).strip())
505
- except jinja2.TemplateError as e:
506
- raise SystemPromptError(f"Error rendering system prompt: {e}")
507
-
508
- # If not ignoring task template system prompt, try to extract it
509
- if not ignore_task_sysprompt:
510
- try:
511
- # Extract YAML frontmatter
512
- if task_template.startswith("---\n"):
513
- end = task_template.find("\n---\n", 4)
514
- if end != -1:
515
- frontmatter = task_template[4:end]
516
- try:
517
- metadata = yaml.safe_load(frontmatter)
518
- if (
519
- isinstance(metadata, dict)
520
- and "system_prompt" in metadata
521
- ):
522
- system_prompt = str(metadata["system_prompt"])
523
- # Render system prompt with template context
524
- try:
525
- template = env.from_string(system_prompt)
526
- return cast(
527
- str,
528
- template.render(
529
- **template_context
530
- ).strip(),
531
- )
532
- except jinja2.TemplateError as e:
533
- raise SystemPromptError(
534
- f"Error rendering system prompt: {e}"
535
- )
536
- except yaml.YAMLError as e:
537
- raise SystemPromptError(
538
- f"Invalid YAML frontmatter: {e}"
539
- )
540
-
65
+ app_config = OstructConfig.load(config)
66
+ ctx.ensure_object(dict)
67
+ ctx.obj["config"] = app_config
541
68
  except Exception as e:
542
- raise SystemPromptError(
543
- f"Error extracting system prompt from template: {e}"
544
- )
545
-
546
- # Fall back to default
547
- return default_prompt
548
-
549
-
550
- def validate_variable_mapping(
551
- mapping: str, is_json: bool = False
552
- ) -> tuple[str, Any]:
553
- """Validate a variable mapping in name=value format."""
554
- try:
555
- name, value = mapping.split("=", 1)
556
- if not name:
557
- raise VariableNameError(
558
- f"Empty name in {'JSON ' if is_json else ''}variable mapping"
69
+ click.secho(
70
+ f"Warning: Failed to load configuration: {e}",
71
+ fg="yellow",
72
+ err=True,
559
73
  )
74
+ # Use default configuration
75
+ ctx.ensure_object(dict)
76
+ ctx.obj["config"] = OstructConfig()
560
77
 
561
- if is_json:
562
- try:
563
- value = json.loads(value)
564
- except json.JSONDecodeError as e:
565
- raise InvalidJSONError(
566
- f"Invalid JSON value for variable {name!r}: {value!r}",
567
- context={"variable_name": name},
568
- ) from e
569
-
570
- return name, value
571
-
572
- except ValueError as e:
573
- if "not enough values to unpack" in str(e):
574
- raise VariableValueError(
575
- f"Invalid {'JSON ' if is_json else ''}variable mapping "
576
- f"(expected name=value format): {mapping!r}"
577
- )
578
- raise
579
-
580
-
581
- @overload
582
- def _validate_path_mapping_internal(
583
- mapping: str,
584
- is_dir: Literal[True],
585
- base_dir: Optional[str] = None,
586
- security_manager: Optional[SecurityManager] = None,
587
- ) -> Tuple[str, str]: ...
588
-
589
-
590
- @overload
591
- def _validate_path_mapping_internal(
592
- mapping: str,
593
- is_dir: Literal[False] = False,
594
- base_dir: Optional[str] = None,
595
- security_manager: Optional[SecurityManager] = None,
596
- ) -> Tuple[str, str]: ...
597
-
598
-
599
- def _validate_path_mapping_internal(
600
- mapping: str,
601
- is_dir: bool = False,
602
- base_dir: Optional[str] = None,
603
- security_manager: Optional[SecurityManager] = None,
604
- ) -> Tuple[str, str]:
605
- """Validate a path mapping in the format "name=path".
606
-
607
- Args:
608
- mapping: The path mapping string (e.g., "myvar=/path/to/file").
609
- is_dir: Whether the path is expected to be a directory (True) or file (False).
610
- base_dir: Optional base directory to resolve relative paths against.
611
- security_manager: Optional security manager to validate paths.
612
-
613
- Returns:
614
- A (name, path) tuple.
615
-
616
- Raises:
617
- VariableNameError: If the variable name portion is empty or invalid.
618
- DirectoryNotFoundError: If is_dir=True and the path is not a directory or doesn't exist.
619
- FileNotFoundError: If is_dir=False and the path is not a file or doesn't exist.
620
- PathSecurityError: If the path is inaccessible or outside the allowed directory.
621
- ValueError: If the format is invalid (missing "=").
622
- OSError: If there is an underlying OS error (permissions, etc.).
623
- """
624
- logger = logging.getLogger(__name__)
625
- logger.debug("Starting path validation for mapping: %r", mapping)
626
- logger.debug("Parameters - is_dir: %r, base_dir: %r", is_dir, base_dir)
627
-
628
- try:
629
- if not mapping or "=" not in mapping:
630
- logger.debug("Invalid mapping format: %r", mapping)
631
- raise ValueError(
632
- "Invalid path mapping format. Expected format: name=path"
633
- )
634
-
635
- name, path = mapping.split("=", 1)
636
- logger.debug("Split mapping - name: %r, path: %r", name, path)
637
-
638
- if not name:
639
- logger.debug("Empty name in mapping")
640
- raise VariableNameError(
641
- f"Empty name in {'directory' if is_dir else 'file'} mapping"
642
- )
643
-
644
- if not path:
645
- logger.debug("Empty path in mapping")
646
- raise VariableValueError("Path cannot be empty")
647
-
648
- # Convert to Path object and resolve against base_dir if provided
649
- logger.debug("Creating Path object for: %r", path)
650
- path_obj = Path(path)
651
- if base_dir:
652
- logger.debug("Resolving against base_dir: %r", base_dir)
653
- path_obj = Path(base_dir) / path_obj
654
- logger.debug("Path object created: %r", path_obj)
655
-
656
- # Resolve the path to catch directory traversal attempts
657
- try:
658
- logger.debug("Attempting to resolve path: %r", path_obj)
659
- resolved_path = path_obj.resolve()
660
- logger.debug("Resolved path: %r", resolved_path)
661
- except OSError as e:
662
- logger.error("Failed to resolve path: %s", e)
663
- raise OSError(f"Failed to resolve path: {e}")
664
-
665
- # Check for directory traversal
666
- try:
667
- base_path = (
668
- Path.cwd() if base_dir is None else Path(base_dir).resolve()
669
- )
670
- if not str(resolved_path).startswith(str(base_path)):
671
- raise PathSecurityError(
672
- f"Path {str(path)!r} resolves to {str(resolved_path)!r} which is outside "
673
- f"base directory {str(base_path)!r}"
674
- )
675
- except OSError as e:
676
- raise OSError(f"Failed to resolve base path: {e}")
677
-
678
- # Check if path exists
679
- if not resolved_path.exists():
680
- if is_dir:
681
- raise DirectoryNotFoundError(f"Directory not found: {path!r}")
682
- else:
683
- raise FileNotFoundError(f"File not found: {path!r}")
684
-
685
- # Check if path is correct type
686
- if is_dir and not resolved_path.is_dir():
687
- raise DirectoryNotFoundError(f"Path is not a directory: {path!r}")
688
- elif not is_dir and not resolved_path.is_file():
689
- raise FileNotFoundError(f"Path is not a file: {path!r}")
690
-
691
- # Check if path is accessible
692
- try:
693
- if is_dir:
694
- os.listdir(str(resolved_path))
695
- else:
696
- with open(str(resolved_path), "r", encoding="utf-8") as f:
697
- f.read(1)
698
- except OSError as e:
699
- if e.errno == 13: # Permission denied
700
- raise PathSecurityError(
701
- f"Permission denied accessing path: {path!r}",
702
- error_logged=True,
703
- )
704
- raise
705
-
706
- if security_manager:
707
- try:
708
- security_manager.validate_path(str(resolved_path))
709
- except PathSecurityError:
710
- raise PathSecurityError.from_expanded_paths(
711
- original_path=str(path),
712
- expanded_path=str(resolved_path),
713
- base_dir=str(security_manager.base_dir),
714
- allowed_dirs=[
715
- str(d) for d in security_manager.allowed_dirs
716
- ],
717
- error_logged=True,
718
- )
719
-
720
- # Return the original path to maintain relative paths in the output
721
- return name, path
722
-
723
- except ValueError as e:
724
- if "not enough values to unpack" in str(e):
725
- raise VariableValueError(
726
- f"Invalid {'directory' if is_dir else 'file'} mapping "
727
- f"(expected name=path format): {mapping!r}"
728
- )
729
- raise
730
-
731
-
732
- def validate_task_template(
733
- task: Optional[str], task_file: Optional[str]
734
- ) -> str:
735
- """Validate and load a task template.
736
-
737
- Args:
738
- task: The task template string
739
- task_file: Path to task template file
740
-
741
- Returns:
742
- The task template string
743
-
744
- Raises:
745
- TaskTemplateVariableError: If neither task nor task_file is provided, or if both are provided
746
- TaskTemplateSyntaxError: If the template has invalid syntax
747
- FileNotFoundError: If the template file does not exist
748
- PathSecurityError: If the template file path violates security constraints
749
- """
750
- if task is not None and task_file is not None:
751
- raise TaskTemplateVariableError(
752
- "Cannot specify both --task and --task-file"
753
- )
754
-
755
- if task is None and task_file is None:
756
- raise TaskTemplateVariableError(
757
- "Must specify either --task or --task-file"
758
- )
759
-
760
- template_content: str
761
- if task_file is not None:
762
- try:
763
- with open(task_file, "r", encoding="utf-8") as f:
764
- template_content = f.read()
765
- except FileNotFoundError:
766
- raise TaskTemplateVariableError(
767
- f"Task template file not found: {task_file}"
768
- )
769
- except PermissionError:
770
- raise TaskTemplateVariableError(
771
- f"Permission denied reading task template file: {task_file}"
772
- )
773
- except Exception as e:
774
- raise TaskTemplateVariableError(
775
- f"Error reading task template file: {e}"
776
- )
777
- else:
778
- template_content = task # type: ignore # We know task is str here due to the checks above
779
-
780
- try:
781
- env = jinja2.Environment(undefined=jinja2.StrictUndefined)
782
- env.parse(template_content)
783
- return template_content
784
- except jinja2.TemplateSyntaxError as e:
785
- raise TaskTemplateSyntaxError(
786
- f"Invalid task template syntax at line {e.lineno}: {e.message}"
787
- )
788
-
789
-
790
- def validate_schema_file(
791
- path: str,
792
- verbose: bool = False,
793
- ) -> Dict[str, Any]:
794
- """Validate and load a JSON schema file.
795
-
796
- Args:
797
- path: Path to schema file
798
- verbose: Whether to enable verbose logging
799
-
800
- Returns:
801
- The validated schema
802
-
803
- Raises:
804
- SchemaFileError: When file cannot be read
805
- InvalidJSONError: When file contains invalid JSON
806
- SchemaValidationError: When schema is invalid
807
- """
808
- if verbose:
809
- logger.info("Validating schema file: %s", path)
810
-
811
- try:
812
- logger.debug("Opening schema file: %s", path)
813
- with open(path, "r", encoding="utf-8") as f:
814
- logger.debug("Loading JSON from schema file")
815
- try:
816
- schema = json.load(f)
817
- logger.debug(
818
- "Successfully loaded JSON: %s",
819
- json.dumps(schema, indent=2),
820
- )
821
- except json.JSONDecodeError as e:
822
- logger.error("JSON decode error in %s: %s", path, str(e))
823
- logger.debug(
824
- "Error details - line: %d, col: %d, msg: %s",
825
- e.lineno,
826
- e.colno,
827
- e.msg,
828
- )
829
- raise InvalidJSONError(
830
- f"Invalid JSON in schema file {path}: {e}",
831
- context={"schema_path": path},
832
- ) from e
833
- except FileNotFoundError:
834
- msg = f"Schema file not found: {path}"
835
- logger.error(msg)
836
- raise SchemaFileError(msg, schema_path=path)
837
- except PermissionError:
838
- msg = f"Permission denied reading schema file: {path}"
839
- logger.error(msg)
840
- raise SchemaFileError(msg, schema_path=path)
841
- except Exception as e:
842
- if isinstance(e, (InvalidJSONError, SchemaValidationError)):
843
- raise
844
- msg = f"Failed to read schema file {path}: {e}"
845
- logger.error(msg)
846
- logger.debug("Unexpected error details: %s", str(e))
847
- raise SchemaFileError(msg, schema_path=path) from e
848
-
849
- # Pre-validation structure checks
850
- if verbose:
851
- logger.info("Performing pre-validation structure checks")
852
- logger.debug("Loaded schema: %s", json.dumps(schema, indent=2))
853
-
854
- if not isinstance(schema, dict):
855
- msg = f"Schema in {path} must be a JSON object"
856
- logger.error(msg)
857
- raise SchemaValidationError(
858
- msg,
859
- context={
860
- "validation_type": "schema",
861
- "schema_path": path,
862
- },
863
- )
864
-
865
- # Validate schema structure
866
- if "schema" in schema:
867
- if verbose:
868
- logger.debug("Found schema wrapper, validating inner schema")
869
- inner_schema = schema["schema"]
870
- if not isinstance(inner_schema, dict):
871
- msg = f"Inner schema in {path} must be a JSON object"
872
- logger.error(msg)
873
- raise SchemaValidationError(
874
- msg,
875
- context={
876
- "validation_type": "schema",
877
- "schema_path": path,
878
- },
879
- )
880
- if verbose:
881
- logger.debug("Inner schema validated successfully")
882
- logger.debug(
883
- "Inner schema: %s", json.dumps(inner_schema, indent=2)
884
- )
885
- else:
886
- if verbose:
887
- logger.debug("No schema wrapper found, using schema as-is")
888
- logger.debug("Schema: %s", json.dumps(schema, indent=2))
889
-
890
- # Additional schema validation
891
- if "type" not in schema.get("schema", schema):
892
- msg = f"Schema in {path} must specify a type"
893
- logger.error(msg)
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
908
-
909
- # Return the full schema including wrapper
910
- return schema
911
-
912
-
913
- def collect_template_files(
914
- args: CLIParams,
915
- security_manager: SecurityManager,
916
- ) -> Dict[str, Union[FileInfoList, str, List[str], Dict[str, str]]]:
917
- """Collect files from command line arguments.
918
-
919
- Args:
920
- args: Command line arguments
921
- security_manager: Security manager for path validation
922
-
923
- Returns:
924
- Dictionary mapping variable names to file info objects
925
-
926
- Raises:
927
- PathSecurityError: If any file paths violate security constraints
928
- ValueError: If file mappings are invalid or files cannot be accessed
929
- """
930
- try:
931
- # Get files, directories, and patterns from args - they are already tuples from Click's nargs=2
932
- files = list(
933
- args.get("files", [])
934
- ) # List of (name, path) tuples from Click
935
- dirs = args.get("dir", []) # List of (name, dir) tuples from Click
936
- patterns = args.get(
937
- "patterns", []
938
- ) # List of (name, pattern) tuples from Click
939
-
940
- # Collect files from directories and patterns
941
- dir_files = collect_files(
942
- file_mappings=cast(List[Tuple[str, Union[str, Path]]], files),
943
- dir_mappings=cast(List[Tuple[str, Union[str, Path]]], dirs),
944
- pattern_mappings=cast(
945
- List[Tuple[str, Union[str, Path]]], patterns
946
- ),
947
- dir_recursive=args.get("recursive", False),
948
- security_manager=security_manager,
949
- )
950
-
951
- # Combine results
952
- return cast(
953
- Dict[str, Union[FileInfoList, str, List[str], Dict[str, str]]],
954
- dir_files,
955
- )
956
- except PathSecurityError:
957
- # Let PathSecurityError propagate without wrapping
958
- raise
959
- except (FileNotFoundError, DirectoryNotFoundError) as e:
960
- # Convert FileNotFoundError to OstructFileNotFoundError
961
- if isinstance(e, FileNotFoundError):
962
- raise OstructFileNotFoundError(str(e))
963
- # Let DirectoryNotFoundError propagate
964
- raise
965
- except Exception as e:
966
- # Don't wrap InvalidJSONError
967
- if isinstance(e, InvalidJSONError):
968
- raise
969
- # Check if this is a wrapped security error
970
- if isinstance(e.__cause__, PathSecurityError):
971
- raise e.__cause__
972
- # Wrap other errors
973
- raise ValueError(f"Error collecting files: {e}")
974
-
975
-
976
- def collect_simple_variables(args: CLIParams) -> Dict[str, str]:
977
- """Collect simple string variables from --var arguments.
978
-
979
- Args:
980
- args: Command line arguments
981
-
982
- Returns:
983
- Dictionary mapping variable names to string values
984
-
985
- Raises:
986
- VariableNameError: If a variable name is invalid or duplicate
987
- """
988
- variables: Dict[str, str] = {}
989
- all_names: Set[str] = set()
990
-
991
- if args.get("var"):
992
- for mapping in args["var"]:
993
- try:
994
- # Handle both tuple format and string format
995
- if isinstance(mapping, tuple):
996
- name, value = mapping
997
- else:
998
- name, value = mapping.split("=", 1)
999
-
1000
- if not name.isidentifier():
1001
- raise VariableNameError(f"Invalid variable name: {name}")
1002
- if name in all_names:
1003
- raise VariableNameError(f"Duplicate variable name: {name}")
1004
- variables[name] = value
1005
- all_names.add(name)
1006
- except ValueError:
1007
- raise VariableNameError(
1008
- f"Invalid variable mapping (expected name=value format): {mapping!r}"
1009
- )
1010
-
1011
- return variables
1012
-
1013
-
1014
- def collect_json_variables(args: CLIParams) -> Dict[str, Any]:
1015
- """Collect JSON variables from --json-var arguments.
1016
-
1017
- Args:
1018
- args: Command line arguments
1019
-
1020
- Returns:
1021
- Dictionary mapping variable names to parsed JSON values
1022
-
1023
- Raises:
1024
- VariableNameError: If a variable name is invalid or duplicate
1025
- InvalidJSONError: If a JSON value is invalid
1026
- """
1027
- variables: Dict[str, Any] = {}
1028
- all_names: Set[str] = set()
1029
-
1030
- if args.get("json_var"):
1031
- for mapping in args["json_var"]:
1032
- try:
1033
- # Handle both tuple format and string format
1034
- if isinstance(mapping, tuple):
1035
- name, value = (
1036
- mapping # Value is already parsed by Click validator
1037
- )
1038
- else:
1039
- try:
1040
- name, json_str = mapping.split("=", 1)
1041
- except ValueError:
1042
- raise VariableNameError(
1043
- f"Invalid JSON variable mapping format: {mapping}. Expected name=json"
1044
- )
1045
- try:
1046
- value = json.loads(json_str)
1047
- except json.JSONDecodeError as e:
1048
- raise InvalidJSONError(
1049
- f"Invalid JSON value for variable '{name}': {json_str}",
1050
- context={"variable_name": name},
1051
- ) from e
1052
-
1053
- if not name.isidentifier():
1054
- raise VariableNameError(f"Invalid variable name: {name}")
1055
- if name in all_names:
1056
- raise VariableNameError(f"Duplicate variable name: {name}")
1057
-
1058
- variables[name] = value
1059
- all_names.add(name)
1060
- except (VariableNameError, InvalidJSONError):
1061
- raise
1062
-
1063
- return variables
1064
-
1065
-
1066
- async def create_template_context_from_args(
1067
- args: CLIParams,
1068
- security_manager: SecurityManager,
1069
- ) -> Dict[str, Any]:
1070
- """Create template context from command line arguments.
1071
-
1072
- Args:
1073
- args: Command line arguments
1074
- security_manager: Security manager for path validation
1075
-
1076
- Returns:
1077
- Template context dictionary
1078
-
1079
- Raises:
1080
- PathSecurityError: If any file paths violate security constraints
1081
- VariableError: If variable mappings are invalid
1082
- ValueError: If file mappings are invalid or files cannot be accessed
1083
- """
1084
- try:
1085
- # Collect files from arguments
1086
- files = collect_template_files(args, security_manager)
1087
-
1088
- # Collect simple variables
1089
- variables = collect_simple_variables(args)
1090
-
1091
- # Collect JSON variables
1092
- json_variables = collect_json_variables(args)
1093
-
1094
- # Get stdin content if available
1095
- stdin_content = None
78
+ # Check for registry updates in a non-intrusive way
1096
79
  try:
1097
- if not sys.stdin.isatty():
1098
- stdin_content = sys.stdin.read()
1099
- except (OSError, IOError):
1100
- # Skip stdin if it can't be read
80
+ update_message = get_update_notification()
81
+ if update_message:
82
+ click.secho(f"Note: {update_message}", fg="blue", err=True)
83
+ except Exception:
84
+ # Ensure any errors don't affect normal operation
1101
85
  pass
1102
86
 
1103
- context = create_template_context(
1104
- files=files,
1105
- variables=variables,
1106
- json_variables=json_variables,
1107
- security_manager=security_manager,
1108
- stdin_content=stdin_content,
1109
- )
1110
-
1111
- # Add current model to context
1112
- context["current_model"] = args["model"]
1113
-
1114
- return context
1115
-
1116
- except PathSecurityError:
1117
- # Let PathSecurityError propagate without wrapping
1118
- raise
1119
- except (FileNotFoundError, DirectoryNotFoundError) as e:
1120
- # Convert FileNotFoundError to OstructFileNotFoundError
1121
- if isinstance(e, FileNotFoundError):
1122
- raise OstructFileNotFoundError(str(e))
1123
- # Let DirectoryNotFoundError propagate
1124
- raise
1125
- except Exception as e:
1126
- # Don't wrap InvalidJSONError
1127
- if isinstance(e, InvalidJSONError):
1128
- raise
1129
- # Check if this is a wrapped security error
1130
- if isinstance(e.__cause__, PathSecurityError):
1131
- raise e.__cause__
1132
- # Wrap other errors
1133
- raise ValueError(f"Error collecting files: {e}")
1134
-
1135
-
1136
- def validate_security_manager(
1137
- base_dir: Optional[str] = None,
1138
- allowed_dirs: Optional[List[str]] = None,
1139
- allowed_dir_file: Optional[str] = None,
1140
- ) -> SecurityManager:
1141
- """Validate and create security manager.
1142
-
1143
- Args:
1144
- base_dir: Base directory for file access. Defaults to current working directory.
1145
- allowed_dirs: Optional list of additional allowed directories
1146
- allowed_dir_file: Optional file containing allowed directories
1147
-
1148
- Returns:
1149
- Configured SecurityManager instance
1150
-
1151
- Raises:
1152
- PathSecurityError: If any paths violate security constraints
1153
- DirectoryNotFoundError: If any directories do not exist
1154
- """
1155
- # Use current working directory if base_dir is None
1156
- if base_dir is None:
1157
- base_dir = os.getcwd()
1158
-
1159
- # Create security manager with base directory
1160
- security_manager = SecurityManager(base_dir)
1161
-
1162
- # Add explicitly allowed directories
1163
- if allowed_dirs:
1164
- for dir_path in allowed_dirs:
1165
- security_manager.add_allowed_directory(dir_path)
1166
-
1167
- # Add directories from file if specified
1168
- if allowed_dir_file:
1169
- try:
1170
- with open(allowed_dir_file, "r", encoding="utf-8") as f:
1171
- for line in f:
1172
- line = line.strip()
1173
- if line and not line.startswith("#"):
1174
- security_manager.add_allowed_directory(line)
1175
- except OSError as e:
1176
- raise DirectoryNotFoundError(
1177
- f"Failed to read allowed directories file: {e}"
1178
- )
1179
-
1180
- return security_manager
1181
-
1182
-
1183
- def parse_var(var_str: str) -> Tuple[str, str]:
1184
- """Parse a simple variable string in the format 'name=value'.
1185
-
1186
- Args:
1187
- var_str: Variable string in format 'name=value'
1188
-
1189
- Returns:
1190
- Tuple of (name, value)
1191
-
1192
- Raises:
1193
- VariableNameError: If variable name is empty or invalid
1194
- VariableValueError: If variable format is invalid
1195
- """
1196
- try:
1197
- name, value = var_str.split("=", 1)
1198
- if not name:
1199
- raise VariableNameError("Empty name in variable mapping")
1200
- if not name.isidentifier():
1201
- raise VariableNameError(
1202
- f"Invalid variable name: {name}. Must be a valid Python identifier"
1203
- )
1204
- return name, value
1205
- except ValueError as e:
1206
- if "not enough values to unpack" in str(e):
1207
- raise VariableValueError(
1208
- f"Invalid variable mapping (expected name=value format): {var_str!r}"
1209
- )
1210
- raise
1211
-
87
+ # Add all commands from the command module
88
+ command_group = create_command_group()
89
+ for command in command_group.commands.values():
90
+ cli_group.add_command(command)
1212
91
 
1213
- def parse_json_var(var_str: str) -> Tuple[str, Any]:
1214
- """Parse a JSON variable string in the format 'name=json_value'.
1215
-
1216
- Args:
1217
- var_str: Variable string in format 'name=json_value'
1218
-
1219
- Returns:
1220
- Tuple of (name, parsed_value)
1221
-
1222
- Raises:
1223
- VariableNameError: If variable name is empty or invalid
1224
- VariableValueError: If variable format is invalid
1225
- InvalidJSONError: If JSON value is invalid
1226
- """
1227
- try:
1228
- name, json_str = var_str.split("=", 1)
1229
- if not name:
1230
- raise VariableNameError("Empty name in JSON variable mapping")
1231
- if not name.isidentifier():
1232
- raise VariableNameError(
1233
- f"Invalid variable name: {name}. Must be a valid Python identifier"
1234
- )
1235
-
1236
- try:
1237
- value = json.loads(json_str)
1238
- except json.JSONDecodeError as e:
1239
- raise InvalidJSONError(
1240
- f"Error parsing JSON for variable '{name}': {str(e)}. Input was: {json_str}",
1241
- context={"variable_name": name},
1242
- )
1243
-
1244
- return name, value
1245
-
1246
- except ValueError as e:
1247
- if "not enough values to unpack" in str(e):
1248
- raise VariableValueError(
1249
- f"Invalid JSON variable mapping (expected name=json format): {var_str!r}"
1250
- )
1251
- raise
1252
-
1253
-
1254
- def handle_error(e: Exception) -> None:
1255
- """Handle CLI errors and display appropriate messages.
1256
-
1257
- Maintains specific error type handling while reducing duplication.
1258
- Provides enhanced debug logging for CLI errors.
1259
- """
1260
- # 1. Determine error type and message
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):
1271
- msg = f"Usage error: {str(e)}"
1272
- exit_code = ExitCode.USAGE_ERROR
1273
- elif isinstance(e, SchemaFileError):
1274
- msg = str(e) # Use existing __str__ formatting
1275
- exit_code = ExitCode.SCHEMA_ERROR
1276
- elif isinstance(e, (InvalidJSONError, json.JSONDecodeError)):
1277
- msg = f"Invalid JSON error: {str(e)}"
1278
- exit_code = ExitCode.DATA_ERROR
1279
- elif isinstance(e, CLIError):
1280
- msg = str(e) # Use existing __str__ formatting
1281
- exit_code = ExitCode(e.exit_code) # Convert int to ExitCode
1282
- else:
1283
- msg = f"Unexpected error: {str(e)}"
1284
- exit_code = ExitCode.INTERNAL_ERROR
1285
-
1286
- # 2. Debug logging
1287
- if isinstance(e, CLIError) and logger.isEnabledFor(logging.DEBUG):
1288
- # Format context fields with lowercase keys and simple values
1289
- context_str = ""
1290
- if hasattr(e, "context") and e.context:
1291
- for key, value in sorted(e.context.items()):
1292
- if key not in {
1293
- "timestamp",
1294
- "host",
1295
- "version",
1296
- "python_version",
1297
- }:
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"
1304
-
1305
- logger.debug(
1306
- "Error details:\n"
1307
- f"Type: {type(e).__name__}\n"
1308
- f"{context_str.rstrip()}"
1309
- )
1310
- elif not isinstance(e, click.UsageError):
1311
- logger.error(msg, exc_info=True)
1312
- else:
1313
- logger.error(msg)
92
+ return cli_group
1314
93
 
1315
- # 3. User output
1316
- click.secho(msg, fg="red", err=True)
1317
- sys.exit(exit_code)
1318
94
 
1319
-
1320
- def validate_model_parameters(model: str, params: Dict[str, Any]) -> None:
1321
- """Validate model parameters against model capabilities.
1322
-
1323
- Args:
1324
- model: The model name to validate parameters for
1325
- params: Dictionary of parameter names and values to validate
1326
-
1327
- Raises:
1328
- CLIError: If any parameters are not supported by the model
1329
- """
1330
- try:
1331
- capabilities = ModelRegistry().get_capabilities(model)
1332
- for param_name, value in params.items():
1333
- try:
1334
- capabilities.validate_parameter(param_name, value)
1335
- except OpenAIClientError as e:
1336
- logger.error(
1337
- "Validation failed for model %s: %s", model, str(e)
1338
- )
1339
- raise CLIError(
1340
- str(e),
1341
- exit_code=ExitCode.VALIDATION_ERROR,
1342
- context={
1343
- "model": model,
1344
- "param": param_name,
1345
- "value": value,
1346
- },
1347
- )
1348
- except (ModelNotSupportedError, ModelVersionError) as e:
1349
- logger.error("Model validation failed: %s", str(e))
1350
- raise CLIError(
1351
- str(e),
1352
- exit_code=ExitCode.VALIDATION_ERROR,
1353
- context={"model": model},
1354
- )
1355
-
1356
-
1357
- async def stream_structured_output(
1358
- client: AsyncOpenAI,
1359
- model: str,
1360
- system_prompt: str,
1361
- user_prompt: str,
1362
- output_schema: Type[BaseModel],
1363
- output_file: Optional[str] = None,
1364
- **kwargs: Any,
1365
- ) -> AsyncGenerator[BaseModel, None]:
1366
- """Stream structured output from OpenAI API.
1367
-
1368
- This function follows the guide's recommendation for a focused async streaming function.
1369
- It handles the core streaming logic and resource cleanup.
1370
-
1371
- Args:
1372
- client: The OpenAI client to use
1373
- model: The model to use
1374
- system_prompt: The system prompt to use
1375
- user_prompt: The user prompt to use
1376
- output_schema: The Pydantic model to validate responses against
1377
- output_file: Optional file to write output to
1378
- **kwargs: Additional parameters to pass to the API
1379
-
1380
- Returns:
1381
- An async generator yielding validated model instances
1382
-
1383
- Raises:
1384
- ValueError: If the model does not support structured output or parameters are invalid
1385
- StreamInterruptedError: If the stream is interrupted
1386
- APIResponseError: If there is an API error
1387
- """
1388
- try:
1389
- # Check if model supports structured output using openai_structured's function
1390
- if not supports_structured_output(model):
1391
- raise ValueError(
1392
- f"Model {model} does not support structured output with json_schema response format. "
1393
- "Please use a model that supports structured output."
1394
- )
1395
-
1396
- # Extract non-model parameters
1397
- on_log = kwargs.pop("on_log", None)
1398
-
1399
- # Handle model-specific parameters
1400
- stream_kwargs = {}
1401
- registry = ModelRegistry()
1402
- capabilities = registry.get_capabilities(model)
1403
-
1404
- # Validate and include supported parameters
1405
- for param_name, value in kwargs.items():
1406
- if param_name in capabilities.supported_parameters:
1407
- # Validate the parameter value
1408
- capabilities.validate_parameter(param_name, value)
1409
- stream_kwargs[param_name] = value
1410
- else:
1411
- logger.warning(
1412
- f"Parameter {param_name} is not supported by model {model} and will be ignored"
1413
- )
1414
-
1415
- # Log the API request details
1416
- logger.debug("Making OpenAI API request with:")
1417
- logger.debug("Model: %s", model)
1418
- logger.debug("System prompt: %s", system_prompt)
1419
- logger.debug("User prompt: %s", user_prompt)
1420
- logger.debug("Parameters: %s", json.dumps(stream_kwargs, indent=2))
1421
- logger.debug("Schema: %s", output_schema.model_json_schema())
1422
-
1423
- # Use the async generator from openai_structured directly
1424
- async for chunk in async_openai_structured_stream(
1425
- client=client,
1426
- model=model,
1427
- system_prompt=system_prompt,
1428
- user_prompt=user_prompt,
1429
- output_schema=output_schema,
1430
- on_log=on_log, # Pass non-model parameters directly to the function
1431
- **stream_kwargs, # Pass only validated model parameters
1432
- ):
1433
- yield chunk
1434
-
1435
- except APIResponseError as e:
1436
- if "Invalid schema for response_format" in str(
1437
- e
1438
- ) and 'type: "array"' in str(e):
1439
- error_msg = (
1440
- "OpenAI API Schema Error: The schema must have a root type of 'object', not 'array'. "
1441
- "To fix this:\n"
1442
- "1. Wrap your array in an object property, e.g.:\n"
1443
- " {\n"
1444
- ' "type": "object",\n'
1445
- ' "properties": {\n'
1446
- ' "items": {\n'
1447
- ' "type": "array",\n'
1448
- ' "items": { ... your array items schema ... }\n'
1449
- " }\n"
1450
- " }\n"
1451
- " }\n"
1452
- "2. Make sure to update your template to handle the wrapper object."
1453
- )
1454
- logger.error(error_msg)
1455
- raise InvalidResponseFormatError(error_msg)
1456
- logger.error(f"API error: {e}")
1457
- raise
1458
- except (
1459
- StreamInterruptedError,
1460
- StreamBufferError,
1461
- StreamParseError,
1462
- EmptyResponseError,
1463
- InvalidResponseFormatError,
1464
- ) as e:
1465
- logger.error("Stream error: %s", str(e))
1466
- raise
1467
- finally:
1468
- # Always ensure client is properly closed
1469
- await client.close()
1470
-
1471
-
1472
- @click.group()
1473
- @click.version_option(version=__version__)
1474
- def cli() -> None:
1475
- """ostruct CLI - Make structured OpenAI API calls.
1476
-
1477
- ostruct allows you to invoke OpenAI Structured Output to produce structured JSON
1478
- output using templates and JSON schemas. It provides support for file handling, variable
1479
- substitution, and output validation.
1480
-
1481
- For detailed documentation, visit: https://ostruct.readthedocs.io
1482
-
1483
- Examples:
1484
-
1485
- # Basic usage with a template and schema
1486
-
1487
- ostruct run task.j2 schema.json -V name=value
1488
-
1489
- # Process files with recursive directory scanning
1490
-
1491
- ostruct run template.j2 schema.json -f code main.py -d src ./src -R
1492
-
1493
- # Use JSON variables and custom model parameters
1494
-
1495
- ostruct run task.j2 schema.json -J config='{"env":"prod"}' -m o3-mini
1496
- """
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
1505
-
1506
-
1507
- @cli.command()
1508
- @click.argument("task_template", type=click.Path(exists=True))
1509
- @click.argument("schema_file", type=click.Path(exists=True))
1510
- @all_options
1511
- @click.pass_context
1512
- def run(
1513
- ctx: click.Context,
1514
- task_template: str,
1515
- schema_file: str,
1516
- **kwargs: Any,
1517
- ) -> None:
1518
- """Run a structured task with template and schema.
1519
-
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
1525
- """
1526
- try:
1527
- # Convert Click parameters to typed dict
1528
- params: CLIParams = {
1529
- "task_file": task_template,
1530
- "task": None,
1531
- "schema_file": schema_file,
1532
- }
1533
- # Add only valid keys from kwargs
1534
- valid_keys = set(CLIParams.__annotations__.keys())
1535
- for k, v in kwargs.items():
1536
- if k in valid_keys:
1537
- params[k] = v # type: ignore[literal-required]
1538
-
1539
- # Run the async function synchronously
1540
- loop = asyncio.new_event_loop()
1541
- asyncio.set_event_loop(loop)
1542
- try:
1543
- exit_code = loop.run_until_complete(run_cli_async(params))
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)
1567
- finally:
1568
- loop.close()
1569
- except KeyboardInterrupt:
1570
- logger.info("Operation cancelled by user")
1571
- raise
1572
-
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()
1628
-
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)
1646
-
1647
-
1648
- async def validate_model_params(args: CLIParams) -> Dict[str, Any]:
1649
- """Validate model parameters and return a dictionary of valid parameters.
1650
-
1651
- Args:
1652
- args: Command line arguments
1653
-
1654
- Returns:
1655
- Dictionary of validated model parameters
1656
-
1657
- Raises:
1658
- CLIError: If model parameters are invalid
1659
- """
1660
- params = {
1661
- "temperature": args.get("temperature"),
1662
- "max_output_tokens": args.get("max_output_tokens"),
1663
- "top_p": args.get("top_p"),
1664
- "frequency_penalty": args.get("frequency_penalty"),
1665
- "presence_penalty": args.get("presence_penalty"),
1666
- "reasoning_effort": args.get("reasoning_effort"),
1667
- }
1668
- # Remove None values
1669
- params = {k: v for k, v in params.items() if v is not None}
1670
- validate_model_parameters(args["model"], params)
1671
- return params
1672
-
1673
-
1674
- async def validate_inputs(
1675
- args: CLIParams,
1676
- ) -> Tuple[
1677
- SecurityManager, str, Dict[str, Any], Dict[str, Any], jinja2.Environment
1678
- ]:
1679
- """Validate all input parameters and return validated components.
1680
-
1681
- Args:
1682
- args: Command line arguments
1683
-
1684
- Returns:
1685
- Tuple containing:
1686
- - SecurityManager instance
1687
- - Task template string
1688
- - Schema dictionary
1689
- - Template context dictionary
1690
- - Jinja2 environment
1691
-
1692
- Raises:
1693
- CLIError: For various validation errors
1694
- SchemaValidationError: When schema is invalid
1695
- """
1696
- logger.debug("=== Input Validation Phase ===")
1697
- security_manager = validate_security_manager(
1698
- base_dir=args.get("base_dir"),
1699
- allowed_dirs=args.get("allowed_dirs"),
1700
- allowed_dir_file=args.get("allowed_dir_file"),
1701
- )
1702
-
1703
- task_template = validate_task_template(
1704
- args.get("task"), args.get("task_file")
1705
- )
1706
-
1707
- # Load and validate schema
1708
- logger.debug("Validating schema from %s", args["schema_file"])
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
-
1722
- template_context = await create_template_context_from_args(
1723
- args, security_manager
1724
- )
1725
- env = create_jinja_env()
1726
-
1727
- return security_manager, task_template, schema, template_context, env
1728
-
1729
-
1730
- async def process_templates(
1731
- args: CLIParams,
1732
- task_template: str,
1733
- template_context: Dict[str, Any],
1734
- env: jinja2.Environment,
1735
- ) -> Tuple[str, str]:
1736
- """Process system prompt and user prompt templates.
1737
-
1738
- Args:
1739
- args: Command line arguments
1740
- task_template: Validated task template
1741
- template_context: Template context dictionary
1742
- env: Jinja2 environment
1743
-
1744
- Returns:
1745
- Tuple of (system_prompt, user_prompt)
1746
-
1747
- Raises:
1748
- CLIError: For template processing errors
1749
- """
1750
- logger.debug("=== Template Processing Phase ===")
1751
- system_prompt = process_system_prompt(
1752
- task_template,
1753
- args.get("system_prompt"),
1754
- args.get("system_prompt_file"),
1755
- template_context,
1756
- env,
1757
- args.get("ignore_task_sysprompt", False),
1758
- )
1759
- user_prompt = render_template(task_template, template_context, env)
1760
- return system_prompt, user_prompt
1761
-
1762
-
1763
- async def validate_model_and_schema(
1764
- args: CLIParams,
1765
- schema: Dict[str, Any],
1766
- system_prompt: str,
1767
- user_prompt: str,
1768
- ) -> Tuple[Type[BaseModel], List[Dict[str, str]], int, ModelRegistry]:
1769
- """Validate model compatibility and schema, and check token limits.
1770
-
1771
- Args:
1772
- args: Command line arguments
1773
- schema: Schema dictionary
1774
- system_prompt: Processed system prompt
1775
- user_prompt: Processed user prompt
1776
-
1777
- Returns:
1778
- Tuple of (output_model, messages, total_tokens, registry)
1779
-
1780
- Raises:
1781
- CLIError: For validation errors
1782
- ModelCreationError: When model creation fails
1783
- SchemaValidationError: When schema is invalid
1784
- """
1785
- logger.debug("=== Model & Schema Validation Phase ===")
1786
- try:
1787
- output_model = create_dynamic_model(
1788
- schema,
1789
- show_schema=args.get("show_model_schema", False),
1790
- debug_validation=args.get("debug_validation", False),
1791
- )
1792
- logger.debug("Successfully created output model")
1793
- except (
1794
- SchemaFileError,
1795
- InvalidJSONError,
1796
- SchemaValidationError,
1797
- ModelCreationError,
1798
- ) as e:
1799
- logger.error("Schema error: %s", str(e))
1800
- # Pass through the error without additional wrapping
1801
- raise
1802
-
1803
- if not supports_structured_output(args["model"]):
1804
- msg = f"Model {args['model']} does not support structured output"
1805
- logger.error(msg)
1806
- raise ModelNotSupportedError(msg)
1807
-
1808
- messages = [
1809
- {"role": "system", "content": system_prompt},
1810
- {"role": "user", "content": user_prompt},
1811
- ]
1812
-
1813
- total_tokens = estimate_tokens_with_encoding(messages, args["model"])
1814
- registry = ModelRegistry()
1815
- capabilities = registry.get_capabilities(args["model"])
1816
- context_limit = capabilities.context_window
1817
-
1818
- if total_tokens > context_limit:
1819
- msg = f"Total tokens ({total_tokens}) exceeds model context limit ({context_limit})"
1820
- logger.error(msg)
1821
- raise CLIError(
1822
- msg,
1823
- context={
1824
- "total_tokens": total_tokens,
1825
- "context_limit": context_limit,
1826
- },
1827
- )
1828
-
1829
- return output_model, messages, total_tokens, registry
1830
-
1831
-
1832
- async def execute_model(
1833
- args: CLIParams,
1834
- params: Dict[str, Any],
1835
- output_model: Type[BaseModel],
1836
- system_prompt: str,
1837
- user_prompt: str,
1838
- ) -> ExitCode:
1839
- """Execute the model and handle the response.
1840
-
1841
- Args:
1842
- args: Command line arguments
1843
- params: Validated model parameters
1844
- output_model: Generated Pydantic model
1845
- system_prompt: Processed system prompt
1846
- user_prompt: Processed user prompt
1847
-
1848
- Returns:
1849
- Exit code indicating success or failure
1850
-
1851
- Raises:
1852
- CLIError: For execution errors
1853
- """
1854
- logger.debug("=== Execution Phase ===")
1855
- api_key = args.get("api_key") or os.getenv("OPENAI_API_KEY")
1856
- if not api_key:
1857
- msg = "No API key provided. Set OPENAI_API_KEY environment variable or use --api-key"
1858
- logger.error(msg)
1859
- raise CLIError(msg, exit_code=ExitCode.API_ERROR)
1860
-
1861
- client = AsyncOpenAI(api_key=api_key, timeout=args.get("timeout", 60.0))
1862
-
1863
- # Create detailed log callback
1864
- def log_callback(level: int, message: str, extra: dict[str, Any]) -> None:
1865
- if args.get("debug_openai_stream", False):
1866
- if extra:
1867
- extra_str = LogSerializer.serialize_log_extra(extra)
1868
- if extra_str:
1869
- logger.debug("%s\nExtra:\n%s", message, extra_str)
1870
- else:
1871
- logger.debug("%s\nExtra: Failed to serialize", message)
1872
- else:
1873
- logger.debug(message)
1874
-
1875
- try:
1876
- # Create output buffer
1877
- output_buffer = []
1878
-
1879
- # Stream the response
1880
- async for response in stream_structured_output(
1881
- client=client,
1882
- model=args["model"],
1883
- system_prompt=system_prompt,
1884
- user_prompt=user_prompt,
1885
- output_schema=output_model,
1886
- output_file=args.get("output_file"),
1887
- on_log=log_callback,
1888
- ):
1889
- output_buffer.append(response)
1890
-
1891
- # Handle final output
1892
- output_file = args.get("output_file")
1893
- if output_file:
1894
- with open(output_file, "w") as f:
1895
- if len(output_buffer) == 1:
1896
- f.write(output_buffer[0].model_dump_json(indent=2))
1897
- else:
1898
- # Build complete JSON array as a single string
1899
- json_output = "[\n"
1900
- for i, response in enumerate(output_buffer):
1901
- if i > 0:
1902
- json_output += ",\n"
1903
- json_output += " " + response.model_dump_json(
1904
- indent=2
1905
- ).replace("\n", "\n ")
1906
- json_output += "\n]"
1907
- f.write(json_output)
1908
- else:
1909
- # Write to stdout when no output file is specified
1910
- if len(output_buffer) == 1:
1911
- print(output_buffer[0].model_dump_json(indent=2))
1912
- else:
1913
- # Build complete JSON array as a single string
1914
- json_output = "[\n"
1915
- for i, response in enumerate(output_buffer):
1916
- if i > 0:
1917
- json_output += ",\n"
1918
- json_output += " " + response.model_dump_json(
1919
- indent=2
1920
- ).replace("\n", "\n ")
1921
- json_output += "\n]"
1922
- print(json_output)
1923
-
1924
- return ExitCode.SUCCESS
1925
-
1926
- except (
1927
- StreamInterruptedError,
1928
- StreamBufferError,
1929
- StreamParseError,
1930
- APIResponseError,
1931
- EmptyResponseError,
1932
- InvalidResponseFormatError,
1933
- ) as e:
1934
- logger.error("Stream error: %s", str(e))
1935
- raise CLIError(str(e), exit_code=ExitCode.API_ERROR)
1936
- except Exception as e:
1937
- logger.exception("Unexpected error during streaming")
1938
- raise CLIError(str(e), exit_code=ExitCode.UNKNOWN_ERROR)
1939
- finally:
1940
- await client.close()
1941
-
1942
-
1943
- async def run_cli_async(args: CLIParams) -> ExitCode:
1944
- """Async wrapper for CLI operations.
1945
-
1946
- Args:
1947
- args: CLI parameters.
1948
-
1949
- Returns:
1950
- Exit code.
1951
-
1952
- Raises:
1953
- CLIError: For errors during CLI operations.
1954
- """
1955
- try:
1956
- # 0. Model Parameter Validation
1957
- logger.debug("=== Model Parameter Validation ===")
1958
- params = await validate_model_params(args)
1959
-
1960
- # 1. Input Validation Phase (includes schema validation)
1961
- security_manager, task_template, schema, template_context, env = (
1962
- await validate_inputs(args)
1963
- )
1964
-
1965
- # 2. Template Processing Phase
1966
- system_prompt, user_prompt = await process_templates(
1967
- args, task_template, template_context, env
1968
- )
1969
-
1970
- # 3. Model & Schema Validation Phase
1971
- output_model, messages, total_tokens, registry = (
1972
- await validate_model_and_schema(
1973
- args, schema, system_prompt, user_prompt
1974
- )
1975
- )
1976
-
1977
- # 4. Dry Run Output Phase - Moved after all validations
1978
- if args.get("dry_run", False):
1979
- logger.info("\n=== Dry Run Summary ===")
1980
- # Only log success if we got this far (no validation errors)
1981
- logger.info("✓ Template rendered successfully")
1982
- logger.info("✓ Schema validation passed")
1983
-
1984
- if args.get("verbose", False):
1985
- logger.info("\nSystem Prompt:")
1986
- logger.info("-" * 40)
1987
- logger.info(system_prompt)
1988
- logger.info("\nRendered Template:")
1989
- logger.info("-" * 40)
1990
- logger.info(user_prompt)
1991
-
1992
- # Return success only if we got here (no validation errors)
1993
- return ExitCode.SUCCESS
1994
-
1995
- # 5. Execution Phase
1996
- return await execute_model(
1997
- args, params, output_model, system_prompt, user_prompt
1998
- )
1999
-
2000
- except KeyboardInterrupt:
2001
- logger.info("Operation cancelled by user")
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
2007
- except Exception as e:
2008
- if isinstance(e, CLIError):
2009
- raise # Let our custom errors propagate
2010
- logger.exception("Unexpected error")
2011
- raise CLIError(str(e), context={"error_type": type(e).__name__})
95
+ # Create the main cli object using the factory
96
+ cli = create_cli_group()
2012
97
 
2013
98
 
2014
99
  def create_cli() -> click.Command:
@@ -2017,7 +102,7 @@ def create_cli() -> click.Command:
2017
102
  Returns:
2018
103
  click.Command: The CLI command object
2019
104
  """
2020
- return cli # The decorator already returns a Command
105
+ return cli
2021
106
 
2022
107
 
2023
108
  def main() -> None:
@@ -2042,15 +127,13 @@ def main() -> None:
2042
127
  sys.exit(ExitCode.INTERNAL_ERROR)
2043
128
 
2044
129
 
130
+ # Re-export ExitCode for compatibility
131
+
2045
132
  # Export public API
2046
133
  __all__ = [
2047
134
  "ExitCode",
2048
- "estimate_tokens_with_encoding",
2049
- "parse_json_var",
2050
- "create_dynamic_model",
2051
- "validate_path_mapping",
2052
- "create_cli",
2053
135
  "main",
136
+ "create_cli",
2054
137
  ]
2055
138
 
2056
139