ostruct-cli 0.4.0__py3-none-any.whl → 0.6.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.
@@ -9,160 +9,349 @@ from typing import Any, Callable, TypeVar, Union, cast
9
9
 
10
10
  import click
11
11
  from click import Command
12
+ from typing_extensions import ParamSpec
12
13
 
13
14
  from ostruct import __version__
14
15
  from ostruct.cli.errors import ( # noqa: F401 - Used in error handling
15
16
  SystemPromptError,
16
17
  TaskTemplateVariableError,
17
18
  )
19
+ from ostruct.cli.validators import (
20
+ validate_json_variable,
21
+ validate_name_path_pair,
22
+ validate_variable,
23
+ )
18
24
 
25
+ P = ParamSpec("P")
26
+ R = TypeVar("R")
19
27
  F = TypeVar("F", bound=Callable[..., Any])
28
+ CommandDecorator = Callable[[F], Command]
20
29
  DecoratedCommand = Union[Command, Callable[..., Any]]
21
30
 
22
31
 
23
- def validate_task_params(
24
- ctx: click.Context, param: click.Parameter, value: Any
25
- ) -> Any:
26
- """Validate task-related parameters."""
27
- if not hasattr(ctx, "params"):
28
- return value
29
-
30
- # Check for conflicting task parameters
31
- if (
32
- param.name == "task_file"
33
- and value is not None
34
- and ctx.params.get("task") is not None
35
- ):
36
- raise click.UsageError("Cannot specify both --task and --task-file")
37
- elif (
38
- param.name == "task"
39
- and value is not None
40
- and ctx.params.get("task_file") is not None
41
- ):
42
- raise click.UsageError("Cannot specify both --task and --task-file")
43
-
44
- return value
45
-
46
-
47
- def validate_system_prompt_params(
48
- ctx: click.Context, param: click.Parameter, value: Any
49
- ) -> Any:
50
- """Validate system prompt parameters."""
51
- if not hasattr(ctx, "params"):
52
- return value
53
-
54
- # Check for conflicting system prompt parameters
55
- if (
56
- param.name == "system_prompt_file"
57
- and value is not None
58
- and ctx.params.get("system_prompt") is not None
59
- ):
60
- raise click.UsageError(
61
- "Cannot specify both --system-prompt and --system-prompt-file"
62
- )
63
- elif (
64
- param.name == "system_prompt"
65
- and value is not None
66
- and ctx.params.get("system_prompt_file") is not None
67
- ):
68
- raise click.UsageError(
69
- "Cannot specify both --system-prompt and --system-prompt-file"
70
- )
71
-
72
- return value
73
-
74
-
75
- def debug_options(f: Callable) -> Callable:
32
+ def debug_options(f: Union[Command, Callable[..., Any]]) -> Command:
76
33
  """Add debug-related CLI options."""
77
- f = click.option(
34
+ # Initial conversion to Command if needed
35
+ cmd: Any = f if isinstance(f, Command) else f
36
+
37
+ # Add options without redundant casts
38
+ cmd = click.option(
78
39
  "--show-model-schema",
79
40
  is_flag=True,
80
41
  help="Show generated Pydantic model schema",
81
- )(f)
82
- f = click.option(
42
+ )(cmd)
43
+
44
+ cmd = click.option(
83
45
  "--debug-validation",
84
46
  is_flag=True,
85
47
  help="Show detailed validation errors",
86
- )(f)
87
- return f
48
+ )(cmd)
88
49
 
50
+ # Final cast to Command for return type
51
+ return cast(Command, cmd)
89
52
 
90
- def file_options(f: Callable) -> Callable:
53
+
54
+ def file_options(f: Union[Command, Callable[..., Any]]) -> Command:
91
55
  """Add file-related CLI options."""
92
- f = click.option(
93
- "--file", "-f", multiple=True, help="File mapping (name=path)"
94
- )(f)
95
- f = click.option(
96
- "--files",
56
+ cmd: Any = f if isinstance(f, Command) else f
57
+
58
+ cmd = click.option(
59
+ "-f",
60
+ "--file",
61
+ "files",
97
62
  multiple=True,
98
- help="Multiple file mappings from a directory",
99
- )(f)
100
- f = click.option(
101
- "--dir", "-d", multiple=True, help="Directory mapping (name=path)"
102
- )(f)
103
- f = click.option(
104
- "--allowed-dir",
63
+ nargs=2,
64
+ metavar="<NAME> <PATH>",
65
+ callback=validate_name_path_pair,
66
+ help="""Associate a file with a variable name. The file will be available in
67
+ your template as the specified variable. You can specify this option multiple times.
68
+ Example: -f code main.py -f test test_main.py""",
69
+ shell_complete=click.Path(exists=True, file_okay=True, dir_okay=False),
70
+ )(cmd)
71
+
72
+ cmd = click.option(
73
+ "-d",
74
+ "--dir",
75
+ "dir",
76
+ multiple=True,
77
+ nargs=2,
78
+ metavar="<NAME> <DIR>",
79
+ callback=validate_name_path_pair,
80
+ help="""Associate a directory with a variable name. All files in the directory
81
+ will be available in your template. Use -R for recursive scanning.
82
+ Example: -d src ./src""",
83
+ shell_complete=click.Path(exists=True, file_okay=False, dir_okay=True),
84
+ )(cmd)
85
+
86
+ cmd = click.option(
87
+ "-p",
88
+ "--pattern",
89
+ "patterns",
90
+ multiple=True,
91
+ nargs=2,
92
+ metavar="<NAME> <PATTERN>",
93
+ help="""Associate a glob pattern with a variable name. Matching files will be
94
+ available in your template. Use -R for recursive matching.
95
+ Example: -p logs '*.log'""",
96
+ )(cmd)
97
+
98
+ cmd = click.option(
99
+ "-R",
100
+ "--recursive",
101
+ is_flag=True,
102
+ help="Process directories and patterns recursively",
103
+ )(cmd)
104
+
105
+ cmd = click.option(
106
+ "--base-dir",
107
+ type=click.Path(exists=True, file_okay=False, dir_okay=True),
108
+ help="""Base directory for resolving relative paths. All file operations will be
109
+ relative to this directory. Defaults to current directory.""",
110
+ shell_complete=click.Path(exists=True, file_okay=False, dir_okay=True),
111
+ )(cmd)
112
+
113
+ cmd = click.option(
114
+ "-A",
115
+ "--allow",
116
+ "allowed_dirs",
105
117
  multiple=True,
106
- help="Additional allowed directory paths",
107
- )(f)
108
- f = click.option(
109
- "--base-dir", type=str, help="Base directory for relative paths"
110
- )(f)
111
- f = click.option(
118
+ type=click.Path(exists=True, file_okay=False, dir_okay=True),
119
+ help="""Add an allowed directory for security. Files must be within allowed
120
+ directories. Can be specified multiple times.""",
121
+ shell_complete=click.Path(exists=True, file_okay=False, dir_okay=True),
122
+ )(cmd)
123
+
124
+ cmd = click.option(
112
125
  "--allowed-dir-file",
113
- type=str,
114
- help="File containing allowed directory paths",
115
- )(f)
116
- f = click.option(
117
- "--dir-recursive", is_flag=True, help="Recursively process directories"
118
- )(f)
119
- f = click.option(
120
- "--dir-ext", type=str, help="Filter directory files by extension"
121
- )(f)
122
- return f
123
-
124
-
125
- def variable_options(f: Callable) -> Callable:
126
+ type=click.Path(exists=True, file_okay=True, dir_okay=False),
127
+ help="""File containing allowed directory paths, one per line. Lines starting
128
+ with # are treated as comments.""",
129
+ shell_complete=click.Path(exists=True, file_okay=True, dir_okay=False),
130
+ )(cmd)
131
+
132
+ return cast(Command, cmd)
133
+
134
+
135
+ def variable_options(f: Union[Command, Callable[..., Any]]) -> Command:
126
136
  """Add variable-related CLI options."""
127
- f = click.option(
128
- "--var", "-v", multiple=True, help="Variable mapping (name=value)"
129
- )(f)
130
- f = click.option(
137
+ cmd: Any = f if isinstance(f, Command) else f
138
+
139
+ cmd = click.option(
140
+ "-V",
141
+ "--var",
142
+ "var",
143
+ multiple=True,
144
+ metavar="name=value",
145
+ callback=validate_variable,
146
+ help="""Define a simple string variable. Format: name=value
147
+ Example: -V debug=true -V env=prod""",
148
+ )(cmd)
149
+
150
+ cmd = click.option(
151
+ "-J",
131
152
  "--json-var",
132
- "-j",
153
+ "json_var",
133
154
  multiple=True,
134
- help="JSON variable mapping (name=json_value)",
135
- )(f)
136
- return f
155
+ metavar='name=\'{"json":"value"}\'',
156
+ callback=validate_json_variable,
157
+ help="""Define a JSON variable. Format: name='{"key":"value"}'
158
+ Example: -J config='{"env":"prod","debug":true}'""",
159
+ )(cmd)
137
160
 
161
+ return cast(Command, cmd)
138
162
 
139
- def model_options(f: Callable) -> Callable:
163
+
164
+ def model_options(f: Union[Command, Callable[..., Any]]) -> Command:
140
165
  """Add model-related CLI options."""
141
- f = click.option(
142
- "--model", type=str, default="gpt-4o", help="OpenAI model to use"
143
- )(f)
144
- f = click.option(
145
- "--temperature", type=float, default=0.0, help="Sampling temperature"
146
- )(f)
147
- f = click.option(
148
- "--max-tokens", type=int, help="Maximum tokens in response"
149
- )(f)
150
- f = click.option(
151
- "--top-p", type=float, default=1.0, help="Nucleus sampling threshold"
152
- )(f)
153
- f = click.option(
166
+ cmd: Any = f if isinstance(f, Command) else f
167
+
168
+ cmd = click.option(
169
+ "-m",
170
+ "--model",
171
+ default="gpt-4o",
172
+ show_default=True,
173
+ help="""OpenAI model to use. Must support structured output.
174
+ Supported models:
175
+ - gpt-4o (128k context window)
176
+ - o1 (200k context window)
177
+ - o3-mini (200k context window)""",
178
+ )(cmd)
179
+
180
+ cmd = click.option(
181
+ "--temperature",
182
+ type=click.FloatRange(0.0, 2.0),
183
+ help="""Sampling temperature. Controls randomness in the output.
184
+ Range: 0.0 to 2.0. Lower values are more focused.""",
185
+ )(cmd)
186
+
187
+ cmd = click.option(
188
+ "--max-output-tokens",
189
+ type=click.IntRange(1, None),
190
+ help="""Maximum number of tokens in the output.
191
+ Higher values allow longer responses but cost more.""",
192
+ )(cmd)
193
+
194
+ cmd = click.option(
195
+ "--top-p",
196
+ type=click.FloatRange(0.0, 1.0),
197
+ help="""Top-p (nucleus) sampling parameter. Controls diversity.
198
+ Range: 0.0 to 1.0. Lower values are more focused.""",
199
+ )(cmd)
200
+
201
+ cmd = click.option(
154
202
  "--frequency-penalty",
155
- type=float,
156
- default=0.0,
157
- help="Frequency penalty",
158
- )(f)
159
- f = click.option(
160
- "--presence-penalty", type=float, default=0.0, help="Presence penalty"
161
- )(f)
162
- return f
203
+ type=click.FloatRange(-2.0, 2.0),
204
+ help="""Frequency penalty for text generation.
205
+ Range: -2.0 to 2.0. Positive values reduce repetition.""",
206
+ )(cmd)
207
+
208
+ cmd = click.option(
209
+ "--presence-penalty",
210
+ type=click.FloatRange(-2.0, 2.0),
211
+ help="""Presence penalty for text generation.
212
+ Range: -2.0 to 2.0. Positive values encourage new topics.""",
213
+ )(cmd)
214
+
215
+ cmd = click.option(
216
+ "--reasoning-effort",
217
+ type=click.Choice(["low", "medium", "high"]),
218
+ help="""Control reasoning effort (if supported by model).
219
+ Higher values may improve output quality but take longer.""",
220
+ )(cmd)
221
+
222
+ return cast(Command, cmd)
223
+
224
+
225
+ def system_prompt_options(f: Union[Command, Callable[..., Any]]) -> Command:
226
+ """Add system prompt related CLI options."""
227
+ cmd: Any = f if isinstance(f, Command) else f
228
+
229
+ cmd = click.option(
230
+ "--sys-prompt",
231
+ "system_prompt",
232
+ help="""Provide system prompt directly. This sets the initial context
233
+ for the model. Example: --sys-prompt "You are a code reviewer." """,
234
+ )(cmd)
235
+
236
+ cmd = click.option(
237
+ "--sys-file",
238
+ "system_prompt_file",
239
+ type=click.Path(exists=True, dir_okay=False),
240
+ help="""Load system prompt from file. The file should contain the prompt text.
241
+ Example: --sys-file prompts/code_review.txt""",
242
+ shell_complete=click.Path(exists=True, file_okay=True, dir_okay=False),
243
+ )(cmd)
244
+
245
+ cmd = click.option(
246
+ "--ignore-task-sysprompt",
247
+ is_flag=True,
248
+ help="""Ignore system prompt in task template. By default, system prompts
249
+ in template frontmatter are used.""",
250
+ )(cmd)
251
+
252
+ return cast(Command, cmd)
253
+
254
+
255
+ def output_options(f: Union[Command, Callable[..., Any]]) -> Command:
256
+ """Add output-related CLI options."""
257
+ cmd: Any = f if isinstance(f, Command) else f
258
+
259
+ cmd = click.option(
260
+ "--output-file",
261
+ type=click.Path(dir_okay=False),
262
+ help="""Write output to file instead of stdout.
263
+ Example: --output-file result.json""",
264
+ shell_complete=click.Path(file_okay=True, dir_okay=False),
265
+ )(cmd)
163
266
 
267
+ cmd = click.option(
268
+ "--dry-run",
269
+ is_flag=True,
270
+ help="""Validate and render but skip API call. Useful for testing
271
+ template rendering and validation.""",
272
+ )(cmd)
273
+
274
+ return cast(Command, cmd)
275
+
276
+
277
+ def api_options(f: Union[Command, Callable[..., Any]]) -> Command:
278
+ """Add API-related CLI options."""
279
+ cmd: Any = f if isinstance(f, Command) else f
280
+
281
+ cmd = click.option(
282
+ "--api-key",
283
+ help="""OpenAI API key. If not provided, uses OPENAI_API_KEY
284
+ environment variable.""",
285
+ )(cmd)
286
+
287
+ cmd = click.option(
288
+ "--timeout",
289
+ type=click.FloatRange(1.0, None),
290
+ default=60.0,
291
+ show_default=True,
292
+ help="API timeout in seconds.",
293
+ )(cmd)
294
+
295
+ return cast(Command, cmd)
296
+
297
+
298
+ def debug_progress_options(f: Union[Command, Callable[..., Any]]) -> Command:
299
+ """Add debugging and progress CLI options."""
300
+ cmd: Any = f if isinstance(f, Command) else f
301
+
302
+ cmd = click.option(
303
+ "--no-progress", is_flag=True, help="Disable progress indicators"
304
+ )(cmd)
164
305
 
165
- def create_click_command() -> Callable[[F], Command]:
306
+ cmd = click.option(
307
+ "--progress-level",
308
+ type=click.Choice(["none", "basic", "detailed"]),
309
+ default="basic",
310
+ show_default=True,
311
+ help="""Control progress verbosity. 'none' shows no progress,
312
+ 'basic' shows key steps, 'detailed' shows all steps.""",
313
+ )(cmd)
314
+
315
+ cmd = click.option(
316
+ "--verbose", is_flag=True, help="Enable verbose logging"
317
+ )(cmd)
318
+
319
+ cmd = click.option(
320
+ "--debug-openai-stream",
321
+ is_flag=True,
322
+ help="Debug OpenAI streaming process",
323
+ )(cmd)
324
+
325
+ return cast(Command, cmd)
326
+
327
+
328
+ def all_options(f: Union[Command, Callable[..., Any]]) -> Command:
329
+ """Add all CLI options.
330
+
331
+ Args:
332
+ f: Function to decorate
333
+
334
+ Returns:
335
+ Decorated function
336
+ """
337
+ decorators = [
338
+ model_options, # Model selection and parameters first
339
+ system_prompt_options, # System prompt configuration
340
+ file_options, # File and directory handling
341
+ variable_options, # Variable definitions
342
+ output_options, # Output control
343
+ api_options, # API configuration
344
+ debug_options, # Debug settings
345
+ debug_progress_options, # Progress and logging
346
+ ]
347
+
348
+ for decorator in decorators:
349
+ f = decorator(f)
350
+
351
+ return cast(Command, f)
352
+
353
+
354
+ def create_click_command() -> CommandDecorator:
166
355
  """Create the Click command with all options.
167
356
 
168
357
  Returns:
@@ -170,88 +359,17 @@ def create_click_command() -> Callable[[F], Command]:
170
359
  """
171
360
 
172
361
  def decorator(f: F) -> Command:
173
- # Start with the base command
174
- cmd: DecoratedCommand = click.command(
175
- help="Make structured OpenAI API calls."
176
- )(f)
177
-
178
- # Add all options
179
- cmd = click.option(
180
- "--task",
181
- help="Task template string",
182
- type=str,
183
- callback=validate_task_params,
184
- )(cmd)
185
- cmd = click.option(
186
- "--task-file",
187
- help="Task template file path",
188
- type=str,
189
- callback=validate_task_params,
190
- )(cmd)
191
- cmd = click.option(
192
- "--system-prompt",
193
- help="System prompt string",
194
- type=str,
195
- callback=validate_system_prompt_params,
196
- )(cmd)
197
- cmd = click.option(
198
- "--system-prompt-file",
199
- help="System prompt file path",
200
- type=str,
201
- callback=validate_system_prompt_params,
202
- )(cmd)
203
- cmd = click.option(
204
- "--schema-file",
205
- required=True,
206
- help="JSON schema file for response validation",
207
- type=str,
208
- )(cmd)
209
- cmd = click.option(
210
- "--ignore-task-sysprompt",
211
- is_flag=True,
212
- help="Ignore system prompt from task template YAML frontmatter",
213
- )(cmd)
214
- cmd = click.option(
215
- "--timeout",
216
- type=float,
217
- default=60.0,
218
- help="API timeout in seconds",
219
- )(cmd)
220
- cmd = click.option(
221
- "--output-file", help="Write JSON output to file", type=str
222
- )(cmd)
223
- cmd = click.option(
224
- "--dry-run",
225
- is_flag=True,
226
- help="Simulate API call without making request",
227
- )(cmd)
228
- cmd = click.option(
229
- "--no-progress", is_flag=True, help="Disable progress indicators"
230
- )(cmd)
231
- cmd = click.option(
232
- "--progress-level",
233
- type=click.Choice(["none", "basic", "detailed"]),
234
- default="basic",
235
- help="Progress reporting level",
236
- )(cmd)
237
- cmd = click.option(
238
- "--api-key", help="OpenAI API key (overrides env var)", type=str
239
- )(cmd)
240
- cmd = click.option(
241
- "--verbose",
242
- is_flag=True,
243
- help="Enable verbose output and detailed logging",
244
- )(cmd)
245
- cmd = click.option(
246
- "--debug-openai-stream",
247
- is_flag=True,
248
- help="Enable low-level debug output for OpenAI streaming",
362
+ # Initial command creation
363
+ cmd: Any = click.command()(f)
364
+
365
+ # Add version option
366
+ cmd = click.version_option(
367
+ __version__,
368
+ "--version",
369
+ "-V",
370
+ message="%(prog)s CLI version %(version)s",
249
371
  )(cmd)
250
- cmd = debug_options(cmd)
251
- cmd = file_options(cmd)
252
- cmd = variable_options(cmd)
253
- cmd = model_options(cmd)
254
- cmd = click.version_option(version=__version__)(cmd)
372
+
255
373
  return cast(Command, cmd)
256
374
 
257
375
  return decorator