ostruct-cli 0.4.0__py3-none-any.whl → 0.5.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/base_errors.py +183 -0
- ostruct/cli/cli.py +822 -543
- ostruct/cli/click_options.py +320 -202
- ostruct/cli/errors.py +222 -128
- ostruct/cli/exit_codes.py +18 -0
- ostruct/cli/file_info.py +30 -14
- ostruct/cli/file_list.py +4 -10
- ostruct/cli/file_utils.py +43 -35
- ostruct/cli/path_utils.py +32 -4
- ostruct/cli/security/allowed_checker.py +8 -0
- ostruct/cli/security/base.py +46 -0
- ostruct/cli/security/errors.py +83 -103
- ostruct/cli/security/security_manager.py +22 -9
- ostruct/cli/serialization.py +25 -0
- ostruct/cli/template_filters.py +5 -3
- ostruct/cli/template_rendering.py +46 -22
- ostruct/cli/template_utils.py +12 -4
- ostruct/cli/template_validation.py +26 -8
- ostruct/cli/token_utils.py +43 -0
- ostruct/cli/validators.py +109 -0
- {ostruct_cli-0.4.0.dist-info → ostruct_cli-0.5.0.dist-info}/METADATA +60 -21
- ostruct_cli-0.5.0.dist-info/RECORD +42 -0
- {ostruct_cli-0.4.0.dist-info → ostruct_cli-0.5.0.dist-info}/WHEEL +1 -1
- ostruct_cli-0.4.0.dist-info/RECORD +0 -36
- {ostruct_cli-0.4.0.dist-info → ostruct_cli-0.5.0.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.4.0.dist-info → ostruct_cli-0.5.0.dist-info}/entry_points.txt +0 -0
@@ -15,6 +15,8 @@ from contextlib import contextmanager
|
|
15
15
|
from pathlib import Path
|
16
16
|
from typing import Generator, List, Optional, Union
|
17
17
|
|
18
|
+
from ostruct.cli.errors import OstructFileNotFoundError
|
19
|
+
|
18
20
|
from .allowed_checker import is_path_in_allowed_dirs
|
19
21
|
from .case_manager import CaseManager
|
20
22
|
from .errors import (
|
@@ -200,24 +202,32 @@ class SecurityManager:
|
|
200
202
|
PathSecurityError: If the path fails security validation
|
201
203
|
FileNotFoundError: If the file doesn't exist (only checked after security validation)
|
202
204
|
"""
|
205
|
+
logger.debug("Validating path: %s", path)
|
206
|
+
|
203
207
|
# First normalize the path
|
204
208
|
norm_path = normalize_path(path)
|
209
|
+
logger.debug("Normalized path: %s", norm_path)
|
205
210
|
|
206
211
|
# Handle symlinks first - delegate to symlink_resolver
|
207
212
|
if norm_path.is_symlink():
|
213
|
+
logger.debug("Path is a symlink, resolving: %s", norm_path)
|
208
214
|
try:
|
209
|
-
|
215
|
+
resolved = _resolve_symlink(
|
210
216
|
norm_path,
|
211
217
|
self._max_symlink_depth,
|
212
218
|
self._allowed_dirs,
|
213
219
|
)
|
220
|
+
logger.debug("Resolved symlink to: %s", resolved)
|
221
|
+
return resolved
|
214
222
|
except RuntimeError as e:
|
215
223
|
if "Symlink loop" in str(e):
|
224
|
+
logger.error("Symlink loop detected: %s", path)
|
216
225
|
raise PathSecurityError(
|
217
226
|
"Symlink security violation: loop detected",
|
218
227
|
path=str(path),
|
219
228
|
context={"reason": SecurityErrorReasons.SYMLINK_LOOP},
|
220
229
|
) from e
|
230
|
+
logger.error("Failed to resolve symlink: %s - %s", path, e)
|
221
231
|
raise PathSecurityError(
|
222
232
|
f"Symlink security violation: failed to resolve symlink - {e}",
|
223
233
|
path=str(path),
|
@@ -225,10 +235,13 @@ class SecurityManager:
|
|
225
235
|
) from e
|
226
236
|
|
227
237
|
# For non-symlinks, just check if the normalized path is allowed
|
238
|
+
logger.debug("Checking if path is allowed: %s", norm_path)
|
228
239
|
if not self.is_path_allowed(norm_path):
|
229
240
|
logger.error(
|
230
|
-
"Security violation: Path %s is outside allowed directories",
|
241
|
+
"Security violation: Path %s is outside allowed directories (base_dir=%s, allowed_dirs=%s)",
|
231
242
|
path,
|
243
|
+
self._base_dir,
|
244
|
+
self._allowed_dirs,
|
232
245
|
)
|
233
246
|
raise PathSecurityError(
|
234
247
|
(
|
@@ -244,12 +257,12 @@ class SecurityManager:
|
|
244
257
|
)
|
245
258
|
|
246
259
|
# Only check existence after security validation passes
|
260
|
+
logger.debug("Checking if path exists: %s", norm_path)
|
247
261
|
if not norm_path.exists():
|
248
262
|
logger.debug("Path allowed but not found: %s", norm_path)
|
249
|
-
raise
|
250
|
-
f"File not found: {os.path.basename(str(path))}"
|
251
|
-
)
|
263
|
+
raise OstructFileNotFoundError(str(path))
|
252
264
|
|
265
|
+
logger.debug("Path validation successful: %s", norm_path)
|
253
266
|
return norm_path
|
254
267
|
|
255
268
|
def resolve_path(self, path: Union[str, Path]) -> Path:
|
@@ -275,7 +288,7 @@ class SecurityManager:
|
|
275
288
|
if self._allow_temp_paths and self.is_temp_path(norm_path):
|
276
289
|
logger.debug("Allowing temp path: %s", norm_path)
|
277
290
|
if not norm_path.exists():
|
278
|
-
raise
|
291
|
+
raise OstructFileNotFoundError(f"File not found: {path}")
|
279
292
|
return norm_path
|
280
293
|
|
281
294
|
# Handle symlinks with security checks
|
@@ -296,7 +309,7 @@ class SecurityManager:
|
|
296
309
|
elif reason == SecurityErrorReasons.SYMLINK_BROKEN:
|
297
310
|
msg = f"Broken symlink: {e.context['source']} -> {e.context['target']}"
|
298
311
|
logger.debug(msg)
|
299
|
-
raise
|
312
|
+
raise OstructFileNotFoundError(msg) from e
|
300
313
|
# Any other security errors propagate unchanged
|
301
314
|
raise
|
302
315
|
|
@@ -318,12 +331,12 @@ class SecurityManager:
|
|
318
331
|
|
319
332
|
# Only check existence after security validation
|
320
333
|
if not norm_path.exists():
|
321
|
-
raise
|
334
|
+
raise OstructFileNotFoundError(f"File not found: {path}")
|
322
335
|
|
323
336
|
return norm_path
|
324
337
|
|
325
338
|
except OSError as e:
|
326
|
-
if isinstance(e,
|
339
|
+
if isinstance(e, OstructFileNotFoundError):
|
327
340
|
raise
|
328
341
|
logger.error("Error resolving path: %s - %s", path, e)
|
329
342
|
raise PathSecurityError(
|
@@ -0,0 +1,25 @@
|
|
1
|
+
"""Serialization utilities for CLI logging."""
|
2
|
+
|
3
|
+
import json
|
4
|
+
from typing import Any, Dict
|
5
|
+
|
6
|
+
|
7
|
+
class LogSerializer:
|
8
|
+
"""Utility class for serializing log data."""
|
9
|
+
|
10
|
+
@staticmethod
|
11
|
+
def serialize_log_extra(extra: Dict[str, Any]) -> str:
|
12
|
+
"""Serialize extra log data to a formatted string.
|
13
|
+
|
14
|
+
Args:
|
15
|
+
extra: Dictionary of extra log data
|
16
|
+
|
17
|
+
Returns:
|
18
|
+
Formatted string representation of the extra data
|
19
|
+
"""
|
20
|
+
try:
|
21
|
+
# Try to serialize with nice formatting
|
22
|
+
return json.dumps(extra, indent=2, default=str)
|
23
|
+
except Exception:
|
24
|
+
# Fall back to basic string representation if JSON fails
|
25
|
+
return str(extra)
|
ostruct/cli/template_filters.py
CHANGED
@@ -10,7 +10,7 @@ from collections import Counter
|
|
10
10
|
from typing import Any, Dict, List, Optional, Sequence, TypeVar, Union
|
11
11
|
|
12
12
|
import tiktoken
|
13
|
-
from jinja2 import Environment
|
13
|
+
from jinja2 import Environment, pass_context
|
14
14
|
from pygments import highlight
|
15
15
|
from pygments.formatters import HtmlFormatter, NullFormatter, TerminalFormatter
|
16
16
|
from pygments.lexers import TextLexer, get_lexer_by_name, guess_lexer
|
@@ -178,10 +178,12 @@ def format_error(e: Exception) -> str:
|
|
178
178
|
return f"{type(e).__name__}: {str(e)}"
|
179
179
|
|
180
180
|
|
181
|
-
|
181
|
+
@pass_context
|
182
|
+
def estimate_tokens(context: Any, text: str) -> int:
|
182
183
|
"""Estimate number of tokens in text."""
|
183
184
|
try:
|
184
|
-
encoding
|
185
|
+
# Use o200k_base encoding for token estimation
|
186
|
+
encoding = tiktoken.get_encoding("o200k_base")
|
185
187
|
return len(encoding.encode(str(text)))
|
186
188
|
except Exception as e:
|
187
189
|
logger.warning(f"Failed to estimate tokens: {e}")
|
@@ -61,8 +61,9 @@ from typing import Any, Dict, List, Optional, Union
|
|
61
61
|
import jinja2
|
62
62
|
from jinja2 import Environment
|
63
63
|
|
64
|
-
from .errors import TemplateValidationError
|
64
|
+
from .errors import TaskTemplateVariableError, TemplateValidationError
|
65
65
|
from .file_utils import FileInfo
|
66
|
+
from .progress import ProgressContext
|
66
67
|
from .template_env import create_jinja_env
|
67
68
|
from .template_schema import DotDict, StdinProxy
|
68
69
|
|
@@ -92,23 +93,23 @@ TemplateContextValue = Union[
|
|
92
93
|
def render_template(
|
93
94
|
template_str: str,
|
94
95
|
context: Dict[str, Any],
|
95
|
-
|
96
|
-
|
96
|
+
env: Optional[Environment] = None,
|
97
|
+
progress: Optional[ProgressContext] = None,
|
97
98
|
) -> str:
|
98
|
-
"""Render a
|
99
|
+
"""Render a template with the given context.
|
99
100
|
|
100
101
|
Args:
|
101
|
-
template_str:
|
102
|
-
context:
|
103
|
-
|
104
|
-
|
102
|
+
template_str: Template string to render
|
103
|
+
context: Context dictionary for template variables
|
104
|
+
env: Optional Jinja2 environment to use
|
105
|
+
progress: Optional progress bar to update
|
105
106
|
|
106
107
|
Returns:
|
107
|
-
|
108
|
+
str: The rendered template string
|
108
109
|
|
109
110
|
Raises:
|
110
|
-
|
111
|
-
|
111
|
+
TaskTemplateVariableError: If template variables are undefined
|
112
|
+
TemplateValidationError: If template rendering fails for other reasons
|
112
113
|
"""
|
113
114
|
from .progress import ( # Import here to avoid circular dependency
|
114
115
|
ProgressContext,
|
@@ -116,16 +117,14 @@ def render_template(
|
|
116
117
|
|
117
118
|
with ProgressContext(
|
118
119
|
description="Rendering task template",
|
119
|
-
level="basic" if
|
120
|
+
level="basic" if progress else "none",
|
120
121
|
) as progress:
|
121
122
|
try:
|
122
123
|
if progress:
|
123
124
|
progress.update(1) # Update progress for setup
|
124
125
|
|
125
|
-
if
|
126
|
-
|
127
|
-
loader=jinja2.FileSystemLoader(".")
|
128
|
-
)
|
126
|
+
if env is None:
|
127
|
+
env = create_jinja_env(loader=jinja2.FileSystemLoader("."))
|
129
128
|
|
130
129
|
logger.debug("=== Raw Input ===")
|
131
130
|
logger.debug(
|
@@ -154,7 +153,7 @@ def render_template(
|
|
154
153
|
# Wrap JSON variables in DotDict and handle special cases
|
155
154
|
wrapped_context: Dict[str, TemplateContextValue] = {}
|
156
155
|
for key, value in context.items():
|
157
|
-
if isinstance(value, dict):
|
156
|
+
if isinstance(value, dict) and not isinstance(value, DotDict):
|
158
157
|
wrapped_context[key] = DotDict(value)
|
159
158
|
else:
|
160
159
|
wrapped_context[key] = value
|
@@ -188,7 +187,7 @@ def render_template(
|
|
188
187
|
f"Task template file not found: {template_str}"
|
189
188
|
)
|
190
189
|
try:
|
191
|
-
template =
|
190
|
+
template = env.get_template(template_str)
|
192
191
|
except jinja2.TemplateNotFound as e:
|
193
192
|
raise TemplateValidationError(
|
194
193
|
f"Task template file not found: {e.name}"
|
@@ -199,10 +198,10 @@ def render_template(
|
|
199
198
|
template_str,
|
200
199
|
)
|
201
200
|
try:
|
202
|
-
template =
|
201
|
+
template = env.from_string(template_str)
|
203
202
|
|
204
203
|
# Add debug log for loop rendering
|
205
|
-
def debug_file_render(f: FileInfo) ->
|
204
|
+
def debug_file_render(f: FileInfo) -> Any:
|
206
205
|
logger.info("Rendering file: %s", f.path)
|
207
206
|
return ""
|
208
207
|
|
@@ -292,6 +291,10 @@ def render_template(
|
|
292
291
|
" %s: %s (%r)", key, type(value).__name__, value
|
293
292
|
)
|
294
293
|
result = template.render(**wrapped_context)
|
294
|
+
if not isinstance(result, str):
|
295
|
+
raise TemplateValidationError(
|
296
|
+
f"Template rendered to non-string type: {type(result)}"
|
297
|
+
)
|
295
298
|
logger.info(
|
296
299
|
"Template render result (first 100 chars): %r",
|
297
300
|
result[:100],
|
@@ -302,7 +305,17 @@ def render_template(
|
|
302
305
|
)
|
303
306
|
if progress:
|
304
307
|
progress.update(1)
|
305
|
-
return result
|
308
|
+
return result
|
309
|
+
except jinja2.UndefinedError as e:
|
310
|
+
# Extract variable name from error message
|
311
|
+
var_name = str(e).split("'")[1]
|
312
|
+
error_msg = (
|
313
|
+
f"Missing required template variable: {var_name}\n"
|
314
|
+
f"Available variables: {', '.join(sorted(context.keys()))}\n"
|
315
|
+
"To fix this, please provide the variable using:\n"
|
316
|
+
f" -V {var_name}='value'"
|
317
|
+
)
|
318
|
+
raise TaskTemplateVariableError(error_msg) from e
|
306
319
|
except (jinja2.TemplateError, Exception) as e:
|
307
320
|
logger.error("Template rendering failed: %s", str(e))
|
308
321
|
raise TemplateValidationError(
|
@@ -336,4 +349,15 @@ def render_template_file(
|
|
336
349
|
"""
|
337
350
|
with open(template_path, "r", encoding="utf-8") as f:
|
338
351
|
template_str = f.read()
|
339
|
-
|
352
|
+
|
353
|
+
# Create a progress context if enabled
|
354
|
+
progress = (
|
355
|
+
ProgressContext(
|
356
|
+
description="Rendering template file",
|
357
|
+
level="basic" if progress_enabled else "none",
|
358
|
+
)
|
359
|
+
if progress_enabled
|
360
|
+
else None
|
361
|
+
)
|
362
|
+
|
363
|
+
return render_template(template_str, context, jinja_env, progress)
|
ostruct/cli/template_utils.py
CHANGED
@@ -9,10 +9,11 @@ It re-exports the public APIs from specialized modules:
|
|
9
9
|
- template_io: File I/O operations and metadata extraction
|
10
10
|
"""
|
11
11
|
|
12
|
+
import logging
|
12
13
|
from typing import Any, Dict, List, Optional, Set
|
13
14
|
|
14
15
|
import jsonschema
|
15
|
-
from jinja2 import Environment, meta
|
16
|
+
from jinja2 import Environment, TemplateSyntaxError, meta
|
16
17
|
from jinja2.nodes import Node
|
17
18
|
|
18
19
|
from .errors import (
|
@@ -35,6 +36,8 @@ from .template_schema import (
|
|
35
36
|
)
|
36
37
|
from .template_validation import SafeUndefined, validate_template_placeholders
|
37
38
|
|
39
|
+
logger = logging.getLogger(__name__)
|
40
|
+
|
38
41
|
|
39
42
|
# Custom error classes
|
40
43
|
class TemplateMetadataError(TaskTemplateError):
|
@@ -118,8 +121,13 @@ def find_all_template_variables(
|
|
118
121
|
if env is None:
|
119
122
|
env = Environment(undefined=SafeUndefined)
|
120
123
|
|
121
|
-
|
122
|
-
|
124
|
+
try:
|
125
|
+
ast = env.parse(template)
|
126
|
+
except TemplateSyntaxError as e:
|
127
|
+
logger.error("Failed to parse template: %s", str(e))
|
128
|
+
return set() # Return empty set on parse error
|
129
|
+
|
130
|
+
variables: Set[str] = set()
|
123
131
|
|
124
132
|
# Find all variables in this template
|
125
133
|
variables = meta.find_undeclared_variables(ast)
|
@@ -252,7 +260,7 @@ def find_all_template_variables(
|
|
252
260
|
visit_nodes(node.else_)
|
253
261
|
|
254
262
|
visit_nodes(ast.body)
|
255
|
-
return variables
|
263
|
+
return variables
|
256
264
|
|
257
265
|
|
258
266
|
__all__ = [
|
@@ -67,7 +67,7 @@ from jinja2 import meta
|
|
67
67
|
from jinja2.nodes import For, Name, Node
|
68
68
|
|
69
69
|
from . import template_filters
|
70
|
-
from .errors import TemplateValidationError
|
70
|
+
from .errors import TaskTemplateVariableError, TemplateValidationError
|
71
71
|
from .template_env import create_jinja_env
|
72
72
|
from .template_schema import (
|
73
73
|
DictProxy,
|
@@ -160,7 +160,8 @@ def validate_template_placeholders(
|
|
160
160
|
template_context: Optional context to validate against
|
161
161
|
|
162
162
|
Raises:
|
163
|
-
|
163
|
+
TaskTemplateVariableError: If any variables are undefined
|
164
|
+
TemplateValidationError: If template validation fails for other reasons
|
164
165
|
"""
|
165
166
|
logger = logging.getLogger(__name__)
|
166
167
|
|
@@ -309,10 +310,15 @@ def validate_template_placeholders(
|
|
309
310
|
}
|
310
311
|
|
311
312
|
if missing:
|
312
|
-
|
313
|
-
|
314
|
-
f"
|
313
|
+
# Create a more user-friendly error message
|
314
|
+
error_msg = (
|
315
|
+
f"Missing required template variable(s): {', '.join(sorted(missing))}\n"
|
316
|
+
f"Available variables: {', '.join(sorted(available_vars))}\n"
|
317
|
+
"To fix this, please provide the missing variable(s) using:\n"
|
315
318
|
)
|
319
|
+
for var in sorted(missing):
|
320
|
+
error_msg += f" -V {var}='value'\n"
|
321
|
+
raise TaskTemplateVariableError(error_msg)
|
316
322
|
|
317
323
|
logger.debug(
|
318
324
|
"Before create_validation_context - available_vars type: %s, value: %s",
|
@@ -336,8 +342,17 @@ def validate_template_placeholders(
|
|
336
342
|
try:
|
337
343
|
env.from_string(template).render(validation_context)
|
338
344
|
except jinja2.UndefinedError as e:
|
339
|
-
# Convert Jinja2 undefined errors to
|
340
|
-
|
345
|
+
# Convert Jinja2 undefined errors to TaskTemplateVariableError with helpful message
|
346
|
+
var_name = str(e).split("'")[
|
347
|
+
1
|
348
|
+
] # Extract variable name from error message
|
349
|
+
error_msg = (
|
350
|
+
f"Missing required template variable: {var_name}\n"
|
351
|
+
f"Available variables: {', '.join(sorted(available_vars))}\n"
|
352
|
+
"To fix this, please provide the variable using:\n"
|
353
|
+
f" -V {var_name}='value'"
|
354
|
+
)
|
355
|
+
raise TaskTemplateVariableError(error_msg) from e
|
341
356
|
except ValueError as e:
|
342
357
|
# Convert validation errors from template_schema to TemplateValidationError
|
343
358
|
raise TemplateValidationError(str(e))
|
@@ -348,10 +363,13 @@ def validate_template_placeholders(
|
|
348
363
|
raise
|
349
364
|
|
350
365
|
except jinja2.TemplateSyntaxError as e:
|
351
|
-
# Convert Jinja2 syntax errors to
|
366
|
+
# Convert Jinja2 syntax errors to TemplateValidationError
|
352
367
|
raise TemplateValidationError(
|
353
368
|
f"Invalid task template syntax: {str(e)}"
|
354
369
|
)
|
370
|
+
except (TaskTemplateVariableError, TemplateValidationError):
|
371
|
+
# Re-raise these without wrapping
|
372
|
+
raise
|
355
373
|
except Exception as e:
|
356
374
|
logger.error("Unexpected error during template validation: %s", str(e))
|
357
375
|
raise
|
@@ -0,0 +1,43 @@
|
|
1
|
+
"""Token estimation utilities."""
|
2
|
+
|
3
|
+
from typing import Any, Dict, List, Union
|
4
|
+
|
5
|
+
import tiktoken
|
6
|
+
|
7
|
+
|
8
|
+
def estimate_tokens_with_encoding(
|
9
|
+
messages: Union[str, Dict[str, str], List[Dict[str, str]]],
|
10
|
+
model: str,
|
11
|
+
encoder: Any = None,
|
12
|
+
) -> int:
|
13
|
+
"""Estimate the number of tokens in a chat completion.
|
14
|
+
|
15
|
+
Args:
|
16
|
+
messages: Message content - can be string, single message dict, or list of messages
|
17
|
+
model: Model name
|
18
|
+
encoder: Optional tiktoken encoder for testing
|
19
|
+
|
20
|
+
Returns:
|
21
|
+
int: Estimated token count
|
22
|
+
"""
|
23
|
+
if encoder is None:
|
24
|
+
# Use o200k_base for gpt-4o and o1 models
|
25
|
+
if model.startswith(("gpt-4o", "o1", "o3")):
|
26
|
+
encoder = tiktoken.get_encoding("o200k_base")
|
27
|
+
else:
|
28
|
+
encoder = tiktoken.get_encoding("cl100k_base")
|
29
|
+
|
30
|
+
if isinstance(messages, str):
|
31
|
+
return len(encoder.encode(messages))
|
32
|
+
elif isinstance(messages, dict):
|
33
|
+
return len(encoder.encode(str(messages.get("content", ""))))
|
34
|
+
else:
|
35
|
+
num_tokens = 0
|
36
|
+
for message in messages:
|
37
|
+
num_tokens += 4 # message overhead
|
38
|
+
for key, value in message.items():
|
39
|
+
num_tokens += len(encoder.encode(str(value)))
|
40
|
+
if key == "name":
|
41
|
+
num_tokens -= 1 # role is omitted
|
42
|
+
num_tokens += 2 # reply priming
|
43
|
+
return num_tokens
|
@@ -0,0 +1,109 @@
|
|
1
|
+
"""Validators for CLI options and arguments."""
|
2
|
+
|
3
|
+
import json
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import Any, List, Optional, Tuple, Union
|
6
|
+
|
7
|
+
import click
|
8
|
+
|
9
|
+
from .errors import InvalidJSONError, VariableNameError
|
10
|
+
|
11
|
+
|
12
|
+
def validate_name_path_pair(
|
13
|
+
ctx: click.Context,
|
14
|
+
param: click.Parameter,
|
15
|
+
value: List[Tuple[str, Union[str, Path]]],
|
16
|
+
) -> List[Tuple[str, Union[str, Path]]]:
|
17
|
+
"""Validate name/path pairs for files and directories.
|
18
|
+
|
19
|
+
Args:
|
20
|
+
ctx: Click context
|
21
|
+
param: Click parameter
|
22
|
+
value: List of (name, path) tuples
|
23
|
+
|
24
|
+
Returns:
|
25
|
+
List of validated (name, Path) tuples
|
26
|
+
|
27
|
+
Raises:
|
28
|
+
click.BadParameter: If validation fails
|
29
|
+
"""
|
30
|
+
if not value:
|
31
|
+
return value
|
32
|
+
|
33
|
+
result: List[Tuple[str, Union[str, Path]]] = []
|
34
|
+
for name, path in value:
|
35
|
+
if not name.isidentifier():
|
36
|
+
raise click.BadParameter(f"Invalid variable name: {name}")
|
37
|
+
result.append((name, Path(path)))
|
38
|
+
return result
|
39
|
+
|
40
|
+
|
41
|
+
def validate_variable(
|
42
|
+
ctx: click.Context, param: click.Parameter, value: Optional[List[str]]
|
43
|
+
) -> Optional[List[Tuple[str, str]]]:
|
44
|
+
"""Validate name=value format for simple variables.
|
45
|
+
|
46
|
+
Args:
|
47
|
+
ctx: Click context
|
48
|
+
param: Click parameter
|
49
|
+
value: List of "name=value" strings
|
50
|
+
|
51
|
+
Returns:
|
52
|
+
List of validated (name, value) tuples
|
53
|
+
|
54
|
+
Raises:
|
55
|
+
click.BadParameter: If validation fails
|
56
|
+
"""
|
57
|
+
if not value:
|
58
|
+
return None
|
59
|
+
|
60
|
+
result = []
|
61
|
+
for var in value:
|
62
|
+
if "=" not in var:
|
63
|
+
raise click.BadParameter(
|
64
|
+
f"Variable must be in format name=value: {var}"
|
65
|
+
)
|
66
|
+
name, val = var.split("=", 1)
|
67
|
+
if not name.isidentifier():
|
68
|
+
raise click.BadParameter(f"Invalid variable name: {name}")
|
69
|
+
result.append((name, val))
|
70
|
+
return result
|
71
|
+
|
72
|
+
|
73
|
+
def validate_json_variable(
|
74
|
+
ctx: click.Context, param: click.Parameter, value: Optional[List[str]]
|
75
|
+
) -> Optional[List[Tuple[str, Any]]]:
|
76
|
+
"""Validate JSON variable format.
|
77
|
+
|
78
|
+
Args:
|
79
|
+
ctx: Click context
|
80
|
+
param: Click parameter
|
81
|
+
value: List of "name=json_string" values
|
82
|
+
|
83
|
+
Returns:
|
84
|
+
List of validated (name, parsed_json) tuples
|
85
|
+
|
86
|
+
Raises:
|
87
|
+
click.BadParameter: If validation fails
|
88
|
+
"""
|
89
|
+
if not value:
|
90
|
+
return None
|
91
|
+
|
92
|
+
result = []
|
93
|
+
for var in value:
|
94
|
+
if "=" not in var:
|
95
|
+
raise InvalidJSONError(
|
96
|
+
f'JSON variable must be in format name=\'{"json":"value"}\': {var}'
|
97
|
+
)
|
98
|
+
name, json_str = var.split("=", 1)
|
99
|
+
if not name.isidentifier():
|
100
|
+
raise VariableNameError(f"Invalid variable name: {name}")
|
101
|
+
try:
|
102
|
+
json_value = json.loads(json_str)
|
103
|
+
result.append((name, json_value))
|
104
|
+
except json.JSONDecodeError as e:
|
105
|
+
raise InvalidJSONError(
|
106
|
+
f"Invalid JSON value for variable {name!r}: {json_str!r}",
|
107
|
+
context={"variable_name": name},
|
108
|
+
) from e
|
109
|
+
return result
|