ostruct-cli 0.8.29__py3-none-any.whl → 1.0.1__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 +157 -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 +175 -5
- ostruct/cli/explicit_file_processor.py +0 -15
- ostruct/cli/file_info.py +97 -15
- ostruct/cli/file_list.py +43 -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/validators.py +255 -54
- {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.1.dist-info}/METADATA +231 -128
- ostruct_cli-1.0.1.dist-info/RECORD +80 -0
- ostruct/cli/commands/quick_ref.py +0 -54
- ostruct/cli/template_optimizer.py +0 -478
- ostruct_cli-0.8.29.dist-info/RECORD +0 -71
- {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.1.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.1.dist-info}/WHEEL +0 -0
- {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.1.dist-info}/entry_points.txt +0 -0
@@ -65,12 +65,7 @@ from jinja2 import Environment
|
|
65
65
|
from .errors import TaskTemplateVariableError, TemplateValidationError
|
66
66
|
from .file_utils import FileInfo
|
67
67
|
from .progress import ProgressContext
|
68
|
-
from .progress_reporting import get_progress_reporter
|
69
68
|
from .template_env import create_jinja_env
|
70
|
-
from .template_optimizer import (
|
71
|
-
is_optimization_beneficial,
|
72
|
-
optimize_template_for_llm,
|
73
|
-
)
|
74
69
|
from .template_schema import DotDict, StdinProxy
|
75
70
|
|
76
71
|
__all__ = [
|
@@ -156,7 +151,7 @@ def render_template(
|
|
156
151
|
TaskTemplateVariableError: If template variables are undefined
|
157
152
|
TemplateValidationError: If template rendering fails for other reasons
|
158
153
|
"""
|
159
|
-
from .progress import (
|
154
|
+
from .progress import (
|
160
155
|
ProgressContext,
|
161
156
|
)
|
162
157
|
|
@@ -169,7 +164,7 @@ def render_template(
|
|
169
164
|
progress.update(1) # Update progress for setup
|
170
165
|
|
171
166
|
if env is None:
|
172
|
-
env = create_jinja_env(loader=jinja2.FileSystemLoader("."))
|
167
|
+
env, _ = create_jinja_env(loader=jinja2.FileSystemLoader("."))
|
173
168
|
|
174
169
|
logger.debug("=== Raw Input ===")
|
175
170
|
logger.debug(
|
@@ -304,67 +299,6 @@ def render_template(
|
|
304
299
|
" %s: %s (%r)", key, type(value).__name__, value
|
305
300
|
)
|
306
301
|
|
307
|
-
# Apply template optimization for better LLM performance
|
308
|
-
try:
|
309
|
-
# Get template source - use template_str for string templates or template.source for file templates
|
310
|
-
if hasattr(template, "source") and template.source:
|
311
|
-
original_template_source = template.source
|
312
|
-
else:
|
313
|
-
original_template_source = template_str
|
314
|
-
|
315
|
-
if (
|
316
|
-
original_template_source
|
317
|
-
and is_optimization_beneficial(
|
318
|
-
original_template_source
|
319
|
-
)
|
320
|
-
):
|
321
|
-
logger.debug("=== Template Optimization ===")
|
322
|
-
optimization_result = optimize_template_for_llm(
|
323
|
-
original_template_source
|
324
|
-
)
|
325
|
-
|
326
|
-
if optimization_result.has_optimizations:
|
327
|
-
# Report optimization to user
|
328
|
-
progress_reporter = get_progress_reporter()
|
329
|
-
progress_reporter.report_optimization(
|
330
|
-
optimization_result.transformations
|
331
|
-
)
|
332
|
-
|
333
|
-
logger.info(
|
334
|
-
"Template optimized for LLM performance:"
|
335
|
-
)
|
336
|
-
for (
|
337
|
-
transformation
|
338
|
-
) in optimization_result.transformations:
|
339
|
-
logger.info(f" • {transformation}")
|
340
|
-
logger.info(
|
341
|
-
f" • Optimization time: {optimization_result.optimization_time_ms:.1f}ms"
|
342
|
-
)
|
343
|
-
|
344
|
-
# Create new template from optimized content
|
345
|
-
template = env.from_string(
|
346
|
-
optimization_result.optimized_template
|
347
|
-
)
|
348
|
-
# Re-add globals to new template
|
349
|
-
template.globals["template_name"] = getattr(
|
350
|
-
template, "name", "<string>"
|
351
|
-
)
|
352
|
-
template.globals["template_path"] = getattr(
|
353
|
-
template, "filename", None
|
354
|
-
)
|
355
|
-
else:
|
356
|
-
logger.debug("No beneficial optimizations found")
|
357
|
-
else:
|
358
|
-
logger.debug(
|
359
|
-
"Template optimization not beneficial - skipping"
|
360
|
-
)
|
361
|
-
except Exception as e:
|
362
|
-
# If optimization fails, continue with original template
|
363
|
-
logger.warning(
|
364
|
-
f"Template optimization failed, using original: {e}"
|
365
|
-
)
|
366
|
-
# template remains unchanged
|
367
|
-
|
368
302
|
result = template.render(**wrapped_context)
|
369
303
|
if not isinstance(result, str):
|
370
304
|
raise TemplateValidationError(
|
@@ -391,11 +325,53 @@ def render_template(
|
|
391
325
|
f" -V {var_name}='value'"
|
392
326
|
)
|
393
327
|
raise TaskTemplateVariableError(error_msg) from e
|
394
|
-
except
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
328
|
+
except TypeError as e:
|
329
|
+
error_str = str(e)
|
330
|
+
# Handle iteration errors with user-friendly messages
|
331
|
+
if "object is not iterable" in error_str:
|
332
|
+
# Extract variable name from iteration context
|
333
|
+
# Look for patterns like "'ClassName' object is not iterable"
|
334
|
+
# and provide helpful guidance
|
335
|
+
user_friendly_msg = (
|
336
|
+
"Template iteration error: A variable used in a loop ({% for ... %}) "
|
337
|
+
"is not iterable. This usually means:\n"
|
338
|
+
"1. The variable is not a file object, list, or other iterable type\n"
|
339
|
+
"2. Check that your template variable names match the expected data\n"
|
340
|
+
"3. Verify the variable contains the expected data type\n"
|
341
|
+
f"Available variables: {', '.join(sorted(context.keys()))}"
|
342
|
+
)
|
343
|
+
logger.error("Template iteration error: %s", error_str)
|
344
|
+
raise TemplateValidationError(user_friendly_msg) from e
|
345
|
+
else:
|
346
|
+
# Other TypeError - preserve original behavior
|
347
|
+
logger.error("Template rendering failed: %s", str(e))
|
348
|
+
raise TemplateValidationError(
|
349
|
+
f"Template rendering failed: {str(e)}"
|
350
|
+
) from e
|
351
|
+
except Exception as e:
|
352
|
+
# Import here to avoid circular imports
|
353
|
+
from .template_filters import (
|
354
|
+
TemplateStructureError,
|
355
|
+
format_tses_error,
|
356
|
+
)
|
357
|
+
|
358
|
+
# Handle TemplateStructureError with helpful formatting
|
359
|
+
if isinstance(e, TemplateStructureError):
|
360
|
+
formatted_error = format_tses_error(e)
|
361
|
+
logger.error(
|
362
|
+
"Template structure error: %s", formatted_error
|
363
|
+
)
|
364
|
+
raise TemplateValidationError(formatted_error) from e
|
365
|
+
elif isinstance(e, jinja2.TemplateError):
|
366
|
+
logger.error("Template rendering failed: %s", str(e))
|
367
|
+
raise TemplateValidationError(
|
368
|
+
f"Template rendering failed: {str(e)}"
|
369
|
+
) from e
|
370
|
+
else:
|
371
|
+
logger.error("Template rendering failed: %s", str(e))
|
372
|
+
raise TemplateValidationError(
|
373
|
+
f"Template rendering failed: {str(e)}"
|
374
|
+
) from e
|
399
375
|
|
400
376
|
except ValueError as e:
|
401
377
|
# Re-raise with original context
|
@@ -218,7 +218,8 @@ def validate_template_placeholders(
|
|
218
218
|
|
219
219
|
try:
|
220
220
|
# 1) Create Jinja2 environment with meta extension and safe undefined
|
221
|
-
|
221
|
+
env_tuple = create_jinja_env(validation_mode=True)
|
222
|
+
env = env_tuple[0]
|
222
223
|
|
223
224
|
# Register custom filters with None-safe wrappers
|
224
225
|
env.filters.update(
|
ostruct/cli/token_validation.py
CHANGED
@@ -184,19 +184,36 @@ class TokenLimitValidator:
|
|
184
184
|
|
185
185
|
for file_path, tokens in oversized_files:
|
186
186
|
file_name = Path(file_path).name
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
error_msg += f"
|
187
|
+
file_extension = Path(file_path).suffix.lower()
|
188
|
+
|
189
|
+
if file_extension in [
|
190
|
+
".csv",
|
191
|
+
".json",
|
192
|
+
".xml",
|
193
|
+
".yaml",
|
194
|
+
".yml",
|
195
|
+
]:
|
196
|
+
error_msg += f" 📊 Data file: ostruct --file ci:data {file_name} <template> <schema>\n"
|
197
|
+
elif file_extension in [
|
198
|
+
".pdf",
|
199
|
+
".txt",
|
200
|
+
".md",
|
201
|
+
".doc",
|
202
|
+
".docx",
|
203
|
+
]:
|
204
|
+
error_msg += f" 📄 Document: ostruct --file fs:docs {file_name} <template> <schema>\n"
|
205
|
+
elif file_extension in [
|
206
|
+
".py",
|
207
|
+
".js",
|
208
|
+
".ts",
|
209
|
+
".java",
|
210
|
+
".cpp",
|
211
|
+
".c",
|
212
|
+
]:
|
213
|
+
error_msg += f" 💻 Code file: ostruct --file ci:code {file_name} <template> <schema>\n"
|
197
214
|
else:
|
198
|
-
error_msg += f" 📁 Large file: ostruct
|
199
|
-
error_msg += " (Choose based on usage:
|
215
|
+
error_msg += f" 📁 Large file: ostruct --file ci:data {file_name} OR --file fs:docs {file_name} <template> <schema>\n"
|
216
|
+
error_msg += " (Choose based on usage: ci for processing, fs for retrieval)\n\n"
|
200
217
|
|
201
218
|
error_msg += (
|
202
219
|
f" Size: {tokens:,} tokens ({file_path})\n\n"
|
@@ -256,11 +273,14 @@ class TokenLimitValidator:
|
|
256
273
|
def _get_recommended_flags(self, file_path: str) -> List[str]:
|
257
274
|
"""Get recommended CLI flags for file routing."""
|
258
275
|
if self._is_data_file(file_path) or self._is_code_file(file_path):
|
259
|
-
return ["
|
276
|
+
return ["--file ci:data", "--file ci:code"]
|
260
277
|
elif self._is_document_file(file_path):
|
261
|
-
return ["
|
278
|
+
return ["--file fs:docs", "--file fs:knowledge"]
|
262
279
|
else:
|
263
|
-
return [
|
280
|
+
return [
|
281
|
+
"--file ci:data",
|
282
|
+
"--file fs:docs",
|
283
|
+
] # Both options for unknown files
|
264
284
|
|
265
285
|
|
266
286
|
def validate_token_limits(
|
ostruct/cli/types.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
"""Type definitions for ostruct CLI."""
|
2
2
|
|
3
3
|
from pathlib import Path
|
4
|
-
from typing import List, Optional, Tuple, TypedDict, Union
|
4
|
+
from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union
|
5
5
|
|
6
6
|
# Import FileRoutingResult from validators
|
7
7
|
FileRoutingResult = List[Tuple[Optional[str], Union[str, Path]]]
|
@@ -32,7 +32,6 @@ class CLIParams(TypedDict, total=False):
|
|
32
32
|
timeout: float
|
33
33
|
output_file: Optional[str]
|
34
34
|
dry_run: bool
|
35
|
-
no_progress: bool
|
36
35
|
api_key: Optional[str]
|
37
36
|
verbose: bool
|
38
37
|
show_model_schema: bool
|
@@ -43,24 +42,30 @@ class CLIParams(TypedDict, total=False):
|
|
43
42
|
frequency_penalty: Optional[float]
|
44
43
|
presence_penalty: Optional[float]
|
45
44
|
reasoning_effort: Optional[str]
|
46
|
-
|
45
|
+
progress: str
|
47
46
|
task_file: Optional[str]
|
48
47
|
task: Optional[str]
|
49
48
|
schema_file: str
|
50
49
|
mcp_servers: List[str]
|
50
|
+
|
51
|
+
# New attachment system (T3.0)
|
52
|
+
attaches: List[Dict[str, Any]] # --attach specifications
|
53
|
+
dirs: List[Dict[str, Any]] # --dir specifications
|
54
|
+
collects: List[Dict[str, Any]] # --collect specifications
|
51
55
|
mcp_allowed_tools: List[str]
|
52
56
|
mcp_require_approval: str
|
53
57
|
mcp_headers: Optional[str]
|
54
58
|
code_interpreter_files: FileRoutingResult # Fixed: was List[str]
|
55
59
|
code_interpreter_dirs: List[str]
|
56
|
-
|
57
|
-
|
60
|
+
ci_download_dir: str
|
61
|
+
ci_duplicate_outputs: Optional[str]
|
62
|
+
ci_cleanup: bool
|
58
63
|
file_search_files: FileRoutingResult # Fixed: was List[str]
|
59
64
|
file_search_dirs: List[str]
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
65
|
+
fs_store_name: str
|
66
|
+
fs_cleanup: bool
|
67
|
+
fs_retries: int
|
68
|
+
fs_timeout: float
|
64
69
|
template_files: FileRoutingResult # Fixed: was List[str]
|
65
70
|
template_dirs: List[str]
|
66
71
|
template_file_aliases: List[
|
@@ -75,17 +80,8 @@ class CLIParams(TypedDict, total=False):
|
|
75
80
|
tool_files: List[
|
76
81
|
Tuple[str, str]
|
77
82
|
] # List of (tool, path) tuples from --file-for
|
78
|
-
web_search: bool
|
79
83
|
debug: bool
|
80
|
-
|
81
|
-
debug_templates: bool
|
82
|
-
show_context: bool
|
83
|
-
show_context_detailed: bool
|
84
|
-
show_pre_optimization: bool
|
85
|
-
show_optimization_diff: bool
|
86
|
-
no_optimization: bool
|
87
|
-
show_optimization_steps: bool
|
88
|
-
optimization_step_detail: str
|
84
|
+
|
89
85
|
help_debug: bool
|
90
86
|
enabled_features: List[str] # List of feature names to enable
|
91
87
|
disabled_features: List[str] # List of feature names to disable
|
@@ -0,0 +1,283 @@
|
|
1
|
+
"""Windows Unicode compatibility utilities.
|
2
|
+
|
3
|
+
This module provides smart emoji handling for Windows terminals:
|
4
|
+
- Modern terminals (Windows Terminal, PowerShell, Win11 Console) get full emoji
|
5
|
+
- Legacy terminals (cmd.exe with cp1252) get clean text without emoji
|
6
|
+
- Detection is automatic and graceful with user overrides available
|
7
|
+
|
8
|
+
Environment Variables:
|
9
|
+
- OSTRUCT_UNICODE=auto: Auto-detect terminal capabilities (default)
|
10
|
+
- OSTRUCT_UNICODE=1/true/yes: Force emoji display regardless of detection
|
11
|
+
- OSTRUCT_UNICODE=0/false/no: Force plain text regardless of detection
|
12
|
+
- OSTRUCT_UNICODE=debug: Show detection details and use auto-detection
|
13
|
+
"""
|
14
|
+
|
15
|
+
import os
|
16
|
+
import sys
|
17
|
+
from typing import Any, Optional
|
18
|
+
|
19
|
+
|
20
|
+
def _get_unicode_setting() -> str:
|
21
|
+
"""Get the Unicode setting from environment variable."""
|
22
|
+
return os.environ.get("OSTRUCT_UNICODE", "auto").lower()
|
23
|
+
|
24
|
+
|
25
|
+
def _is_debug_mode() -> bool:
|
26
|
+
"""Check if Unicode debugging is enabled."""
|
27
|
+
return _get_unicode_setting() == "debug"
|
28
|
+
|
29
|
+
|
30
|
+
def _debug_log(message: str) -> None:
|
31
|
+
"""Log debug message if unicode debugging is enabled."""
|
32
|
+
if _is_debug_mode():
|
33
|
+
print(f"[UNICODE DEBUG] {message}", file=sys.stderr)
|
34
|
+
|
35
|
+
|
36
|
+
def _detect_modern_windows_terminal() -> bool:
|
37
|
+
"""Detect if we're running in a modern Windows terminal that supports Unicode.
|
38
|
+
|
39
|
+
Returns:
|
40
|
+
True if running in Windows Terminal, PowerShell, or Win11 Console Host
|
41
|
+
False if running in legacy cmd.exe or other limited terminals
|
42
|
+
"""
|
43
|
+
# Check for explicit user overrides first
|
44
|
+
unicode_setting = _get_unicode_setting()
|
45
|
+
|
46
|
+
if unicode_setting in ("1", "true", "yes", "on"):
|
47
|
+
_debug_log("Unicode forced ON via OSTRUCT_UNICODE")
|
48
|
+
return True
|
49
|
+
|
50
|
+
if unicode_setting in ("0", "false", "no", "off"):
|
51
|
+
_debug_log("Unicode forced OFF via OSTRUCT_UNICODE")
|
52
|
+
return False
|
53
|
+
|
54
|
+
# For "auto" and "debug", proceed with detection
|
55
|
+
|
56
|
+
if not sys.platform.startswith("win"):
|
57
|
+
# Non-Windows systems generally support Unicode well
|
58
|
+
_debug_log(f"Non-Windows platform ({sys.platform}): Unicode enabled")
|
59
|
+
return True
|
60
|
+
|
61
|
+
_debug_log("Windows platform detected, checking terminal capabilities...")
|
62
|
+
|
63
|
+
# Check for Windows Terminal
|
64
|
+
if os.environ.get("WT_SESSION"):
|
65
|
+
_debug_log("Windows Terminal detected via WT_SESSION")
|
66
|
+
return True
|
67
|
+
|
68
|
+
# Check for PowerShell (both Windows PowerShell and PowerShell Core)
|
69
|
+
if os.environ.get("PSModulePath"):
|
70
|
+
_debug_log("PowerShell detected via PSModulePath")
|
71
|
+
return True
|
72
|
+
|
73
|
+
# Check for modern console host (Windows 11+)
|
74
|
+
# The TERMINAL_EMULATOR environment variable is set by modern terminals
|
75
|
+
if os.environ.get("TERMINAL_EMULATOR"):
|
76
|
+
_debug_log(
|
77
|
+
f"Modern terminal detected via TERMINAL_EMULATOR: {os.environ.get('TERMINAL_EMULATOR')}"
|
78
|
+
)
|
79
|
+
return True
|
80
|
+
|
81
|
+
# Check for VS Code integrated terminal
|
82
|
+
if os.environ.get("VSCODE_INJECTION"):
|
83
|
+
_debug_log("VS Code integrated terminal detected")
|
84
|
+
return True
|
85
|
+
|
86
|
+
# Check for GitHub Codespaces
|
87
|
+
if os.environ.get("CODESPACES"):
|
88
|
+
_debug_log("GitHub Codespaces detected")
|
89
|
+
return True
|
90
|
+
|
91
|
+
# Check console code page - UTF-8 indicates modern setup
|
92
|
+
try:
|
93
|
+
import locale
|
94
|
+
|
95
|
+
encoding = locale.getpreferredencoding()
|
96
|
+
if encoding.lower() in ("utf-8", "utf8"):
|
97
|
+
_debug_log(f"UTF-8 locale detected: {encoding}")
|
98
|
+
return True
|
99
|
+
else:
|
100
|
+
_debug_log(f"Non-UTF-8 locale: {encoding}")
|
101
|
+
except Exception as e:
|
102
|
+
_debug_log(f"Locale detection failed: {e}")
|
103
|
+
|
104
|
+
# Check if stdout encoding supports Unicode
|
105
|
+
try:
|
106
|
+
stdout_encoding = getattr(sys.stdout, "encoding", None)
|
107
|
+
if stdout_encoding and stdout_encoding.lower() in ("utf-8", "utf8"):
|
108
|
+
_debug_log(f"UTF-8 stdout encoding: {stdout_encoding}")
|
109
|
+
return True
|
110
|
+
else:
|
111
|
+
_debug_log(f"Non-UTF-8 stdout encoding: {stdout_encoding}")
|
112
|
+
except Exception as e:
|
113
|
+
_debug_log(f"Stdout encoding detection failed: {e}")
|
114
|
+
|
115
|
+
# Check Windows version (Windows 10 1903+ has better Unicode support)
|
116
|
+
try:
|
117
|
+
import platform
|
118
|
+
|
119
|
+
version = platform.version()
|
120
|
+
# Windows 10 build 18362 (1903) and later have better Unicode support
|
121
|
+
if version and "10.0." in version:
|
122
|
+
build = version.split(".")[-1]
|
123
|
+
if build.isdigit() and int(build) >= 18362:
|
124
|
+
_debug_log(f"Modern Windows version detected: {version}")
|
125
|
+
return True
|
126
|
+
_debug_log(f"Windows version: {version}")
|
127
|
+
except Exception as e:
|
128
|
+
_debug_log(f"Windows version detection failed: {e}")
|
129
|
+
|
130
|
+
# Default to False for safety on Windows
|
131
|
+
_debug_log(
|
132
|
+
"No modern terminal indicators found, defaulting to legacy mode"
|
133
|
+
)
|
134
|
+
return False
|
135
|
+
|
136
|
+
|
137
|
+
def safe_emoji(emoji: str, text_without_emoji: Optional[str] = None) -> str:
|
138
|
+
"""Return emoji on Unicode-capable terminals, clean text on legacy terminals.
|
139
|
+
|
140
|
+
Args:
|
141
|
+
emoji: The emoji character to display
|
142
|
+
text_without_emoji: Optional clean text version. If None, emoji is simply omitted.
|
143
|
+
|
144
|
+
Returns:
|
145
|
+
Emoji on modern terminals, clean text on legacy terminals
|
146
|
+
|
147
|
+
Examples:
|
148
|
+
safe_emoji("🚀", "START") -> "🚀" or "START"
|
149
|
+
safe_emoji("🔍") -> "🔍" or ""
|
150
|
+
"""
|
151
|
+
if _detect_modern_windows_terminal():
|
152
|
+
# Modern terminal - try to use emoji
|
153
|
+
try:
|
154
|
+
# Test if we can encode the emoji
|
155
|
+
emoji.encode(sys.stdout.encoding or "utf-8")
|
156
|
+
return emoji
|
157
|
+
except (UnicodeEncodeError, LookupError, AttributeError):
|
158
|
+
# Fallback even on modern terminals if encoding fails
|
159
|
+
return text_without_emoji or ""
|
160
|
+
else:
|
161
|
+
# Legacy terminal - use clean text
|
162
|
+
return text_without_emoji or ""
|
163
|
+
|
164
|
+
|
165
|
+
def safe_format(format_string: str, *args: Any, **kwargs: Any) -> str:
|
166
|
+
"""Format string with emoji safety.
|
167
|
+
|
168
|
+
Processes format strings containing emoji through safe_emoji() automatically.
|
169
|
+
Optimized to skip processing when no emoji are present.
|
170
|
+
|
171
|
+
Args:
|
172
|
+
format_string: Format string that may contain emoji
|
173
|
+
*args, **kwargs: Format arguments
|
174
|
+
|
175
|
+
Returns:
|
176
|
+
Formatted string with emoji handled appropriately for the terminal
|
177
|
+
"""
|
178
|
+
# Quick check: if no emoji characters are present, skip processing entirely
|
179
|
+
# This optimizes the common case of plain text messages
|
180
|
+
# Check for the specific emoji we handle rather than broad Unicode ranges
|
181
|
+
emoji_chars = {
|
182
|
+
"🚀",
|
183
|
+
"🔍",
|
184
|
+
"⚙️",
|
185
|
+
"ℹ️",
|
186
|
+
"📖",
|
187
|
+
"💻",
|
188
|
+
"📄",
|
189
|
+
"🌐",
|
190
|
+
"🕐",
|
191
|
+
"📋",
|
192
|
+
"🤖",
|
193
|
+
"🔒",
|
194
|
+
"🛠️",
|
195
|
+
"📎",
|
196
|
+
"📥",
|
197
|
+
"📊",
|
198
|
+
"💰",
|
199
|
+
"✅",
|
200
|
+
"❌",
|
201
|
+
"⚠️",
|
202
|
+
"⏱️",
|
203
|
+
}
|
204
|
+
if not any(emoji in format_string for emoji in emoji_chars):
|
205
|
+
return format_string.format(*args, **kwargs)
|
206
|
+
|
207
|
+
# Common emoji replacements for CLI output
|
208
|
+
emoji_map = {
|
209
|
+
"🚀": "", # Just omit, the text context is clear
|
210
|
+
"🔍": "", # Just omit, "Plan" or "Execution Plan" is clear
|
211
|
+
"⚙️": "", # Just omit for progress reporting
|
212
|
+
"ℹ️": "", # Just omit for info messages
|
213
|
+
"📖": "", # Just omit for help references
|
214
|
+
"💻": "", # Just omit for Code Interpreter
|
215
|
+
"📄": "", # Just omit for file references
|
216
|
+
"🌐": "", # Just omit for web search
|
217
|
+
"🕐": "", # Just omit for timestamp
|
218
|
+
"📋": "", # Just omit for schema
|
219
|
+
"🤖": "", # Just omit for model
|
220
|
+
"🔒": "", # Just omit for security
|
221
|
+
"🛠️": "", # Just omit for tools
|
222
|
+
"📎": "", # Just omit for attachments
|
223
|
+
"📥": "", # Just omit for downloads
|
224
|
+
"📊": "", # Just omit for variables
|
225
|
+
"💰": "", # Just omit for cost
|
226
|
+
"✅": "[OK]", # Show status indicator
|
227
|
+
"❌": "[ERROR]", # Show status indicator
|
228
|
+
"⚠️": "[WARNING]", # Show status indicator
|
229
|
+
"⏱️": "", # Just omit for timing
|
230
|
+
}
|
231
|
+
|
232
|
+
# Apply emoji safety to the format string
|
233
|
+
safe_format_string = format_string
|
234
|
+
for emoji, fallback in emoji_map.items():
|
235
|
+
if emoji in format_string: # Only process if emoji is actually present
|
236
|
+
safe_format_string = safe_format_string.replace(
|
237
|
+
emoji, safe_emoji(emoji, fallback)
|
238
|
+
)
|
239
|
+
|
240
|
+
# Format with the processed string
|
241
|
+
return safe_format_string.format(*args, **kwargs)
|
242
|
+
|
243
|
+
|
244
|
+
def safe_print(message: str, **print_kwargs: Any) -> None:
|
245
|
+
"""Print message with emoji safety.
|
246
|
+
|
247
|
+
Convenience function for safe printing with automatic emoji handling.
|
248
|
+
|
249
|
+
Args:
|
250
|
+
message: Message to print (may contain emoji)
|
251
|
+
**print_kwargs: Additional arguments passed to print()
|
252
|
+
"""
|
253
|
+
# Fast path for messages without emoji
|
254
|
+
emoji_chars = {
|
255
|
+
"🚀",
|
256
|
+
"🔍",
|
257
|
+
"⚙️",
|
258
|
+
"ℹ️",
|
259
|
+
"📖",
|
260
|
+
"💻",
|
261
|
+
"📄",
|
262
|
+
"🌐",
|
263
|
+
"🕐",
|
264
|
+
"📋",
|
265
|
+
"🤖",
|
266
|
+
"🔒",
|
267
|
+
"🛠️",
|
268
|
+
"📎",
|
269
|
+
"📥",
|
270
|
+
"📊",
|
271
|
+
"💰",
|
272
|
+
"✅",
|
273
|
+
"❌",
|
274
|
+
"⚠️",
|
275
|
+
"⏱️",
|
276
|
+
}
|
277
|
+
if not any(emoji in message for emoji in emoji_chars):
|
278
|
+
print(message, **print_kwargs)
|
279
|
+
return
|
280
|
+
|
281
|
+
# Process emoji for compatibility
|
282
|
+
safe_message = safe_format(message)
|
283
|
+
print(safe_message, **print_kwargs)
|