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.
Files changed (142) hide show
  1. krons/__init__.py +49 -0
  2. krons/agent/__init__.py +144 -0
  3. krons/agent/mcps/__init__.py +14 -0
  4. krons/agent/mcps/loader.py +287 -0
  5. krons/agent/mcps/wrapper.py +799 -0
  6. krons/agent/message/__init__.py +20 -0
  7. krons/agent/message/action.py +69 -0
  8. krons/agent/message/assistant.py +52 -0
  9. krons/agent/message/common.py +49 -0
  10. krons/agent/message/instruction.py +130 -0
  11. krons/agent/message/prepare_msg.py +187 -0
  12. krons/agent/message/role.py +53 -0
  13. krons/agent/message/system.py +53 -0
  14. krons/agent/operations/__init__.py +82 -0
  15. krons/agent/operations/act.py +100 -0
  16. krons/agent/operations/generate.py +145 -0
  17. krons/agent/operations/llm_reparse.py +89 -0
  18. krons/agent/operations/operate.py +247 -0
  19. krons/agent/operations/parse.py +243 -0
  20. krons/agent/operations/react.py +286 -0
  21. krons/agent/operations/specs.py +235 -0
  22. krons/agent/operations/structure.py +151 -0
  23. krons/agent/operations/utils.py +79 -0
  24. krons/agent/providers/__init__.py +17 -0
  25. krons/agent/providers/anthropic_messages.py +146 -0
  26. krons/agent/providers/claude_code.py +276 -0
  27. krons/agent/providers/gemini.py +268 -0
  28. krons/agent/providers/match.py +75 -0
  29. krons/agent/providers/oai_chat.py +174 -0
  30. krons/agent/third_party/__init__.py +2 -0
  31. krons/agent/third_party/anthropic_models.py +154 -0
  32. krons/agent/third_party/claude_code.py +682 -0
  33. krons/agent/third_party/gemini_models.py +508 -0
  34. krons/agent/third_party/openai_models.py +295 -0
  35. krons/agent/tool.py +291 -0
  36. krons/core/__init__.py +56 -74
  37. krons/core/base/__init__.py +121 -0
  38. krons/core/{broadcaster.py → base/broadcaster.py} +7 -3
  39. krons/core/{element.py → base/element.py} +13 -5
  40. krons/core/{event.py → base/event.py} +39 -6
  41. krons/core/{eventbus.py → base/eventbus.py} +3 -1
  42. krons/core/{flow.py → base/flow.py} +11 -4
  43. krons/core/{graph.py → base/graph.py} +24 -8
  44. krons/core/{node.py → base/node.py} +44 -19
  45. krons/core/{pile.py → base/pile.py} +22 -8
  46. krons/core/{processor.py → base/processor.py} +21 -7
  47. krons/core/{progression.py → base/progression.py} +3 -1
  48. krons/{specs → core/specs}/__init__.py +0 -5
  49. krons/{specs → core/specs}/adapters/dataclass_field.py +16 -8
  50. krons/{specs → core/specs}/adapters/pydantic_adapter.py +11 -5
  51. krons/{specs → core/specs}/adapters/sql_ddl.py +14 -8
  52. krons/{specs → core/specs}/catalog/__init__.py +2 -2
  53. krons/{specs → core/specs}/catalog/_audit.py +2 -2
  54. krons/{specs → core/specs}/catalog/_common.py +2 -2
  55. krons/{specs → core/specs}/catalog/_content.py +4 -4
  56. krons/{specs → core/specs}/catalog/_enforcement.py +3 -3
  57. krons/{specs → core/specs}/factory.py +5 -5
  58. krons/{specs → core/specs}/operable.py +8 -2
  59. krons/{specs → core/specs}/protocol.py +4 -2
  60. krons/{specs → core/specs}/spec.py +23 -11
  61. krons/{types → core/types}/base.py +4 -2
  62. krons/{types → core/types}/db_types.py +2 -2
  63. krons/errors.py +13 -13
  64. krons/protocols.py +9 -4
  65. krons/resource/__init__.py +89 -0
  66. krons/{services → resource}/backend.py +48 -22
  67. krons/{services → resource}/endpoint.py +28 -14
  68. krons/{services → resource}/hook.py +20 -7
  69. krons/{services → resource}/imodel.py +46 -28
  70. krons/{services → resource}/registry.py +26 -24
  71. krons/{services → resource}/utilities/rate_limited_executor.py +7 -3
  72. krons/{services → resource}/utilities/rate_limiter.py +3 -1
  73. krons/{services → resource}/utilities/resilience.py +15 -5
  74. krons/resource/utilities/token_calculator.py +185 -0
  75. krons/session/__init__.py +12 -17
  76. krons/session/constraints.py +70 -0
  77. krons/session/exchange.py +11 -3
  78. krons/session/message.py +3 -1
  79. krons/session/registry.py +35 -0
  80. krons/session/session.py +165 -174
  81. krons/utils/__init__.py +45 -0
  82. krons/utils/_function_arg_parser.py +99 -0
  83. krons/utils/_pythonic_function_call.py +249 -0
  84. krons/utils/_to_list.py +9 -3
  85. krons/utils/_utils.py +6 -2
  86. krons/utils/concurrency/_async_call.py +4 -2
  87. krons/utils/concurrency/_errors.py +3 -1
  88. krons/utils/concurrency/_patterns.py +3 -1
  89. krons/utils/concurrency/_resource_tracker.py +6 -2
  90. krons/utils/display.py +257 -0
  91. krons/utils/fuzzy/__init__.py +6 -1
  92. krons/utils/fuzzy/_fuzzy_match.py +14 -8
  93. krons/utils/fuzzy/_string_similarity.py +3 -1
  94. krons/utils/fuzzy/_to_dict.py +3 -1
  95. krons/utils/schemas/__init__.py +26 -0
  96. krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
  97. krons/utils/schemas/_formatter.py +72 -0
  98. krons/utils/schemas/_minimal_yaml.py +151 -0
  99. krons/utils/schemas/_typescript.py +153 -0
  100. krons/utils/validators/__init__.py +3 -0
  101. krons/utils/validators/_validate_image_url.py +56 -0
  102. krons/work/__init__.py +126 -0
  103. krons/work/engine.py +333 -0
  104. krons/work/form.py +305 -0
  105. krons/{operations → work/operations}/__init__.py +7 -4
  106. krons/{operations → work/operations}/builder.py +1 -1
  107. krons/{enforcement → work/operations}/context.py +36 -5
  108. krons/{operations → work/operations}/flow.py +13 -5
  109. krons/{operations → work/operations}/node.py +45 -43
  110. krons/work/operations/registry.py +103 -0
  111. krons/{specs → work}/phrase.py +130 -13
  112. krons/{enforcement → work}/policy.py +3 -3
  113. krons/work/report.py +268 -0
  114. krons/work/rules/__init__.py +47 -0
  115. krons/{enforcement → work/rules}/common/boolean.py +3 -1
  116. krons/{enforcement → work/rules}/common/choice.py +9 -3
  117. krons/{enforcement → work/rules}/common/number.py +3 -1
  118. krons/{enforcement → work/rules}/common/string.py +9 -3
  119. krons/{enforcement → work/rules}/rule.py +1 -1
  120. krons/{enforcement → work/rules}/validator.py +20 -5
  121. krons/{enforcement → work}/service.py +16 -7
  122. krons/work/worker.py +266 -0
  123. {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/METADATA +15 -1
  124. krons-0.2.0.dist-info/RECORD +154 -0
  125. krons/enforcement/__init__.py +0 -57
  126. krons/operations/registry.py +0 -92
  127. krons/services/__init__.py +0 -81
  128. krons-0.1.1.dist-info/RECORD +0 -101
  129. /krons/{specs → core/specs}/adapters/__init__.py +0 -0
  130. /krons/{specs → core/specs}/adapters/_utils.py +0 -0
  131. /krons/{specs → core/specs}/adapters/factory.py +0 -0
  132. /krons/{types → core/types}/__init__.py +0 -0
  133. /krons/{types → core/types}/_sentinel.py +0 -0
  134. /krons/{types → core/types}/identity.py +0 -0
  135. /krons/{services → resource}/utilities/__init__.py +0 -0
  136. /krons/{services → resource}/utilities/header_factory.py +0 -0
  137. /krons/{enforcement → work/rules}/common/__init__.py +0 -0
  138. /krons/{enforcement → work/rules}/common/mapping.py +0 -0
  139. /krons/{enforcement → work/rules}/common/model.py +0 -0
  140. /krons/{enforcement → work/rules}/registry.py +0 -0
  141. {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/WHEEL +0 -0
  142. {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(zip(original_words, compare_words, strict=False)):
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]
@@ -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(obj, prioritize_model_dump=prioritize_model_dump)
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,3 @@
1
+ from ._validate_image_url import validate_image_url
2
+
3
+ __all__ = ("validate_image_url",)
@@ -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}")