ostruct-cli 0.6.1__py3-none-any.whl → 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ostruct/cli/__init__.py +2 -0
- ostruct/cli/cli.py +200 -73
- ostruct/cli/errors.py +61 -54
- ostruct/cli/model_creation.py +67 -94
- ostruct/cli/registry_updates.py +162 -0
- ostruct/cli/security/errors.py +1 -1
- ostruct/cli/security/normalization.py +1 -1
- ostruct/cli/security/security_manager.py +48 -7
- ostruct/cli/template_extensions.py +32 -1
- ostruct/cli/template_utils.py +175 -16
- ostruct/cli/utils.py +3 -1
- ostruct/cli/validators.py +6 -2
- {ostruct_cli-0.6.1.dist-info → ostruct_cli-0.7.0.dist-info}/METADATA +71 -165
- {ostruct_cli-0.6.1.dist-info → ostruct_cli-0.7.0.dist-info}/RECORD +17 -16
- {ostruct_cli-0.6.1.dist-info → ostruct_cli-0.7.0.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.6.1.dist-info → ostruct_cli-0.7.0.dist-info}/WHEEL +0 -0
- {ostruct_cli-0.6.1.dist-info → ostruct_cli-0.7.0.dist-info}/entry_points.txt +0 -0
ostruct/cli/model_creation.py
CHANGED
@@ -40,6 +40,7 @@ from .errors import (
|
|
40
40
|
NestedModelError,
|
41
41
|
SchemaValidationError,
|
42
42
|
)
|
43
|
+
from .exit_codes import ExitCode
|
43
44
|
|
44
45
|
logger = logging.getLogger(__name__)
|
45
46
|
|
@@ -297,90 +298,26 @@ def create_dynamic_model(
|
|
297
298
|
show_schema: bool = False,
|
298
299
|
debug_validation: bool = False,
|
299
300
|
) -> Type[BaseModel]:
|
300
|
-
"""Create a Pydantic model from a JSON
|
301
|
+
"""Create a Pydantic model from a JSON Schema.
|
301
302
|
|
302
303
|
Args:
|
303
|
-
schema: JSON
|
304
|
-
base_name:
|
305
|
-
show_schema: Whether to show the generated
|
306
|
-
debug_validation: Whether to show
|
304
|
+
schema: JSON Schema to create model from
|
305
|
+
base_name: Base name for the model class
|
306
|
+
show_schema: Whether to show the generated schema
|
307
|
+
debug_validation: Whether to show debug validation info
|
307
308
|
|
308
309
|
Returns:
|
309
|
-
|
310
|
+
Generated Pydantic model class
|
310
311
|
|
311
312
|
Raises:
|
312
|
-
|
313
|
-
|
313
|
+
SchemaValidationError: If schema validation fails
|
314
|
+
ModelCreationError: If model creation fails
|
314
315
|
"""
|
315
|
-
if debug_validation:
|
316
|
-
logger.info("Creating dynamic model from schema:")
|
317
|
-
logger.info(json.dumps(schema, indent=2))
|
318
|
-
|
319
316
|
try:
|
320
|
-
#
|
321
|
-
|
322
|
-
if debug_validation:
|
323
|
-
logger.info("Found schema wrapper, extracting inner schema")
|
324
|
-
logger.info(
|
325
|
-
"Original schema: %s", json.dumps(schema, indent=2)
|
326
|
-
)
|
327
|
-
inner_schema = schema["schema"]
|
328
|
-
if not isinstance(inner_schema, dict):
|
329
|
-
if debug_validation:
|
330
|
-
logger.info(
|
331
|
-
"Inner schema must be a dictionary, got %s",
|
332
|
-
type(inner_schema),
|
333
|
-
)
|
334
|
-
raise SchemaValidationError(
|
335
|
-
"Inner schema must be a dictionary"
|
336
|
-
)
|
337
|
-
if debug_validation:
|
338
|
-
logger.info("Using inner schema:")
|
339
|
-
logger.info(json.dumps(inner_schema, indent=2))
|
340
|
-
schema = inner_schema
|
341
|
-
|
342
|
-
# Validate against OpenAI requirements
|
343
|
-
from .schema_validation import validate_openai_schema
|
344
|
-
|
345
|
-
validate_openai_schema(schema)
|
346
|
-
|
347
|
-
# Create model configuration
|
348
|
-
config = ConfigDict(
|
349
|
-
title=schema.get("title", base_name),
|
350
|
-
extra="forbid", # OpenAI requires additionalProperties: false
|
351
|
-
validate_default=True,
|
352
|
-
use_enum_values=True,
|
353
|
-
arbitrary_types_allowed=True,
|
354
|
-
json_schema_extra={
|
355
|
-
k: v
|
356
|
-
for k, v in schema.items()
|
357
|
-
if k
|
358
|
-
not in {
|
359
|
-
"type",
|
360
|
-
"properties",
|
361
|
-
"required",
|
362
|
-
"title",
|
363
|
-
"description",
|
364
|
-
"additionalProperties",
|
365
|
-
"readOnly",
|
366
|
-
}
|
367
|
-
},
|
368
|
-
)
|
317
|
+
# Validate schema structure before model creation
|
318
|
+
from .template_utils import validate_json_schema
|
369
319
|
|
370
|
-
|
371
|
-
logger.info("Created model configuration:")
|
372
|
-
logger.info(" Title: %s", config.get("title"))
|
373
|
-
logger.info(" Extra: %s", config.get("extra"))
|
374
|
-
logger.info(
|
375
|
-
" Validate Default: %s", config.get("validate_default")
|
376
|
-
)
|
377
|
-
logger.info(" Use Enum Values: %s", config.get("use_enum_values"))
|
378
|
-
logger.info(
|
379
|
-
" Arbitrary Types: %s", config.get("arbitrary_types_allowed")
|
380
|
-
)
|
381
|
-
logger.info(
|
382
|
-
" JSON Schema Extra: %s", config.get("json_schema_extra")
|
383
|
-
)
|
320
|
+
validate_json_schema(schema)
|
384
321
|
|
385
322
|
# Process schema properties into fields
|
386
323
|
properties = schema.get("properties", {})
|
@@ -438,23 +375,25 @@ def create_dynamic_model(
|
|
438
375
|
)
|
439
376
|
for name, (field_type, field) in field_definitions.items()
|
440
377
|
}
|
441
|
-
model: Type[BaseModel] = create_model(
|
442
|
-
base_name, __config__=config, **field_defs
|
443
|
-
)
|
444
378
|
|
445
|
-
#
|
446
|
-
model
|
379
|
+
# Create model class
|
380
|
+
model = create_model(base_name, __base__=BaseModel, **field_defs)
|
447
381
|
|
448
|
-
|
449
|
-
|
450
|
-
|
382
|
+
# Set model config
|
383
|
+
model.model_config = ConfigDict(
|
384
|
+
title=schema.get("title", base_name),
|
385
|
+
extra="forbid",
|
386
|
+
)
|
387
|
+
|
388
|
+
if show_schema:
|
451
389
|
logger.info(
|
452
|
-
"
|
390
|
+
"Generated schema for %s:\n%s",
|
391
|
+
base_name,
|
453
392
|
json.dumps(model.model_json_schema(), indent=2),
|
454
393
|
)
|
455
394
|
|
456
|
-
# Validate the model's JSON schema
|
457
395
|
try:
|
396
|
+
# Validate model schema
|
458
397
|
model.model_json_schema()
|
459
398
|
except ValidationError as e:
|
460
399
|
validation_errors = (
|
@@ -467,18 +406,52 @@ def create_dynamic_model(
|
|
467
406
|
logger.error(" Error type: %s", type(e).__name__)
|
468
407
|
logger.error(" Error message: %s", str(e))
|
469
408
|
raise ModelValidationError(base_name, validation_errors)
|
409
|
+
except KeyError as e:
|
410
|
+
# Handle Pydantic schema generation errors, particularly for recursive references
|
411
|
+
error_msg = str(e).strip(
|
412
|
+
"'\""
|
413
|
+
) # Strip quotes from KeyError message
|
414
|
+
if error_msg.startswith("#/definitions/"):
|
415
|
+
context = {
|
416
|
+
"schema_path": schema.get("$id", "unknown"),
|
417
|
+
"reference": error_msg,
|
418
|
+
"found": "circular reference or missing definition",
|
419
|
+
"tips": [
|
420
|
+
"Add explicit $ref definitions for recursive structures",
|
421
|
+
"Use Pydantic's deferred annotations with typing.Self",
|
422
|
+
"Limit recursion depth with max_depth validator",
|
423
|
+
"Flatten nested structures using reference IDs",
|
424
|
+
],
|
425
|
+
}
|
470
426
|
|
471
|
-
|
427
|
+
error_msg = (
|
428
|
+
f"Invalid schema reference: {error_msg}\n"
|
429
|
+
"Detected circular reference or missing definition.\n"
|
430
|
+
"Solutions:\n"
|
431
|
+
"1. Add missing $ref definitions to your schema\n"
|
432
|
+
"2. Use explicit ID references instead of nested objects\n"
|
433
|
+
"3. Implement depth limits for recursive structures"
|
434
|
+
)
|
472
435
|
|
473
|
-
|
474
|
-
|
475
|
-
|
436
|
+
if debug_validation:
|
437
|
+
logger.error("Schema reference error:")
|
438
|
+
logger.error(" Error type: %s", type(e).__name__)
|
439
|
+
logger.error(" Error message: %s", error_msg)
|
476
440
|
|
477
|
-
|
478
|
-
|
479
|
-
|
480
|
-
|
481
|
-
|
441
|
+
raise SchemaValidationError(
|
442
|
+
error_msg, context=context, exit_code=ExitCode.SCHEMA_ERROR
|
443
|
+
) from e
|
444
|
+
|
445
|
+
# For other KeyErrors, preserve the original error
|
446
|
+
raise ModelCreationError(
|
447
|
+
f"Failed to create model {base_name}",
|
448
|
+
context={"error": str(e)},
|
449
|
+
) from e
|
450
|
+
|
451
|
+
return model
|
452
|
+
|
453
|
+
except SchemaValidationError:
|
454
|
+
# Re-raise schema validation errors without wrapping
|
482
455
|
raise
|
483
456
|
|
484
457
|
except Exception as e:
|
@@ -0,0 +1,162 @@
|
|
1
|
+
"""Registry update checks for ostruct CLI.
|
2
|
+
|
3
|
+
This module provides functionality to check for updates to the model registry
|
4
|
+
and notify users when updates are available.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import json
|
8
|
+
import logging
|
9
|
+
import os
|
10
|
+
import time
|
11
|
+
from pathlib import Path
|
12
|
+
from typing import Optional, Tuple
|
13
|
+
|
14
|
+
from openai_structured.model_registry import (
|
15
|
+
ModelRegistry,
|
16
|
+
RegistryUpdateStatus,
|
17
|
+
)
|
18
|
+
|
19
|
+
logger = logging.getLogger(__name__)
|
20
|
+
|
21
|
+
# Constants
|
22
|
+
UPDATE_CHECK_ENV_VAR = "OSTRUCT_DISABLE_UPDATE_CHECKS"
|
23
|
+
UPDATE_CHECK_INTERVAL_SECONDS = (
|
24
|
+
86400 # Check for updates once per day (24 hours)
|
25
|
+
)
|
26
|
+
LAST_CHECK_CACHE_FILE = ".ostruct_registry_check"
|
27
|
+
|
28
|
+
|
29
|
+
def _get_cache_dir() -> Path:
|
30
|
+
"""Get the cache directory for ostruct.
|
31
|
+
|
32
|
+
Returns:
|
33
|
+
Path: Path to the cache directory
|
34
|
+
"""
|
35
|
+
# Use XDG_CACHE_HOME if available, otherwise use ~/.cache
|
36
|
+
xdg_cache_home = os.environ.get("XDG_CACHE_HOME")
|
37
|
+
if xdg_cache_home:
|
38
|
+
base_dir = Path(xdg_cache_home)
|
39
|
+
else:
|
40
|
+
base_dir = Path.home() / ".cache"
|
41
|
+
|
42
|
+
cache_dir = base_dir / "ostruct"
|
43
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
44
|
+
return cache_dir
|
45
|
+
|
46
|
+
|
47
|
+
def _get_last_check_time() -> Optional[float]:
|
48
|
+
"""Get the timestamp of the last update check.
|
49
|
+
|
50
|
+
Returns:
|
51
|
+
Optional[float]: Timestamp of the last check, or None if never checked
|
52
|
+
"""
|
53
|
+
cache_file = _get_cache_dir() / LAST_CHECK_CACHE_FILE
|
54
|
+
|
55
|
+
if not cache_file.exists():
|
56
|
+
return None
|
57
|
+
|
58
|
+
try:
|
59
|
+
with open(cache_file, "r") as f:
|
60
|
+
data = json.load(f)
|
61
|
+
last_check_time = data.get("last_check_time")
|
62
|
+
return (
|
63
|
+
float(last_check_time) if last_check_time is not None else None
|
64
|
+
)
|
65
|
+
except (json.JSONDecodeError, IOError, OSError):
|
66
|
+
return None
|
67
|
+
|
68
|
+
|
69
|
+
def _save_last_check_time() -> None:
|
70
|
+
"""Save the current time as the last update check time."""
|
71
|
+
cache_file = _get_cache_dir() / LAST_CHECK_CACHE_FILE
|
72
|
+
|
73
|
+
try:
|
74
|
+
data = {"last_check_time": time.time()}
|
75
|
+
with open(cache_file, "w") as f:
|
76
|
+
json.dump(data, f)
|
77
|
+
except (IOError, OSError) as e:
|
78
|
+
logger.debug(f"Failed to save last check time: {e}")
|
79
|
+
|
80
|
+
|
81
|
+
def should_check_for_updates() -> bool:
|
82
|
+
"""Determine if we should check for registry updates.
|
83
|
+
|
84
|
+
Returns:
|
85
|
+
bool: True if update checks are enabled, False otherwise
|
86
|
+
"""
|
87
|
+
# Allow users to disable update checks via environment variable
|
88
|
+
if os.environ.get(UPDATE_CHECK_ENV_VAR, "").lower() in (
|
89
|
+
"1",
|
90
|
+
"true",
|
91
|
+
"yes",
|
92
|
+
):
|
93
|
+
logger.debug(
|
94
|
+
"Registry update checks disabled via environment variable"
|
95
|
+
)
|
96
|
+
return False
|
97
|
+
|
98
|
+
# Check if we've checked recently
|
99
|
+
last_check_time = _get_last_check_time()
|
100
|
+
if last_check_time is not None:
|
101
|
+
time_since_last_check = time.time() - last_check_time
|
102
|
+
if time_since_last_check < UPDATE_CHECK_INTERVAL_SECONDS:
|
103
|
+
logger.debug(
|
104
|
+
f"Skipping update check, last check was {time_since_last_check:.1f} seconds ago"
|
105
|
+
)
|
106
|
+
return False
|
107
|
+
|
108
|
+
return True
|
109
|
+
|
110
|
+
|
111
|
+
def check_for_registry_updates() -> Tuple[bool, Optional[str]]:
|
112
|
+
"""Check if there are updates available for the model registry.
|
113
|
+
|
114
|
+
This function is designed to be non-intrusive and fail gracefully.
|
115
|
+
|
116
|
+
Returns:
|
117
|
+
Tuple[bool, Optional[str]]: (update_available, message)
|
118
|
+
- update_available: True if an update is available
|
119
|
+
- message: A message to display to the user, or None if no update is available
|
120
|
+
"""
|
121
|
+
if not should_check_for_updates():
|
122
|
+
return False, None
|
123
|
+
|
124
|
+
try:
|
125
|
+
registry = ModelRegistry()
|
126
|
+
result = registry.check_for_updates()
|
127
|
+
|
128
|
+
# Save the check time regardless of the result
|
129
|
+
_save_last_check_time()
|
130
|
+
|
131
|
+
if result.status == RegistryUpdateStatus.UPDATE_AVAILABLE:
|
132
|
+
return True, (
|
133
|
+
"A new model registry is available. "
|
134
|
+
"This may include support for new models or features. "
|
135
|
+
"The registry will be automatically updated when needed."
|
136
|
+
)
|
137
|
+
|
138
|
+
return False, None
|
139
|
+
except Exception as e:
|
140
|
+
# Ensure any errors don't affect normal operation
|
141
|
+
logger.debug(f"Error checking for registry updates: {e}")
|
142
|
+
return False, None
|
143
|
+
|
144
|
+
|
145
|
+
def get_update_notification() -> Optional[str]:
|
146
|
+
"""Get a notification message if registry updates are available.
|
147
|
+
|
148
|
+
This function is designed to be called from the CLI to provide
|
149
|
+
a non-intrusive notification to users.
|
150
|
+
|
151
|
+
Returns:
|
152
|
+
Optional[str]: A notification message, or None if no notification is needed
|
153
|
+
"""
|
154
|
+
try:
|
155
|
+
update_available, message = check_for_registry_updates()
|
156
|
+
if update_available and message:
|
157
|
+
return message
|
158
|
+
return None
|
159
|
+
except Exception as e:
|
160
|
+
# Ensure any errors don't affect normal operation
|
161
|
+
logger.debug(f"Error getting update notification: {e}")
|
162
|
+
return None
|
ostruct/cli/security/errors.py
CHANGED
@@ -61,7 +61,7 @@ from .errors import PathSecurityError, SecurityErrorReasons
|
|
61
61
|
# Patterns for path normalization and validation
|
62
62
|
_UNICODE_SAFETY_PATTERN = re.compile(
|
63
63
|
r"[\u0000-\u001F\u007F-\u009F\u2028-\u2029\u0085]" # Control chars and line separators
|
64
|
-
r"
|
64
|
+
r"|(?:^|/)\.\.(?:/|$)" # Directory traversal attempts (only ".." as a path component)
|
65
65
|
r"|[\u2024\u2025\uFE52\u2024\u2025\u2026\uFE19\uFE30\uFE52\uFF0E\uFF61]" # Alternative dots and separators
|
66
66
|
)
|
67
67
|
_BACKSLASH_PATTERN = re.compile(r"\\")
|
@@ -39,10 +39,16 @@ class SecurityManager:
|
|
39
39
|
|
40
40
|
The security model is based on:
|
41
41
|
1. A base directory that serves as the root for all file operations
|
42
|
+
(typically set to the current working directory by higher-level functions)
|
42
43
|
2. A set of explicitly allowed directories that can be accessed outside the base directory
|
43
44
|
3. Special handling for temporary directories that are always allowed
|
44
45
|
4. Case-sensitive or case-insensitive path handling based on platform
|
45
46
|
|
47
|
+
Note:
|
48
|
+
While the SecurityManager class itself requires base_dir to be explicitly provided,
|
49
|
+
higher-level functions in the CLI layer (like validate_security_manager and file_utils)
|
50
|
+
will automatically use the current working directory as the base_dir if none is specified.
|
51
|
+
|
46
52
|
Example:
|
47
53
|
>>> sm = SecurityManager("/base/dir")
|
48
54
|
>>> sm.add_allowed_directory("/tmp")
|
@@ -62,7 +68,9 @@ class SecurityManager:
|
|
62
68
|
"""Initialize the SecurityManager.
|
63
69
|
|
64
70
|
Args:
|
65
|
-
base_dir: The root directory for file operations.
|
71
|
+
base_dir: The root directory for file operations. While this parameter is required here,
|
72
|
+
note that higher-level functions in the CLI layer will automatically use the
|
73
|
+
current working directory if no base_dir is specified.
|
66
74
|
allowed_dirs: Additional directories allowed for access.
|
67
75
|
allow_temp_paths: Whether to allow temporary directory paths.
|
68
76
|
max_symlink_depth: Maximum depth for symlink resolution.
|
@@ -234,20 +242,53 @@ class SecurityManager:
|
|
234
242
|
context={"reason": SecurityErrorReasons.SYMLINK_ERROR},
|
235
243
|
) from e
|
236
244
|
|
237
|
-
#
|
245
|
+
# Check for directory traversal attempts
|
246
|
+
if ".." in str(norm_path):
|
247
|
+
logger.error("Directory traversal attempt detected: %s", path)
|
248
|
+
raise PathSecurityError(
|
249
|
+
"Directory traversal attempt blocked",
|
250
|
+
path=str(path),
|
251
|
+
context={
|
252
|
+
"reason": SecurityErrorReasons.PATH_TRAVERSAL,
|
253
|
+
"base_dir": str(self._base_dir),
|
254
|
+
"allowed_dirs": [str(d) for d in self._allowed_dirs],
|
255
|
+
},
|
256
|
+
)
|
257
|
+
|
258
|
+
# Check for suspicious Unicode characters
|
259
|
+
if any(
|
260
|
+
c in str(norm_path)
|
261
|
+
for c in [
|
262
|
+
"\u2024",
|
263
|
+
"\u2025",
|
264
|
+
"\u2026",
|
265
|
+
"\u0085",
|
266
|
+
"\u2028",
|
267
|
+
"\u2029",
|
268
|
+
]
|
269
|
+
):
|
270
|
+
logger.error("Suspicious Unicode characters detected: %s", path)
|
271
|
+
raise PathSecurityError(
|
272
|
+
"Suspicious characters detected in path",
|
273
|
+
path=str(path),
|
274
|
+
context={
|
275
|
+
"reason": SecurityErrorReasons.UNSAFE_UNICODE,
|
276
|
+
"base_dir": str(self._base_dir),
|
277
|
+
"allowed_dirs": [str(d) for d in self._allowed_dirs],
|
278
|
+
},
|
279
|
+
)
|
280
|
+
|
281
|
+
# For non-symlinks, check if the normalized path is allowed
|
238
282
|
logger.debug("Checking if path is allowed: %s", norm_path)
|
239
283
|
if not self.is_path_allowed(norm_path):
|
240
284
|
logger.error(
|
241
|
-
"
|
285
|
+
"Path outside allowed directories: %s (base_dir=%s, allowed_dirs=%s)",
|
242
286
|
path,
|
243
287
|
self._base_dir,
|
244
288
|
self._allowed_dirs,
|
245
289
|
)
|
246
290
|
raise PathSecurityError(
|
247
|
-
|
248
|
-
f"Access denied: {os.path.basename(str(path))} is outside "
|
249
|
-
"base directory and not in allowed directories"
|
250
|
-
),
|
291
|
+
"Path outside allowed directories",
|
251
292
|
path=str(path),
|
252
293
|
context={
|
253
294
|
"reason": SecurityErrorReasons.PATH_OUTSIDE_ALLOWED,
|
@@ -16,6 +16,26 @@ class CommentExtension(Extension):
|
|
16
16
|
1. Contents of comment blocks are completely ignored during parsing
|
17
17
|
2. Variables inside comments are not validated or processed
|
18
18
|
3. Comments are stripped from the output
|
19
|
+
4. Nested comments are not allowed (will raise a syntax error)
|
20
|
+
|
21
|
+
Example:
|
22
|
+
Valid usage:
|
23
|
+
```jinja
|
24
|
+
{% comment %}
|
25
|
+
This is a comment
|
26
|
+
{{ some_var }} # This variable will be ignored
|
27
|
+
{% endcomment %}
|
28
|
+
```
|
29
|
+
|
30
|
+
Invalid usage (will raise error):
|
31
|
+
```jinja
|
32
|
+
{% comment %}
|
33
|
+
Outer comment
|
34
|
+
{% comment %} # Error: Nested comments are not allowed
|
35
|
+
Inner comment
|
36
|
+
{% endcomment %}
|
37
|
+
{% endcomment %}
|
38
|
+
```
|
19
39
|
"""
|
20
40
|
|
21
41
|
tags = {"comment"}
|
@@ -23,6 +43,9 @@ class CommentExtension(Extension):
|
|
23
43
|
def parse(self, parser: Parser) -> nodes.Node:
|
24
44
|
"""Parse a comment block, ignoring its contents.
|
25
45
|
|
46
|
+
Nested comments are not allowed and will raise a syntax error.
|
47
|
+
This keeps the template syntax simpler and more predictable.
|
48
|
+
|
26
49
|
Args:
|
27
50
|
parser: The Jinja2 parser instance
|
28
51
|
|
@@ -31,6 +54,7 @@ class CommentExtension(Extension):
|
|
31
54
|
|
32
55
|
Raises:
|
33
56
|
TemplateSyntaxError: If the comment block is not properly closed
|
57
|
+
or if a nested comment is found
|
34
58
|
"""
|
35
59
|
# Get the line number for error reporting
|
36
60
|
lineno = parser.stream.current.lineno
|
@@ -38,10 +62,17 @@ class CommentExtension(Extension):
|
|
38
62
|
# Skip the opening comment tag
|
39
63
|
next(parser.stream)
|
40
64
|
|
41
|
-
# Skip until we find {% endcomment %}
|
65
|
+
# Skip until we find {% endcomment %}, rejecting nested comments
|
42
66
|
while not parser.stream.current.test("name:endcomment"):
|
43
67
|
if parser.stream.current.type == "eof":
|
44
68
|
raise parser.fail("Unclosed comment block", lineno)
|
69
|
+
|
70
|
+
# Explicitly reject nested comments
|
71
|
+
if parser.stream.current.test("name:comment"):
|
72
|
+
raise parser.fail(
|
73
|
+
"Nested comments are not allowed. Use separate comment blocks instead.",
|
74
|
+
parser.stream.current.lineno,
|
75
|
+
)
|
45
76
|
next(parser.stream)
|
46
77
|
|
47
78
|
# Skip the endcomment tag
|