ostruct-cli 0.8.8__py3-none-any.whl → 1.0.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 +3 -15
- ostruct/cli/attachment_processor.py +455 -0
- ostruct/cli/attachment_template_bridge.py +973 -0
- ostruct/cli/cli.py +187 -33
- ostruct/cli/click_options.py +775 -692
- ostruct/cli/code_interpreter.py +195 -12
- ostruct/cli/commands/__init__.py +0 -3
- ostruct/cli/commands/run.py +289 -62
- ostruct/cli/config.py +23 -22
- ostruct/cli/constants.py +89 -0
- ostruct/cli/errors.py +191 -6
- ostruct/cli/explicit_file_processor.py +0 -15
- ostruct/cli/file_info.py +118 -14
- ostruct/cli/file_list.py +82 -1
- ostruct/cli/file_search.py +68 -2
- ostruct/cli/help_json.py +235 -0
- ostruct/cli/mcp_integration.py +13 -16
- ostruct/cli/params.py +217 -0
- ostruct/cli/plan_assembly.py +335 -0
- ostruct/cli/plan_printing.py +385 -0
- ostruct/cli/progress_reporting.py +8 -56
- ostruct/cli/quick_ref_help.py +128 -0
- ostruct/cli/rich_config.py +299 -0
- ostruct/cli/runner.py +397 -190
- ostruct/cli/security/__init__.py +2 -0
- ostruct/cli/security/allowed_checker.py +41 -0
- ostruct/cli/security/normalization.py +13 -9
- ostruct/cli/security/security_manager.py +558 -17
- ostruct/cli/security/types.py +15 -0
- ostruct/cli/template_debug.py +283 -261
- ostruct/cli/template_debug_help.py +233 -142
- ostruct/cli/template_env.py +46 -5
- ostruct/cli/template_filters.py +415 -8
- ostruct/cli/template_processor.py +240 -619
- ostruct/cli/template_rendering.py +49 -73
- ostruct/cli/template_validation.py +2 -1
- ostruct/cli/token_validation.py +35 -15
- ostruct/cli/types.py +15 -19
- ostruct/cli/unicode_compat.py +283 -0
- ostruct/cli/upload_manager.py +448 -0
- ostruct/cli/utils.py +30 -0
- ostruct/cli/validators.py +272 -54
- {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/METADATA +292 -126
- ostruct_cli-1.0.0.dist-info/RECORD +80 -0
- ostruct/cli/commands/quick_ref.py +0 -54
- ostruct/cli/template_optimizer.py +0 -478
- ostruct_cli-0.8.8.dist-info/RECORD +0 -71
- {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/WHEEL +0 -0
- {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/entry_points.txt +0 -0
ostruct/cli/template_filters.py
CHANGED
@@ -14,6 +14,7 @@ from typing import (
|
|
14
14
|
List,
|
15
15
|
Optional,
|
16
16
|
Sequence,
|
17
|
+
Set,
|
17
18
|
TypeVar,
|
18
19
|
Union,
|
19
20
|
)
|
@@ -33,6 +34,192 @@ logger = logging.getLogger(__name__)
|
|
33
34
|
T = TypeVar("T")
|
34
35
|
|
35
36
|
|
37
|
+
# ============================================================================
|
38
|
+
# Template Structure Enhancement System (TSES) Components
|
39
|
+
# ============================================================================
|
40
|
+
|
41
|
+
|
42
|
+
class TemplateStructureError(Exception):
|
43
|
+
"""Exception for TSES-specific errors with helpful suggestions."""
|
44
|
+
|
45
|
+
def __init__(self, message: str, suggestions: Optional[List[str]] = None):
|
46
|
+
super().__init__(message)
|
47
|
+
self.suggestions = suggestions or []
|
48
|
+
|
49
|
+
|
50
|
+
def format_tses_error(error: TemplateStructureError) -> str:
|
51
|
+
"""Format TSES error with suggestions."""
|
52
|
+
lines = [f"Template Structure Error: {error}"]
|
53
|
+
if error.suggestions:
|
54
|
+
lines.append("Suggestions:")
|
55
|
+
for suggestion in error.suggestions:
|
56
|
+
lines.append(f" • {suggestion}")
|
57
|
+
return "\n".join(lines)
|
58
|
+
|
59
|
+
|
60
|
+
class AliasManager:
|
61
|
+
"""Manages file aliases and tracks references."""
|
62
|
+
|
63
|
+
def __init__(self) -> None:
|
64
|
+
self.aliases: Dict[str, Dict[str, Any]] = {}
|
65
|
+
self.referenced: Set[str] = set()
|
66
|
+
|
67
|
+
def register_attachment(
|
68
|
+
self,
|
69
|
+
alias: str,
|
70
|
+
path: str,
|
71
|
+
files: List[Any],
|
72
|
+
is_collection: bool = False,
|
73
|
+
) -> None:
|
74
|
+
"""Register files attached via CLI with their alias."""
|
75
|
+
# Determine attachment type from the files themselves
|
76
|
+
if is_collection:
|
77
|
+
attachment_type = "collection"
|
78
|
+
elif files:
|
79
|
+
# Use the attachment_type from the first file if available
|
80
|
+
first_file = files[0]
|
81
|
+
attachment_type = getattr(first_file, "attachment_type", "file")
|
82
|
+
else:
|
83
|
+
# Fallback for empty file lists
|
84
|
+
attachment_type = "file"
|
85
|
+
|
86
|
+
self.aliases[alias] = {
|
87
|
+
"type": attachment_type,
|
88
|
+
"path": path,
|
89
|
+
"files": files,
|
90
|
+
}
|
91
|
+
|
92
|
+
def reference_alias(self, alias: str) -> None:
|
93
|
+
"""Mark an alias as referenced by file_ref()."""
|
94
|
+
if alias not in self.aliases:
|
95
|
+
available = list(self.aliases.keys())
|
96
|
+
raise TemplateStructureError(
|
97
|
+
f"Unknown alias '{alias}' in file_ref()",
|
98
|
+
[
|
99
|
+
f"Available aliases: {', '.join(available) if available else 'none'}",
|
100
|
+
"Check your --dir and --file attachments",
|
101
|
+
],
|
102
|
+
)
|
103
|
+
self.referenced.add(alias)
|
104
|
+
|
105
|
+
def get_referenced_aliases(self) -> Dict[str, Dict[str, Any]]:
|
106
|
+
"""Get only the aliases that were actually referenced."""
|
107
|
+
return {
|
108
|
+
alias: data
|
109
|
+
for alias, data in self.aliases.items()
|
110
|
+
if alias in self.referenced
|
111
|
+
}
|
112
|
+
|
113
|
+
|
114
|
+
class XMLAppendixBuilder:
|
115
|
+
"""Builds XML appendix for referenced file aliases."""
|
116
|
+
|
117
|
+
def __init__(self, alias_manager: AliasManager) -> None:
|
118
|
+
self.alias_manager = alias_manager
|
119
|
+
|
120
|
+
def build_appendix(self) -> str:
|
121
|
+
"""Build XML appendix for all referenced aliases."""
|
122
|
+
referenced = self.alias_manager.get_referenced_aliases()
|
123
|
+
|
124
|
+
if not referenced:
|
125
|
+
return ""
|
126
|
+
|
127
|
+
lines = ["<files>"]
|
128
|
+
|
129
|
+
for alias, data in referenced.items():
|
130
|
+
alias_type = data["type"]
|
131
|
+
path = data["path"]
|
132
|
+
files = data["files"]
|
133
|
+
|
134
|
+
if alias_type == "file":
|
135
|
+
# Single file
|
136
|
+
file_info = files[0]
|
137
|
+
lines.append(f' <file alias="{alias}" path="{path}">')
|
138
|
+
lines.append(
|
139
|
+
f" <content><![CDATA[{file_info.content}]]></content>"
|
140
|
+
)
|
141
|
+
lines.append(" </file>")
|
142
|
+
|
143
|
+
elif alias_type == "dir":
|
144
|
+
# Directory with multiple files
|
145
|
+
lines.append(f' <dir alias="{alias}" path="{path}">')
|
146
|
+
for file_info in files:
|
147
|
+
rel_path = getattr(
|
148
|
+
file_info, "relative_path", file_info.name
|
149
|
+
)
|
150
|
+
lines.append(f' <file path="{rel_path}">')
|
151
|
+
lines.append(
|
152
|
+
f" <content><![CDATA[{file_info.content}]]></content>"
|
153
|
+
)
|
154
|
+
lines.append(" </file>")
|
155
|
+
lines.append(" </dir>")
|
156
|
+
|
157
|
+
elif alias_type == "collection":
|
158
|
+
# File collection
|
159
|
+
lines.append(f' <collection alias="{alias}" path="{path}">')
|
160
|
+
for file_info in files:
|
161
|
+
file_path = getattr(file_info, "path", file_info.name)
|
162
|
+
lines.append(f' <file path="{file_path}">')
|
163
|
+
lines.append(
|
164
|
+
f" <content><![CDATA[{file_info.content}]]></content>"
|
165
|
+
)
|
166
|
+
lines.append(" </file>")
|
167
|
+
lines.append(" </collection>")
|
168
|
+
|
169
|
+
lines.append("</files>")
|
170
|
+
return "\n".join(lines)
|
171
|
+
|
172
|
+
|
173
|
+
# Global alias manager instance (set during environment creation)
|
174
|
+
_alias_manager: Optional[AliasManager] = None
|
175
|
+
|
176
|
+
|
177
|
+
def file_ref(alias_name: str) -> str:
|
178
|
+
"""Reference a file collection by its CLI alias name.
|
179
|
+
|
180
|
+
Args:
|
181
|
+
alias_name: The alias from --dir or --file attachment
|
182
|
+
|
183
|
+
Returns:
|
184
|
+
Reference string that renders as <alias_name>
|
185
|
+
|
186
|
+
Usage:
|
187
|
+
{{ file_ref("source-code") }} -> renders as "<source-code>"
|
188
|
+
"""
|
189
|
+
global _alias_manager
|
190
|
+
|
191
|
+
if not _alias_manager:
|
192
|
+
raise TemplateStructureError(
|
193
|
+
"File references not initialized",
|
194
|
+
[
|
195
|
+
"Check template processing pipeline",
|
196
|
+
"Ensure files are properly attached via CLI",
|
197
|
+
],
|
198
|
+
)
|
199
|
+
|
200
|
+
# Register this alias as referenced
|
201
|
+
_alias_manager.reference_alias(alias_name)
|
202
|
+
|
203
|
+
# Return the reference format
|
204
|
+
return f"<{alias_name}>"
|
205
|
+
|
206
|
+
|
207
|
+
def register_tses_filters(
|
208
|
+
env: Environment, alias_manager: AliasManager
|
209
|
+
) -> None:
|
210
|
+
"""Register TSES functions in Jinja2 environment."""
|
211
|
+
global _alias_manager
|
212
|
+
_alias_manager = alias_manager
|
213
|
+
|
214
|
+
# Register file_ref as a global function
|
215
|
+
env.globals["file_ref"] = file_ref
|
216
|
+
|
217
|
+
|
218
|
+
# ============================================================================
|
219
|
+
# End TSES v2.0 Components
|
220
|
+
# ============================================================================
|
221
|
+
|
222
|
+
|
36
223
|
def extract_keywords(text: str) -> List[str]:
|
37
224
|
"""Extract keywords from text."""
|
38
225
|
return text.split()
|
@@ -50,12 +237,12 @@ def char_count(text: str) -> int:
|
|
50
237
|
|
51
238
|
def to_json(obj: Any) -> str:
|
52
239
|
"""Convert object to JSON string."""
|
53
|
-
return json.dumps(obj
|
240
|
+
return json.dumps(obj)
|
54
241
|
|
55
242
|
|
56
|
-
def from_json(
|
243
|
+
def from_json(json_str: str) -> Any:
|
57
244
|
"""Parse JSON string to object."""
|
58
|
-
return json.loads(
|
245
|
+
return json.loads(json_str)
|
59
246
|
|
60
247
|
|
61
248
|
def remove_comments(text: str) -> str:
|
@@ -159,6 +346,16 @@ def debug_print(x: Any) -> None:
|
|
159
346
|
print(f"DEBUG: {x}")
|
160
347
|
|
161
348
|
|
349
|
+
def format_json(obj: Any) -> str:
|
350
|
+
"""Format JSON with proper indentation."""
|
351
|
+
if isinstance(obj, str):
|
352
|
+
try:
|
353
|
+
obj = json.loads(obj)
|
354
|
+
except json.JSONDecodeError:
|
355
|
+
return str(obj)
|
356
|
+
return json.dumps(obj, indent=2, ensure_ascii=False)
|
357
|
+
|
358
|
+
|
162
359
|
def type_of(x: Any) -> str:
|
163
360
|
"""Get type name of object."""
|
164
361
|
return type(x).__name__
|
@@ -190,6 +387,156 @@ def format_error(e: Exception) -> str:
|
|
190
387
|
return f"{type(e).__name__}: {str(e)}"
|
191
388
|
|
192
389
|
|
390
|
+
@pass_context
|
391
|
+
def safe_get(context: Any, *args: Any) -> Any:
|
392
|
+
"""Safely get a nested attribute path, returning default if any part is undefined.
|
393
|
+
|
394
|
+
This function provides safe access to nested object attributes without raising
|
395
|
+
UndefinedError when intermediate objects don't exist.
|
396
|
+
|
397
|
+
Template Usage:
|
398
|
+
safe_get(path: str, default_value: Any = "") -> Any
|
399
|
+
|
400
|
+
Args:
|
401
|
+
path: Dot-separated path to the attribute (e.g., "transcript.content")
|
402
|
+
default_value: Value to return if path doesn't exist (default: "")
|
403
|
+
|
404
|
+
Returns:
|
405
|
+
The value at the path if it exists and is non-empty, otherwise default_value
|
406
|
+
|
407
|
+
Examples:
|
408
|
+
{{ safe_get("transcript.content", "No transcript available") }}
|
409
|
+
{{ safe_get("user.profile.bio", "No bio provided") }}
|
410
|
+
{{ safe_get("config.debug") }} # Uses empty string as default
|
411
|
+
|
412
|
+
Common Mistakes:
|
413
|
+
❌ {{ safe_get(object, 'property', 'default') }} # Wrong: passing object
|
414
|
+
✅ {{ safe_get('object.property', 'default') }} # Right: string path
|
415
|
+
|
416
|
+
❌ {{ safe_get(user_data, 'name') }} # Wrong: object first
|
417
|
+
✅ {{ safe_get('user_data.name') }} # Right: string path
|
418
|
+
|
419
|
+
Raises:
|
420
|
+
TemplateStructureError: If arguments are incorrect or malformed
|
421
|
+
"""
|
422
|
+
# Import here to avoid circular imports
|
423
|
+
|
424
|
+
# Validate argument count
|
425
|
+
if len(args) < 1 or len(args) > 2:
|
426
|
+
raise TemplateStructureError(
|
427
|
+
f"safe_get() takes 1 or 2 arguments, got {len(args)}",
|
428
|
+
[
|
429
|
+
"Correct usage: safe_get('path.to.property', 'default_value')",
|
430
|
+
"Example: safe_get('user.name', 'Anonymous')",
|
431
|
+
f"You provided: {len(args)} arguments",
|
432
|
+
],
|
433
|
+
)
|
434
|
+
|
435
|
+
# Extract arguments
|
436
|
+
path = args[0]
|
437
|
+
default_value = args[1] if len(args) > 1 else ""
|
438
|
+
|
439
|
+
# Validate path parameter type
|
440
|
+
if not isinstance(path, str):
|
441
|
+
# Provide helpful error message for common mistakes
|
442
|
+
path_type = type(path).__name__
|
443
|
+
if hasattr(path, "__class__") and hasattr(
|
444
|
+
path.__class__, "__module__"
|
445
|
+
):
|
446
|
+
# For complex objects, show module.class
|
447
|
+
module_name = path.__class__.__module__
|
448
|
+
if module_name != "builtins":
|
449
|
+
path_type = f"{module_name}.{path.__class__.__name__}"
|
450
|
+
|
451
|
+
# Check for common mistake patterns
|
452
|
+
suggestions = [
|
453
|
+
"Use string path syntax: safe_get('object.property', 'default')",
|
454
|
+
f"You passed: {path_type} as first argument",
|
455
|
+
"WRONG: safe_get(object, 'property', 'default')",
|
456
|
+
"RIGHT: safe_get('object.property', 'default')",
|
457
|
+
]
|
458
|
+
|
459
|
+
# Add specific suggestions based on the type
|
460
|
+
if hasattr(path, "name") or hasattr(path, "content"):
|
461
|
+
suggestions.append(
|
462
|
+
"For file objects, use: safe_get('filename.property', 'default')"
|
463
|
+
)
|
464
|
+
elif isinstance(path, (list, tuple)):
|
465
|
+
suggestions.append(
|
466
|
+
"For collections, iterate first: {% for item in collection %}"
|
467
|
+
)
|
468
|
+
elif hasattr(path, "__dict__"):
|
469
|
+
suggestions.append(
|
470
|
+
"For objects, use dot notation in quotes: safe_get('object.attribute', 'default')"
|
471
|
+
)
|
472
|
+
|
473
|
+
raise TemplateStructureError(
|
474
|
+
f"safe_get() expects a string path, got {path_type}", suggestions
|
475
|
+
)
|
476
|
+
|
477
|
+
# Validate path is not empty
|
478
|
+
if not path.strip():
|
479
|
+
raise TemplateStructureError(
|
480
|
+
"safe_get() path cannot be empty",
|
481
|
+
[
|
482
|
+
"Provide a valid dot-separated path like 'object.property'",
|
483
|
+
"Example: safe_get('user.name', 'Anonymous')",
|
484
|
+
],
|
485
|
+
)
|
486
|
+
|
487
|
+
try:
|
488
|
+
# Split the path and traverse the object tree
|
489
|
+
parts = path.split(".")
|
490
|
+
current = context
|
491
|
+
|
492
|
+
# Start from the first part in the context
|
493
|
+
for i, part in enumerate(parts):
|
494
|
+
if i == 0:
|
495
|
+
# First part: look in the template context
|
496
|
+
if part in context:
|
497
|
+
current = context[part]
|
498
|
+
else:
|
499
|
+
return default_value
|
500
|
+
else:
|
501
|
+
# Subsequent parts: traverse the object
|
502
|
+
if hasattr(current, part):
|
503
|
+
current = getattr(current, part)
|
504
|
+
elif isinstance(current, dict) and part in current:
|
505
|
+
current = current[part]
|
506
|
+
else:
|
507
|
+
# Path doesn't exist, return default
|
508
|
+
return default_value
|
509
|
+
|
510
|
+
# Apply emptiness check to the final value
|
511
|
+
# Check for None
|
512
|
+
if current is None:
|
513
|
+
return default_value
|
514
|
+
|
515
|
+
# Check for empty string
|
516
|
+
if isinstance(current, str) and not current.strip():
|
517
|
+
return default_value
|
518
|
+
|
519
|
+
# Check for empty collections (list, dict, etc.)
|
520
|
+
if hasattr(current, "__len__") and len(current) == 0:
|
521
|
+
return default_value
|
522
|
+
|
523
|
+
# Return the value (preserving intentional falsy values like False or 0)
|
524
|
+
return current
|
525
|
+
|
526
|
+
except AttributeError as e:
|
527
|
+
# More specific error handling for attribute access issues
|
528
|
+
logger.debug(f"safe_get attribute error for path '{path}': {e}")
|
529
|
+
return default_value
|
530
|
+
except (KeyError, TypeError) as e:
|
531
|
+
# Handle other access issues
|
532
|
+
logger.debug(f"safe_get access error for path '{path}': {e}")
|
533
|
+
return default_value
|
534
|
+
except Exception as e:
|
535
|
+
# Catch any other unexpected errors but log them for debugging
|
536
|
+
logger.warning(f"Unexpected error in safe_get for path '{path}': {e}")
|
537
|
+
return default_value
|
538
|
+
|
539
|
+
|
193
540
|
@pass_context
|
194
541
|
def estimate_tokens(context: Any, text: str) -> int:
|
195
542
|
"""Estimate number of tokens in text."""
|
@@ -202,11 +549,6 @@ def estimate_tokens(context: Any, text: str) -> int:
|
|
202
549
|
return len(str(text).split())
|
203
550
|
|
204
551
|
|
205
|
-
def format_json(obj: Any) -> str:
|
206
|
-
"""Format JSON with indentation."""
|
207
|
-
return json.dumps(obj, indent=2, default=str)
|
208
|
-
|
209
|
-
|
210
552
|
def auto_table(data: Any) -> str:
|
211
553
|
"""Format data as table based on type."""
|
212
554
|
if isinstance(data, dict):
|
@@ -641,6 +983,60 @@ def single_filter(value: Any) -> Any:
|
|
641
983
|
return value
|
642
984
|
|
643
985
|
|
986
|
+
def files_filter(value: Any) -> List[Any]:
|
987
|
+
"""Ensure a file-bearing value is iterable.
|
988
|
+
|
989
|
+
This filter implements the file-sequence protocol by ensuring that
|
990
|
+
any file-bearing value can be iterated over. Single files yield
|
991
|
+
themselves, while collections remain as-is.
|
992
|
+
|
993
|
+
Args:
|
994
|
+
value: A file-bearing value (FileInfo, FileInfoList, or other iterable)
|
995
|
+
|
996
|
+
Returns:
|
997
|
+
A list containing the file(s) for uniform iteration
|
998
|
+
"""
|
999
|
+
# Handle strings and bytes specially - treat as single items, not character sequences
|
1000
|
+
if isinstance(value, (str, bytes)):
|
1001
|
+
return [value]
|
1002
|
+
|
1003
|
+
try:
|
1004
|
+
# If it's already iterable (but not string/bytes), convert to list
|
1005
|
+
return list(value)
|
1006
|
+
except TypeError:
|
1007
|
+
# If not iterable, wrap in a list
|
1008
|
+
return [value]
|
1009
|
+
|
1010
|
+
|
1011
|
+
def is_fileish(value: Any) -> bool:
|
1012
|
+
"""Test if a value is file-like (iterable collection of file objects).
|
1013
|
+
|
1014
|
+
This test function helps templates identify file-bearing values
|
1015
|
+
that implement the file-sequence protocol.
|
1016
|
+
|
1017
|
+
Args:
|
1018
|
+
value: The value to test
|
1019
|
+
|
1020
|
+
Returns:
|
1021
|
+
True if the value is iterable and contains file-like objects
|
1022
|
+
"""
|
1023
|
+
try:
|
1024
|
+
# Import here to avoid circular imports
|
1025
|
+
from .file_info import FileInfo
|
1026
|
+
|
1027
|
+
# Check if it's iterable
|
1028
|
+
if not hasattr(value, "__iter__"):
|
1029
|
+
return False
|
1030
|
+
|
1031
|
+
# Convert to list to check contents
|
1032
|
+
items = list(value)
|
1033
|
+
|
1034
|
+
# Check if all items are FileInfo objects
|
1035
|
+
return all(isinstance(item, FileInfo) for item in items)
|
1036
|
+
except (TypeError, ImportError):
|
1037
|
+
return False
|
1038
|
+
|
1039
|
+
|
644
1040
|
def register_template_filters(env: Environment) -> None:
|
645
1041
|
"""Register all template filters with the Jinja2 environment.
|
646
1042
|
|
@@ -682,10 +1078,19 @@ def register_template_filters(env: Environment) -> None:
|
|
682
1078
|
"auto_table": auto_table,
|
683
1079
|
# Single item extraction
|
684
1080
|
"single": single_filter,
|
1081
|
+
# File-sequence protocol support
|
1082
|
+
"files": files_filter,
|
685
1083
|
}
|
686
1084
|
|
687
1085
|
env.filters.update(filters)
|
688
1086
|
|
1087
|
+
# Add template tests
|
1088
|
+
tests = {
|
1089
|
+
"fileish": is_fileish,
|
1090
|
+
}
|
1091
|
+
|
1092
|
+
env.tests.update(tests)
|
1093
|
+
|
689
1094
|
# Add template globals
|
690
1095
|
env.globals.update(
|
691
1096
|
{
|
@@ -703,5 +1108,7 @@ def register_template_filters(env: Environment) -> None:
|
|
703
1108
|
"pivot_table": pivot_table,
|
704
1109
|
# Table utilities
|
705
1110
|
"auto_table": auto_table,
|
1111
|
+
# Safe access utilities
|
1112
|
+
"safe_get": safe_get,
|
706
1113
|
}
|
707
1114
|
)
|