krons 0.1.1__py3-none-any.whl → 0.2.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.
- krons/__init__.py +49 -0
- krons/agent/__init__.py +144 -0
- krons/agent/mcps/__init__.py +14 -0
- krons/agent/mcps/loader.py +287 -0
- krons/agent/mcps/wrapper.py +799 -0
- krons/agent/message/__init__.py +20 -0
- krons/agent/message/action.py +69 -0
- krons/agent/message/assistant.py +52 -0
- krons/agent/message/common.py +49 -0
- krons/agent/message/instruction.py +130 -0
- krons/agent/message/prepare_msg.py +187 -0
- krons/agent/message/role.py +53 -0
- krons/agent/message/system.py +53 -0
- krons/agent/operations/__init__.py +82 -0
- krons/agent/operations/act.py +100 -0
- krons/agent/operations/generate.py +145 -0
- krons/agent/operations/llm_reparse.py +89 -0
- krons/agent/operations/operate.py +247 -0
- krons/agent/operations/parse.py +243 -0
- krons/agent/operations/react.py +286 -0
- krons/agent/operations/specs.py +235 -0
- krons/agent/operations/structure.py +151 -0
- krons/agent/operations/utils.py +79 -0
- krons/agent/providers/__init__.py +17 -0
- krons/agent/providers/anthropic_messages.py +146 -0
- krons/agent/providers/claude_code.py +276 -0
- krons/agent/providers/gemini.py +268 -0
- krons/agent/providers/match.py +75 -0
- krons/agent/providers/oai_chat.py +174 -0
- krons/agent/third_party/__init__.py +2 -0
- krons/agent/third_party/anthropic_models.py +154 -0
- krons/agent/third_party/claude_code.py +682 -0
- krons/agent/third_party/gemini_models.py +508 -0
- krons/agent/third_party/openai_models.py +295 -0
- krons/agent/tool.py +291 -0
- krons/core/__init__.py +56 -74
- krons/core/base/__init__.py +121 -0
- krons/core/{broadcaster.py → base/broadcaster.py} +7 -3
- krons/core/{element.py → base/element.py} +13 -5
- krons/core/{event.py → base/event.py} +39 -6
- krons/core/{eventbus.py → base/eventbus.py} +3 -1
- krons/core/{flow.py → base/flow.py} +11 -4
- krons/core/{graph.py → base/graph.py} +24 -8
- krons/core/{node.py → base/node.py} +44 -19
- krons/core/{pile.py → base/pile.py} +22 -8
- krons/core/{processor.py → base/processor.py} +21 -7
- krons/core/{progression.py → base/progression.py} +3 -1
- krons/{specs → core/specs}/__init__.py +0 -5
- krons/{specs → core/specs}/adapters/dataclass_field.py +16 -8
- krons/{specs → core/specs}/adapters/pydantic_adapter.py +11 -5
- krons/{specs → core/specs}/adapters/sql_ddl.py +14 -8
- krons/{specs → core/specs}/catalog/__init__.py +2 -2
- krons/{specs → core/specs}/catalog/_audit.py +2 -2
- krons/{specs → core/specs}/catalog/_common.py +2 -2
- krons/{specs → core/specs}/catalog/_content.py +4 -4
- krons/{specs → core/specs}/catalog/_enforcement.py +3 -3
- krons/{specs → core/specs}/factory.py +5 -5
- krons/{specs → core/specs}/operable.py +8 -2
- krons/{specs → core/specs}/protocol.py +4 -2
- krons/{specs → core/specs}/spec.py +23 -11
- krons/{types → core/types}/base.py +4 -2
- krons/{types → core/types}/db_types.py +2 -2
- krons/errors.py +13 -13
- krons/protocols.py +9 -4
- krons/resource/__init__.py +89 -0
- krons/{services → resource}/backend.py +48 -22
- krons/{services → resource}/endpoint.py +28 -14
- krons/{services → resource}/hook.py +20 -7
- krons/{services → resource}/imodel.py +46 -28
- krons/{services → resource}/registry.py +26 -24
- krons/{services → resource}/utilities/rate_limited_executor.py +7 -3
- krons/{services → resource}/utilities/rate_limiter.py +3 -1
- krons/{services → resource}/utilities/resilience.py +15 -5
- krons/resource/utilities/token_calculator.py +185 -0
- krons/session/__init__.py +12 -17
- krons/session/constraints.py +70 -0
- krons/session/exchange.py +11 -3
- krons/session/message.py +3 -1
- krons/session/registry.py +35 -0
- krons/session/session.py +165 -174
- krons/utils/__init__.py +45 -0
- krons/utils/_function_arg_parser.py +99 -0
- krons/utils/_pythonic_function_call.py +249 -0
- krons/utils/_to_list.py +9 -3
- krons/utils/_utils.py +6 -2
- krons/utils/concurrency/_async_call.py +4 -2
- krons/utils/concurrency/_errors.py +3 -1
- krons/utils/concurrency/_patterns.py +3 -1
- krons/utils/concurrency/_resource_tracker.py +6 -2
- krons/utils/display.py +257 -0
- krons/utils/fuzzy/__init__.py +6 -1
- krons/utils/fuzzy/_fuzzy_match.py +14 -8
- krons/utils/fuzzy/_string_similarity.py +3 -1
- krons/utils/fuzzy/_to_dict.py +3 -1
- krons/utils/schemas/__init__.py +26 -0
- krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
- krons/utils/schemas/_formatter.py +72 -0
- krons/utils/schemas/_minimal_yaml.py +151 -0
- krons/utils/schemas/_typescript.py +153 -0
- krons/utils/validators/__init__.py +3 -0
- krons/utils/validators/_validate_image_url.py +56 -0
- krons/work/__init__.py +126 -0
- krons/work/engine.py +333 -0
- krons/work/form.py +305 -0
- krons/{operations → work/operations}/__init__.py +7 -4
- krons/{operations → work/operations}/builder.py +1 -1
- krons/{enforcement → work/operations}/context.py +36 -5
- krons/{operations → work/operations}/flow.py +13 -5
- krons/{operations → work/operations}/node.py +45 -43
- krons/work/operations/registry.py +103 -0
- krons/{specs → work}/phrase.py +130 -13
- krons/{enforcement → work}/policy.py +3 -3
- krons/work/report.py +268 -0
- krons/work/rules/__init__.py +47 -0
- krons/{enforcement → work/rules}/common/boolean.py +3 -1
- krons/{enforcement → work/rules}/common/choice.py +9 -3
- krons/{enforcement → work/rules}/common/number.py +3 -1
- krons/{enforcement → work/rules}/common/string.py +9 -3
- krons/{enforcement → work/rules}/rule.py +1 -1
- krons/{enforcement → work/rules}/validator.py +20 -5
- krons/{enforcement → work}/service.py +16 -7
- krons/work/worker.py +266 -0
- {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/METADATA +15 -1
- krons-0.2.0.dist-info/RECORD +154 -0
- krons/enforcement/__init__.py +0 -57
- krons/operations/registry.py +0 -92
- krons/services/__init__.py +0 -81
- krons-0.1.1.dist-info/RECORD +0 -101
- /krons/{specs → core/specs}/adapters/__init__.py +0 -0
- /krons/{specs → core/specs}/adapters/_utils.py +0 -0
- /krons/{specs → core/specs}/adapters/factory.py +0 -0
- /krons/{types → core/types}/__init__.py +0 -0
- /krons/{types → core/types}/_sentinel.py +0 -0
- /krons/{types → core/types}/identity.py +0 -0
- /krons/{services → resource}/utilities/__init__.py +0 -0
- /krons/{services → resource}/utilities/header_factory.py +0 -0
- /krons/{enforcement → work/rules}/common/__init__.py +0 -0
- /krons/{enforcement → work/rules}/common/mapping.py +0 -0
- /krons/{enforcement → work/rules}/common/model.py +0 -0
- /krons/{enforcement → work/rules}/registry.py +0 -0
- {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/WHEEL +0 -0
- {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -165,7 +165,9 @@ def string_similarity(
|
|
|
165
165
|
raise ValueError(f"Unsupported algorithm: {algorithm}")
|
|
166
166
|
|
|
167
167
|
results = []
|
|
168
|
-
for idx, (orig_word, comp_word) in enumerate(
|
|
168
|
+
for idx, (orig_word, comp_word) in enumerate(
|
|
169
|
+
zip(original_words, compare_words, strict=False)
|
|
170
|
+
):
|
|
169
171
|
if algo_name == "hamming" and len(comp_word) != len(compare_word):
|
|
170
172
|
continue # Hamming requires equal length
|
|
171
173
|
score = score_func(compare_word, comp_word) # type: ignore[operator]
|
krons/utils/fuzzy/_to_dict.py
CHANGED
|
@@ -310,7 +310,9 @@ def _preprocess_recursive(
|
|
|
310
310
|
|
|
311
311
|
if recursive_custom_types:
|
|
312
312
|
with contextlib.suppress(Exception):
|
|
313
|
-
mapped = _object_to_mapping_like(
|
|
313
|
+
mapped = _object_to_mapping_like(
|
|
314
|
+
obj, prioritize_model_dump=prioritize_model_dump
|
|
315
|
+
)
|
|
314
316
|
return _preprocess_recursive(
|
|
315
317
|
mapped,
|
|
316
318
|
depth=depth + 1,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Schema utilities for YAML, TypeScript, and Pydantic processing."""
|
|
5
|
+
|
|
6
|
+
from ._breakdown_pydantic_annotation import (
|
|
7
|
+
breakdown_pydantic_annotation,
|
|
8
|
+
is_pydantic_model,
|
|
9
|
+
)
|
|
10
|
+
from ._formatter import (
|
|
11
|
+
format_clean_multiline_strings,
|
|
12
|
+
format_model_schema,
|
|
13
|
+
format_schema_pretty,
|
|
14
|
+
)
|
|
15
|
+
from ._minimal_yaml import minimal_yaml
|
|
16
|
+
from ._typescript import typescript_schema
|
|
17
|
+
|
|
18
|
+
__all__ = (
|
|
19
|
+
"breakdown_pydantic_annotation",
|
|
20
|
+
"is_pydantic_model",
|
|
21
|
+
"minimal_yaml",
|
|
22
|
+
"typescript_schema",
|
|
23
|
+
"format_model_schema",
|
|
24
|
+
"format_schema_pretty",
|
|
25
|
+
"format_clean_multiline_strings",
|
|
26
|
+
)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from inspect import isclass
|
|
6
|
+
from typing import Any, get_args, get_origin
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
# Pattern to match module-qualified names like __main__.Foo or lionagi.x.y.Bar
|
|
11
|
+
_MODULE_PATTERN = re.compile(r"([a-zA-Z_][a-zA-Z0-9_]*\.)+([a-zA-Z_][a-zA-Z0-9_]*)")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Pattern to extract type name from <class 'typename'>
|
|
15
|
+
_CLASS_PATTERN = re.compile(r"<class '([^']+)'>")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _clean_type_repr(t: Any) -> str:
|
|
19
|
+
"""Convert a type annotation to a clean Python-like string.
|
|
20
|
+
|
|
21
|
+
Handles:
|
|
22
|
+
- <class 'str'> -> "str"
|
|
23
|
+
- <class 'int'> -> "int"
|
|
24
|
+
- Module-qualified names -> just class name
|
|
25
|
+
"""
|
|
26
|
+
s = str(t) if not isinstance(t, str) else t
|
|
27
|
+
|
|
28
|
+
# Handle <class 'typename'> pattern
|
|
29
|
+
if match := _CLASS_PATTERN.match(s):
|
|
30
|
+
type_name = match.group(1)
|
|
31
|
+
# Strip module prefix if present (e.g., 'builtins.str' -> 'str')
|
|
32
|
+
return type_name.rsplit(".", 1)[-1]
|
|
33
|
+
|
|
34
|
+
# Replace module-qualified names with just the class name
|
|
35
|
+
s = _MODULE_PATTERN.sub(r"\2", s)
|
|
36
|
+
|
|
37
|
+
return s
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Default maximum recursion depth for safety
|
|
41
|
+
DEFAULT_MAX_DEPTH = 50
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def breakdown_pydantic_annotation(
|
|
45
|
+
model: type[BaseModel],
|
|
46
|
+
max_depth: int | None = DEFAULT_MAX_DEPTH,
|
|
47
|
+
clean_types: bool = True,
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
"""Break down a Pydantic model's annotations into a nested dict structure.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
model: The Pydantic model class to break down.
|
|
53
|
+
max_depth: Maximum recursion depth for nested models (default: 50).
|
|
54
|
+
Set to None for unlimited depth (not recommended).
|
|
55
|
+
clean_types: If True, convert type annotations to clean strings
|
|
56
|
+
without module prefixes (e.g., 'list[CodeModule]' instead of
|
|
57
|
+
'list[__main__.CodeModule]').
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Dict mapping field names to type representations:
|
|
61
|
+
- Strings for simple types
|
|
62
|
+
- Dicts for nested Pydantic models
|
|
63
|
+
- Lists containing the above for list fields
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
TypeError: If model is not a Pydantic BaseModel subclass.
|
|
67
|
+
RecursionError: If max_depth is exceeded during traversal.
|
|
68
|
+
"""
|
|
69
|
+
result = _breakdown_pydantic_annotation(
|
|
70
|
+
model=model,
|
|
71
|
+
max_depth=max_depth,
|
|
72
|
+
current_depth=0,
|
|
73
|
+
)
|
|
74
|
+
if clean_types:
|
|
75
|
+
return _clean_result(result)
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _clean_result(result: dict[str, Any]) -> dict[str, Any]:
|
|
80
|
+
"""Recursively clean type representations in the result dict."""
|
|
81
|
+
out: dict[str, Any] = {}
|
|
82
|
+
for k, v in result.items():
|
|
83
|
+
if isinstance(v, dict):
|
|
84
|
+
out[k] = _clean_result(v)
|
|
85
|
+
elif isinstance(v, list) and v:
|
|
86
|
+
if isinstance(v[0], dict):
|
|
87
|
+
out[k] = [_clean_result(v[0])]
|
|
88
|
+
else:
|
|
89
|
+
out[k] = [_clean_type_repr(v[0])]
|
|
90
|
+
else:
|
|
91
|
+
out[k] = _clean_type_repr(v)
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _breakdown_pydantic_annotation(
|
|
96
|
+
model: type[BaseModel],
|
|
97
|
+
max_depth: int | None = None,
|
|
98
|
+
current_depth: int = 0,
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
if not is_pydantic_model(model):
|
|
101
|
+
raise TypeError("Input must be a Pydantic model")
|
|
102
|
+
|
|
103
|
+
if max_depth is not None and current_depth >= max_depth:
|
|
104
|
+
raise RecursionError("Maximum recursion depth reached")
|
|
105
|
+
|
|
106
|
+
out: dict[str, Any] = {}
|
|
107
|
+
for k, v in model.__annotations__.items():
|
|
108
|
+
origin = get_origin(v)
|
|
109
|
+
if is_pydantic_model(v):
|
|
110
|
+
out[k] = _breakdown_pydantic_annotation(v, max_depth, current_depth + 1)
|
|
111
|
+
elif origin is list:
|
|
112
|
+
args = get_args(v)
|
|
113
|
+
if args and is_pydantic_model(args[0]):
|
|
114
|
+
out[k] = [
|
|
115
|
+
_breakdown_pydantic_annotation(
|
|
116
|
+
args[0], max_depth, current_depth + 1
|
|
117
|
+
)
|
|
118
|
+
]
|
|
119
|
+
else:
|
|
120
|
+
out[k] = [args[0] if args else Any]
|
|
121
|
+
else:
|
|
122
|
+
out[k] = v
|
|
123
|
+
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def is_pydantic_model(x: Any) -> bool:
|
|
128
|
+
try:
|
|
129
|
+
return isclass(x) and issubclass(x, BaseModel)
|
|
130
|
+
except TypeError:
|
|
131
|
+
return False
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import textwrap
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from ._typescript import typescript_schema
|
|
6
|
+
|
|
7
|
+
__all__ = (
|
|
8
|
+
"format_model_schema",
|
|
9
|
+
"format_schema_pretty",
|
|
10
|
+
"format_clean_multiline_strings",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def format_model_schema(request_model: type[BaseModel]) -> str:
|
|
15
|
+
model_schema = request_model.model_json_schema()
|
|
16
|
+
schema_text = ""
|
|
17
|
+
if defs := model_schema.get("$defs"):
|
|
18
|
+
for def_name, def_schema in defs.items():
|
|
19
|
+
if def_ts := typescript_schema(def_schema):
|
|
20
|
+
schema_text += f"\n{def_name}:\n" + textwrap.indent(def_ts, " ")
|
|
21
|
+
return schema_text
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def format_schema_pretty(schema: dict, indent: int = 0) -> str:
|
|
25
|
+
"""Format schema dict with unquoted Python type values."""
|
|
26
|
+
lines = ["{"]
|
|
27
|
+
items = list(schema.items())
|
|
28
|
+
for i, (key, value) in enumerate(items):
|
|
29
|
+
comma = "," if i < len(items) - 1 else ""
|
|
30
|
+
if isinstance(value, dict):
|
|
31
|
+
nested = format_schema_pretty(value, indent + 4)
|
|
32
|
+
lines.append(f'{" " * (indent + 4)}"{key}": {nested}{comma}')
|
|
33
|
+
elif isinstance(value, list) and value:
|
|
34
|
+
if isinstance(value[0], dict):
|
|
35
|
+
nested = format_schema_pretty(value[0], indent + 4)
|
|
36
|
+
lines.append(f'{" " * (indent + 4)}"{key}": [{nested}]{comma}')
|
|
37
|
+
else:
|
|
38
|
+
lines.append(f'{" " * (indent + 4)}"{key}": [{value[0]}]{comma}')
|
|
39
|
+
else:
|
|
40
|
+
lines.append(f'{" " * (indent + 4)}"{key}": {value}{comma}')
|
|
41
|
+
lines.append(f"{' ' * indent}}}")
|
|
42
|
+
return "\n".join(lines)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def format_clean_multiline_strings(data: dict) -> dict:
|
|
46
|
+
"""Clean multiline strings for YAML block scalars (| not |-)."""
|
|
47
|
+
cleaned: dict[str, object] = {}
|
|
48
|
+
for k, v in data.items():
|
|
49
|
+
if isinstance(v, str) and "\n" in v:
|
|
50
|
+
# Strip trailing whitespace from each line, ensure ends with newline for "|"
|
|
51
|
+
lines = "\n".join(line.rstrip() for line in v.split("\n"))
|
|
52
|
+
cleaned[k] = lines if lines.endswith("\n") else lines + "\n"
|
|
53
|
+
elif isinstance(v, list):
|
|
54
|
+
cleaned[k] = [
|
|
55
|
+
(
|
|
56
|
+
_clean_multiline(item)
|
|
57
|
+
if isinstance(item, str) and "\n" in item
|
|
58
|
+
else item
|
|
59
|
+
)
|
|
60
|
+
for item in v
|
|
61
|
+
]
|
|
62
|
+
elif isinstance(v, dict):
|
|
63
|
+
cleaned[k] = format_clean_multiline_strings(v)
|
|
64
|
+
else:
|
|
65
|
+
cleaned[k] = v
|
|
66
|
+
return cleaned
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _clean_multiline(s: str) -> str:
|
|
70
|
+
"""Clean a multiline string: strip line trailing whitespace, ensure final newline."""
|
|
71
|
+
lines = "\n".join(line.rstrip() for line in s.split("\n"))
|
|
72
|
+
return lines if lines.endswith("\n") else lines + "\n"
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import orjson
|
|
9
|
+
import yaml # type: ignore[import-untyped]
|
|
10
|
+
|
|
11
|
+
__all__ = ("minimal_yaml",)
|
|
12
|
+
|
|
13
|
+
# Maximum recursion depth for pruning to prevent stack overflow
|
|
14
|
+
MAX_PRUNE_DEPTH = 100
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MinimalDumper(yaml.SafeDumper):
|
|
18
|
+
"""YAML dumper with minimal, readable settings."""
|
|
19
|
+
|
|
20
|
+
def ignore_aliases(self, data: Any) -> bool: # type: ignore[override]
|
|
21
|
+
"""Disable anchors/aliases (&id001, *id001) for repeated objects."""
|
|
22
|
+
return True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _represent_str(dumper: yaml.SafeDumper, data: str):
|
|
26
|
+
"""Use block scalars for multiline text; plain style otherwise."""
|
|
27
|
+
if "\n" in data:
|
|
28
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
|
|
29
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
MinimalDumper.add_representer(str, _represent_str)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_empty(x: Any) -> bool:
|
|
36
|
+
"""Define 'empty' for pruning. Keeps 0 and False."""
|
|
37
|
+
if x is None:
|
|
38
|
+
return True
|
|
39
|
+
if isinstance(x, str):
|
|
40
|
+
return x.strip() == ""
|
|
41
|
+
if isinstance(x, dict):
|
|
42
|
+
return len(x) == 0
|
|
43
|
+
if isinstance(x, list | tuple | set):
|
|
44
|
+
return len(x) == 0
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _prune(x: Any, *, _depth: int = 0, _max_depth: int = MAX_PRUNE_DEPTH) -> Any:
|
|
49
|
+
"""Recursively remove empty leaves and empty containers.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
x: Value to prune
|
|
53
|
+
_depth: Current recursion depth (internal use)
|
|
54
|
+
_max_depth: Maximum recursion depth (default: 100)
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Pruned value with empty containers removed
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
RecursionError: If depth exceeds _max_depth
|
|
61
|
+
"""
|
|
62
|
+
if _depth > _max_depth:
|
|
63
|
+
msg = f"Pruning depth exceeds maximum ({_max_depth})"
|
|
64
|
+
raise RecursionError(msg)
|
|
65
|
+
|
|
66
|
+
if isinstance(x, dict):
|
|
67
|
+
pruned = {
|
|
68
|
+
k: _prune(v, _depth=_depth + 1, _max_depth=_max_depth)
|
|
69
|
+
for k, v in x.items()
|
|
70
|
+
if not _is_empty(v)
|
|
71
|
+
}
|
|
72
|
+
return {k: v for k, v in pruned.items() if not _is_empty(v)}
|
|
73
|
+
if isinstance(x, list):
|
|
74
|
+
pruned_list = [
|
|
75
|
+
_prune(v, _depth=_depth + 1, _max_depth=_max_depth)
|
|
76
|
+
for v in x
|
|
77
|
+
if not _is_empty(v)
|
|
78
|
+
]
|
|
79
|
+
return [v for v in pruned_list if not _is_empty(v)]
|
|
80
|
+
if isinstance(x, tuple):
|
|
81
|
+
pruned_list = [
|
|
82
|
+
_prune(v, _depth=_depth + 1, _max_depth=_max_depth)
|
|
83
|
+
for v in x
|
|
84
|
+
if not _is_empty(v)
|
|
85
|
+
]
|
|
86
|
+
return tuple(v for v in pruned_list if not _is_empty(v))
|
|
87
|
+
if isinstance(x, set):
|
|
88
|
+
pruned_set = {
|
|
89
|
+
_prune(v, _depth=_depth + 1, _max_depth=_max_depth)
|
|
90
|
+
for v in x
|
|
91
|
+
if not _is_empty(v)
|
|
92
|
+
}
|
|
93
|
+
return {v for v in pruned_set if not _is_empty(v)}
|
|
94
|
+
return x
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def minimal_yaml(
|
|
98
|
+
value: Any,
|
|
99
|
+
*,
|
|
100
|
+
drop_empties: bool = True,
|
|
101
|
+
indent: int = 2,
|
|
102
|
+
line_width: int = 2**31 - 1,
|
|
103
|
+
sort_keys: bool = False,
|
|
104
|
+
unescape_html: bool = False,
|
|
105
|
+
) -> str:
|
|
106
|
+
"""Convert value to minimal YAML string.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
value: Value to convert (dict, list, or JSON string)
|
|
110
|
+
drop_empties: Remove empty/None values recursively (default: True)
|
|
111
|
+
indent: YAML indentation level (default: 2)
|
|
112
|
+
line_width: Maximum line width (default: unlimited)
|
|
113
|
+
sort_keys: Sort dictionary keys alphabetically (default: False)
|
|
114
|
+
unescape_html: Unescape HTML entities in output (default: False)
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
YAML-formatted string
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
RecursionError: If value nesting exceeds maximum depth (100)
|
|
121
|
+
|
|
122
|
+
Security Note:
|
|
123
|
+
This function only DUMPS (outputs) YAML using SafeDumper - it does
|
|
124
|
+
not load/parse YAML, so it is not vulnerable to YAML deserialization
|
|
125
|
+
attacks. However, if unescape_html=True is used and the output is
|
|
126
|
+
later rendered in HTML without proper escaping, XSS vulnerabilities
|
|
127
|
+
may occur. Use caution with unescape_html in web contexts.
|
|
128
|
+
"""
|
|
129
|
+
# Auto-parse JSON strings for convenience (fails gracefully on invalid JSON)
|
|
130
|
+
if isinstance(value, str):
|
|
131
|
+
try:
|
|
132
|
+
value = orjson.loads(value)
|
|
133
|
+
except orjson.JSONDecodeError:
|
|
134
|
+
# Not valid JSON - treat as plain string
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
data = _prune(value) if drop_empties else value
|
|
138
|
+
str_ = yaml.dump(
|
|
139
|
+
data,
|
|
140
|
+
Dumper=MinimalDumper,
|
|
141
|
+
default_flow_style=False,
|
|
142
|
+
sort_keys=sort_keys,
|
|
143
|
+
allow_unicode=True,
|
|
144
|
+
indent=indent,
|
|
145
|
+
width=line_width,
|
|
146
|
+
)
|
|
147
|
+
if unescape_html:
|
|
148
|
+
import html
|
|
149
|
+
|
|
150
|
+
return html.unescape(str_)
|
|
151
|
+
return str_
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _type_map(json_type: str) -> str:
|
|
6
|
+
"""Map JSON Schema types to TypeScript-like types."""
|
|
7
|
+
mapping = {
|
|
8
|
+
"string": "string",
|
|
9
|
+
"integer": "int",
|
|
10
|
+
"number": "float",
|
|
11
|
+
"boolean": "bool",
|
|
12
|
+
"array": "array", # Will be handled specially
|
|
13
|
+
"object": "object",
|
|
14
|
+
"null": "null",
|
|
15
|
+
}
|
|
16
|
+
return mapping.get(json_type, json_type)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _format_enum_union(enum_values: list) -> str:
|
|
20
|
+
"""Format enum values as TypeScript union of literals."""
|
|
21
|
+
formatted = []
|
|
22
|
+
for val in enum_values:
|
|
23
|
+
if isinstance(val, str):
|
|
24
|
+
formatted.append(f'"{val}"')
|
|
25
|
+
elif val is None:
|
|
26
|
+
formatted.append("null")
|
|
27
|
+
else:
|
|
28
|
+
formatted.append(str(val))
|
|
29
|
+
return " | ".join(formatted)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _extract_type_signature(field_spec: dict, required: bool) -> tuple[str, bool]:
|
|
33
|
+
"""Extract TypeScript-style type signature from JSON Schema field."""
|
|
34
|
+
# Handle enums first (most specific)
|
|
35
|
+
if "enum" in field_spec:
|
|
36
|
+
type_sig = _format_enum_union(field_spec["enum"])
|
|
37
|
+
# Check if enum includes null
|
|
38
|
+
has_null = None in field_spec["enum"]
|
|
39
|
+
is_optional = not required or has_null
|
|
40
|
+
return type_sig, is_optional
|
|
41
|
+
|
|
42
|
+
# Handle anyOf (unions)
|
|
43
|
+
if "anyOf" in field_spec:
|
|
44
|
+
options = field_spec["anyOf"]
|
|
45
|
+
type_parts = []
|
|
46
|
+
has_null = False
|
|
47
|
+
|
|
48
|
+
for opt in options:
|
|
49
|
+
if opt.get("type") == "null":
|
|
50
|
+
has_null = True
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
if "type" in opt:
|
|
54
|
+
if opt["type"] == "array" and "items" in opt:
|
|
55
|
+
item_type = _type_map(opt["items"].get("type", "any"))
|
|
56
|
+
if "$ref" in opt["items"]:
|
|
57
|
+
item_type = opt["items"]["$ref"].split("/")[-1]
|
|
58
|
+
type_parts.append(f"{item_type}[]")
|
|
59
|
+
else:
|
|
60
|
+
type_parts.append(_type_map(opt["type"]))
|
|
61
|
+
elif "$ref" in opt:
|
|
62
|
+
ref_name = opt["$ref"].split("/")[-1]
|
|
63
|
+
type_parts.append(ref_name)
|
|
64
|
+
elif "enum" in opt:
|
|
65
|
+
type_parts.append(_format_enum_union(opt["enum"]))
|
|
66
|
+
|
|
67
|
+
if has_null:
|
|
68
|
+
type_parts.append("null")
|
|
69
|
+
|
|
70
|
+
type_sig = " | ".join(type_parts) if type_parts else "any"
|
|
71
|
+
is_optional = not required or has_null
|
|
72
|
+
return type_sig, is_optional
|
|
73
|
+
|
|
74
|
+
# Handle arrays
|
|
75
|
+
if "type" in field_spec and field_spec["type"] == "array":
|
|
76
|
+
if "items" in field_spec:
|
|
77
|
+
items_spec = field_spec["items"]
|
|
78
|
+
if "type" in items_spec:
|
|
79
|
+
item_type = _type_map(items_spec["type"])
|
|
80
|
+
elif "$ref" in items_spec:
|
|
81
|
+
item_type = items_spec["$ref"].split("/")[-1]
|
|
82
|
+
elif "enum" in items_spec:
|
|
83
|
+
item_type = f"({_format_enum_union(items_spec['enum'])})"
|
|
84
|
+
else:
|
|
85
|
+
item_type = "any"
|
|
86
|
+
else:
|
|
87
|
+
item_type = "any"
|
|
88
|
+
type_sig = f"{item_type}[]"
|
|
89
|
+
is_optional = not required
|
|
90
|
+
return type_sig, is_optional
|
|
91
|
+
|
|
92
|
+
# Handle $ref
|
|
93
|
+
if "$ref" in field_spec:
|
|
94
|
+
ref_name = field_spec["$ref"].split("/")[-1]
|
|
95
|
+
is_optional = not required
|
|
96
|
+
return ref_name, is_optional
|
|
97
|
+
|
|
98
|
+
# Handle simple types
|
|
99
|
+
if "type" in field_spec:
|
|
100
|
+
base_type = field_spec["type"]
|
|
101
|
+
# Handle nullable simple types (type? suffix from simplification)
|
|
102
|
+
if isinstance(base_type, str) and base_type.endswith("?"):
|
|
103
|
+
type_sig = _type_map(base_type[:-1])
|
|
104
|
+
is_optional = True
|
|
105
|
+
else:
|
|
106
|
+
type_sig = _type_map(base_type)
|
|
107
|
+
is_optional = not required
|
|
108
|
+
return type_sig, is_optional
|
|
109
|
+
|
|
110
|
+
# Fallback
|
|
111
|
+
return "any", not required
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def typescript_schema(schema: dict, indent: int = 0) -> str:
|
|
115
|
+
"""Convert JSON Schema to TypeScript-style notation for optimal LLM comprehension."""
|
|
116
|
+
lines = []
|
|
117
|
+
prefix = " " * indent
|
|
118
|
+
|
|
119
|
+
if "properties" not in schema:
|
|
120
|
+
return ""
|
|
121
|
+
|
|
122
|
+
required_fields = set(schema.get("required", []))
|
|
123
|
+
|
|
124
|
+
for field_name, field_spec in schema["properties"].items():
|
|
125
|
+
is_required = field_name in required_fields
|
|
126
|
+
|
|
127
|
+
# Extract type signature
|
|
128
|
+
type_sig, is_optional = _extract_type_signature(field_spec, is_required)
|
|
129
|
+
|
|
130
|
+
# Add default value if present
|
|
131
|
+
default_str = ""
|
|
132
|
+
if "default" in field_spec:
|
|
133
|
+
default_val = field_spec["default"]
|
|
134
|
+
if isinstance(default_val, str):
|
|
135
|
+
default_str = f' = "{default_val}"'
|
|
136
|
+
elif default_val is None:
|
|
137
|
+
default_str = " = null"
|
|
138
|
+
elif isinstance(default_val, bool):
|
|
139
|
+
default_str = f" = {'true' if default_val else 'false'}"
|
|
140
|
+
else:
|
|
141
|
+
default_str = f" = {default_val}"
|
|
142
|
+
|
|
143
|
+
# Build field definition
|
|
144
|
+
optional_marker = "?" if is_optional else ""
|
|
145
|
+
field_def = f"{prefix}{field_name}{optional_marker}: {type_sig}{default_str}"
|
|
146
|
+
|
|
147
|
+
# Add description if present
|
|
148
|
+
if field_spec.get("description"):
|
|
149
|
+
field_def += f" - {field_spec['description']}"
|
|
150
|
+
|
|
151
|
+
lines.append(field_def)
|
|
152
|
+
|
|
153
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from urllib.parse import urlparse
|
|
2
|
+
|
|
3
|
+
__all__ = ("validate_image_url",)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def validate_image_url(url: str) -> None:
|
|
7
|
+
"""Validate image URL to prevent security vulnerabilities.
|
|
8
|
+
|
|
9
|
+
Security checks:
|
|
10
|
+
- Reject null bytes (path truncation attacks)
|
|
11
|
+
- Reject file:// URLs (local file access)
|
|
12
|
+
- Reject javascript: URLs (XSS attacks)
|
|
13
|
+
- Reject data:// URLs (DoS via large embedded images)
|
|
14
|
+
- Only allow http:// and https:// schemes
|
|
15
|
+
- Validate URL format
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
url: URL to validate
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
ValueError: If URL is invalid or uses disallowed scheme
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
if not url or not isinstance(url, str):
|
|
25
|
+
raise ValueError(
|
|
26
|
+
f"Image URL must be non-empty string, got: {type(url).__name__}"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Reject null bytes (path truncation attacks)
|
|
30
|
+
# Check both literal null bytes and percent-encoded %00
|
|
31
|
+
if "\x00" in url:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
"Image URL contains null byte - potential path truncation attack"
|
|
34
|
+
)
|
|
35
|
+
if "%00" in url.lower():
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"Image URL contains percent-encoded null byte (%00) - potential path truncation attack"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
parsed = urlparse(url)
|
|
42
|
+
except Exception as e:
|
|
43
|
+
raise ValueError(f"Malformed image URL '{url}': {e}") from e
|
|
44
|
+
|
|
45
|
+
# Only allow http and https schemes
|
|
46
|
+
if parsed.scheme not in ("http", "https"):
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"Image URL must use http:// or https:// scheme, got: {parsed.scheme}://"
|
|
49
|
+
f"\nRejected URL: {url}"
|
|
50
|
+
f"\nReason: Disallowed schemes (file://, javascript://, data://) pose "
|
|
51
|
+
f"security risks (local file access, XSS, DoS)"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Ensure netloc (domain) is present for http/https
|
|
55
|
+
if not parsed.netloc:
|
|
56
|
+
raise ValueError(f"Image URL missing domain: {url}")
|