krons 0.1.0__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 (162) 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 +127 -0
  37. krons/core/base/__init__.py +121 -0
  38. {kronos/core → krons/core/base}/broadcaster.py +7 -3
  39. {kronos/core → krons/core/base}/element.py +15 -7
  40. {kronos/core → krons/core/base}/event.py +41 -8
  41. {kronos/core → krons/core/base}/eventbus.py +4 -2
  42. {kronos/core → krons/core/base}/flow.py +14 -7
  43. {kronos/core → krons/core/base}/graph.py +27 -11
  44. {kronos/core → krons/core/base}/node.py +47 -22
  45. {kronos/core → krons/core/base}/pile.py +26 -12
  46. {kronos/core → krons/core/base}/processor.py +23 -9
  47. {kronos/core → krons/core/base}/progression.py +5 -3
  48. {kronos → krons/core}/specs/__init__.py +0 -5
  49. {kronos → krons/core}/specs/adapters/dataclass_field.py +16 -8
  50. {kronos → krons/core}/specs/adapters/pydantic_adapter.py +11 -5
  51. {kronos → krons/core}/specs/adapters/sql_ddl.py +16 -10
  52. {kronos → krons/core}/specs/catalog/__init__.py +2 -2
  53. {kronos → krons/core}/specs/catalog/_audit.py +3 -3
  54. {kronos → krons/core}/specs/catalog/_common.py +2 -2
  55. {kronos → krons/core}/specs/catalog/_content.py +5 -5
  56. {kronos → krons/core}/specs/catalog/_enforcement.py +4 -4
  57. {kronos → krons/core}/specs/factory.py +7 -7
  58. {kronos → krons/core}/specs/operable.py +9 -3
  59. {kronos → krons/core}/specs/protocol.py +4 -2
  60. {kronos → krons/core}/specs/spec.py +25 -13
  61. {kronos → krons/core}/types/base.py +7 -5
  62. {kronos → krons/core}/types/db_types.py +2 -2
  63. {kronos → krons/core}/types/identity.py +1 -1
  64. {kronos → krons}/errors.py +13 -13
  65. {kronos → krons}/protocols.py +9 -4
  66. krons/resource/__init__.py +89 -0
  67. {kronos/services → krons/resource}/backend.py +50 -24
  68. {kronos/services → krons/resource}/endpoint.py +28 -14
  69. {kronos/services → krons/resource}/hook.py +22 -9
  70. {kronos/services → krons/resource}/imodel.py +50 -32
  71. {kronos/services → krons/resource}/registry.py +27 -25
  72. {kronos/services → krons/resource}/utilities/rate_limited_executor.py +10 -6
  73. {kronos/services → krons/resource}/utilities/rate_limiter.py +4 -2
  74. {kronos/services → krons/resource}/utilities/resilience.py +17 -7
  75. krons/resource/utilities/token_calculator.py +185 -0
  76. {kronos → krons}/session/__init__.py +12 -17
  77. krons/session/constraints.py +70 -0
  78. {kronos → krons}/session/exchange.py +14 -6
  79. {kronos → krons}/session/message.py +4 -2
  80. krons/session/registry.py +35 -0
  81. {kronos → krons}/session/session.py +165 -174
  82. krons/utils/__init__.py +85 -0
  83. krons/utils/_function_arg_parser.py +99 -0
  84. krons/utils/_pythonic_function_call.py +249 -0
  85. {kronos → krons}/utils/_to_list.py +9 -3
  86. {kronos → krons}/utils/_utils.py +9 -5
  87. {kronos → krons}/utils/concurrency/__init__.py +38 -38
  88. {kronos → krons}/utils/concurrency/_async_call.py +6 -4
  89. {kronos → krons}/utils/concurrency/_errors.py +3 -1
  90. {kronos → krons}/utils/concurrency/_patterns.py +3 -1
  91. {kronos → krons}/utils/concurrency/_resource_tracker.py +6 -2
  92. krons/utils/display.py +257 -0
  93. {kronos → krons}/utils/fuzzy/__init__.py +6 -1
  94. {kronos → krons}/utils/fuzzy/_fuzzy_match.py +14 -8
  95. {kronos → krons}/utils/fuzzy/_string_similarity.py +3 -1
  96. {kronos → krons}/utils/fuzzy/_to_dict.py +3 -1
  97. krons/utils/schemas/__init__.py +26 -0
  98. krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
  99. krons/utils/schemas/_formatter.py +72 -0
  100. krons/utils/schemas/_minimal_yaml.py +151 -0
  101. krons/utils/schemas/_typescript.py +153 -0
  102. {kronos → krons}/utils/sql/_sql_validation.py +1 -1
  103. krons/utils/validators/__init__.py +3 -0
  104. krons/utils/validators/_validate_image_url.py +56 -0
  105. krons/work/__init__.py +126 -0
  106. krons/work/engine.py +333 -0
  107. krons/work/form.py +305 -0
  108. {kronos → krons/work}/operations/__init__.py +7 -4
  109. {kronos → krons/work}/operations/builder.py +4 -4
  110. {kronos/enforcement → krons/work/operations}/context.py +37 -6
  111. {kronos → krons/work}/operations/flow.py +17 -9
  112. krons/work/operations/node.py +103 -0
  113. krons/work/operations/registry.py +103 -0
  114. {kronos/specs → krons/work}/phrase.py +131 -14
  115. {kronos/enforcement → krons/work}/policy.py +3 -3
  116. krons/work/report.py +268 -0
  117. krons/work/rules/__init__.py +47 -0
  118. {kronos/enforcement → krons/work/rules}/common/boolean.py +3 -1
  119. {kronos/enforcement → krons/work/rules}/common/choice.py +9 -3
  120. {kronos/enforcement → krons/work/rules}/common/number.py +3 -1
  121. {kronos/enforcement → krons/work/rules}/common/string.py +9 -3
  122. {kronos/enforcement → krons/work/rules}/rule.py +2 -2
  123. {kronos/enforcement → krons/work/rules}/validator.py +21 -6
  124. {kronos/enforcement → krons/work}/service.py +16 -7
  125. krons/work/worker.py +266 -0
  126. {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/METADATA +19 -5
  127. krons-0.2.0.dist-info/RECORD +154 -0
  128. kronos/core/__init__.py +0 -145
  129. kronos/enforcement/__init__.py +0 -57
  130. kronos/operations/node.py +0 -101
  131. kronos/operations/registry.py +0 -92
  132. kronos/services/__init__.py +0 -81
  133. kronos/specs/adapters/__init__.py +0 -0
  134. kronos/utils/__init__.py +0 -40
  135. krons-0.1.0.dist-info/RECORD +0 -101
  136. {kronos → krons/core/specs/adapters}/__init__.py +0 -0
  137. {kronos → krons/core}/specs/adapters/_utils.py +0 -0
  138. {kronos → krons/core}/specs/adapters/factory.py +0 -0
  139. {kronos → krons/core}/types/__init__.py +0 -0
  140. {kronos → krons/core}/types/_sentinel.py +0 -0
  141. {kronos → krons}/py.typed +0 -0
  142. {kronos/services → krons/resource}/utilities/__init__.py +0 -0
  143. {kronos/services → krons/resource}/utilities/header_factory.py +0 -0
  144. {kronos → krons}/utils/_hash.py +0 -0
  145. {kronos → krons}/utils/_json_dump.py +0 -0
  146. {kronos → krons}/utils/_lazy_init.py +0 -0
  147. {kronos → krons}/utils/_to_num.py +0 -0
  148. {kronos → krons}/utils/concurrency/_cancel.py +0 -0
  149. {kronos → krons}/utils/concurrency/_primitives.py +0 -0
  150. {kronos → krons}/utils/concurrency/_priority_queue.py +0 -0
  151. {kronos → krons}/utils/concurrency/_run_async.py +0 -0
  152. {kronos → krons}/utils/concurrency/_task.py +0 -0
  153. {kronos → krons}/utils/concurrency/_utils.py +0 -0
  154. {kronos → krons}/utils/fuzzy/_extract_json.py +0 -0
  155. {kronos → krons}/utils/fuzzy/_fuzzy_json.py +0 -0
  156. {kronos → krons}/utils/sql/__init__.py +0 -0
  157. {kronos/enforcement → krons/work/rules}/common/__init__.py +0 -0
  158. {kronos/enforcement → krons/work/rules}/common/mapping.py +0 -0
  159. {kronos/enforcement → krons/work/rules}/common/model.py +0 -0
  160. {kronos/enforcement → krons/work/rules}/registry.py +0 -0
  161. {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/WHEEL +0 -0
  162. {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -64,7 +64,9 @@ class LeakTracker:
64
64
  name: Identifier (defaults to "obj-{id}").
65
65
  kind: Optional category label.
66
66
  """
67
- info = LeakInfo(name=name or f"obj-{id(obj)}", kind=kind, created_at=time.time())
67
+ info = LeakInfo(
68
+ name=name or f"obj-{id(obj)}", kind=kind, created_at=time.time()
69
+ )
68
70
  key = id(obj)
69
71
 
70
72
  def _finalizer(_key: int = key) -> None:
@@ -94,7 +96,9 @@ class LeakTracker:
94
96
  _TRACKER = LeakTracker()
95
97
 
96
98
 
97
- def track_resource(obj: object, name: str | None = None, kind: str | None = None) -> None:
99
+ def track_resource(
100
+ obj: object, name: str | None = None, kind: str | None = None
101
+ ) -> None:
98
102
  """Track an object using the global leak tracker.
99
103
 
100
104
  Args:
krons/utils/display.py ADDED
@@ -0,0 +1,257 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Display utilities for verbose operation tracking.
5
+
6
+ Ported from lionagi's as_readable system. Provides Rich-based
7
+ console output with YAML/JSON syntax highlighting, environment
8
+ detection, and truncation support.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+ import time
16
+ from typing import Any
17
+
18
+ try:
19
+ from rich.align import Align
20
+ from rich.box import ROUNDED
21
+ from rich.console import Console
22
+ from rich.padding import Padding
23
+ from rich.panel import Panel
24
+ from rich.syntax import Syntax
25
+ from rich.theme import Theme
26
+
27
+ DARK_THEME = Theme(
28
+ {
29
+ "info": "bright_cyan",
30
+ "warning": "bright_yellow",
31
+ "error": "bold bright_red",
32
+ "success": "bold bright_green",
33
+ "panel.border": "bright_blue",
34
+ "panel.title": "bold bright_cyan",
35
+ "json.key": "bright_cyan",
36
+ "json.string": "bright_green",
37
+ "json.number": "bright_yellow",
38
+ "json.boolean": "bright_magenta",
39
+ "json.null": "bright_red",
40
+ "yaml.key": "bright_cyan",
41
+ "yaml.string": "bright_green",
42
+ "yaml.number": "bright_yellow",
43
+ "yaml.boolean": "bright_magenta",
44
+ }
45
+ )
46
+ RICH_AVAILABLE = True
47
+ except ImportError:
48
+ RICH_AVAILABLE = False
49
+ DARK_THEME = None
50
+
51
+ _console: Console | None = None
52
+
53
+
54
+ def _get_console() -> Console | None:
55
+ global _console
56
+ if not RICH_AVAILABLE:
57
+ return None
58
+ if _console is None:
59
+ _console = Console(theme=DARK_THEME)
60
+ return _console
61
+
62
+
63
+ def in_notebook() -> bool:
64
+ """Check if running inside a Jupyter notebook."""
65
+ try:
66
+ from IPython import get_ipython
67
+
68
+ shell = get_ipython().__class__.__name__
69
+ return "ZMQInteractiveShell" in shell
70
+ except Exception:
71
+ return False
72
+
73
+
74
+ def in_console() -> bool:
75
+ """Check if running in a terminal with TTY."""
76
+ return hasattr(sys.stdout, "isatty") and sys.stdout.isatty() and not in_notebook()
77
+
78
+
79
+ def format_dict(data: Any, indent: int = 0) -> str:
80
+ """Format data as YAML-like readable string."""
81
+ lines = []
82
+ prefix = " " * indent
83
+
84
+ if isinstance(data, dict):
85
+ for key, value in data.items():
86
+ if isinstance(value, dict):
87
+ lines.append(f"{prefix}{key}:")
88
+ lines.append(format_dict(value, indent + 1))
89
+ elif isinstance(value, list):
90
+ lines.append(f"{prefix}{key}:")
91
+ for item in value:
92
+ item_str = format_dict(item, indent + 2).lstrip()
93
+ lines.append(f"{prefix} - {item_str}")
94
+ elif isinstance(value, str) and "\n" in value:
95
+ lines.append(f"{prefix}{key}: |")
96
+ subprefix = " " * (indent + 1)
97
+ for line in value.splitlines():
98
+ lines.append(f"{subprefix}{line}")
99
+ else:
100
+ item_str = format_dict(value, indent + 1).lstrip()
101
+ lines.append(f"{prefix}{key}: {item_str}")
102
+ return "\n".join(lines)
103
+
104
+ elif isinstance(data, list):
105
+ for item in data:
106
+ item_str = format_dict(item, indent + 1).lstrip()
107
+ lines.append(f"{prefix}- {item_str}")
108
+ return "\n".join(lines)
109
+
110
+ return prefix + str(data)
111
+
112
+
113
+ def as_readable(
114
+ input_: Any,
115
+ /,
116
+ *,
117
+ md: bool = False,
118
+ format_curly: bool = True,
119
+ max_chars: int | None = None,
120
+ ) -> str:
121
+ """Convert data to human-readable string.
122
+
123
+ Args:
124
+ input_: Data to format (dict, model, list, etc.).
125
+ md: Wrap in code fences for markdown.
126
+ format_curly: YAML-like (True) or JSON (False).
127
+ max_chars: Truncate output.
128
+
129
+ Returns:
130
+ Formatted string.
131
+ """
132
+ from krons.utils.fuzzy import to_dict
133
+
134
+ # Convert to dict
135
+ def safe_dict(obj: Any) -> Any:
136
+ try:
137
+ return to_dict(
138
+ obj,
139
+ use_model_dump=True,
140
+ fuzzy_parse=True,
141
+ recursive=True,
142
+ recursive_python_only=False,
143
+ max_recursive_depth=5,
144
+ )
145
+ except Exception:
146
+ return str(obj)
147
+
148
+ if isinstance(input_, list):
149
+ items = [safe_dict(x) for x in input_]
150
+ else:
151
+ maybe = safe_dict(input_)
152
+ items = maybe if isinstance(maybe, list) else [maybe]
153
+
154
+ rendered = []
155
+ for item in items:
156
+ if format_curly:
157
+ rendered.append(
158
+ format_dict(item) if isinstance(item, (dict, list)) else str(item)
159
+ )
160
+ else:
161
+ try:
162
+ rendered.append(json.dumps(item, indent=2, ensure_ascii=False))
163
+ except Exception:
164
+ rendered.append(str(item))
165
+
166
+ text = "\n\n".join(rendered).strip()
167
+
168
+ if md:
169
+ lang = "yaml" if format_curly else "json"
170
+ text = f"```{lang}\n{text}\n```"
171
+
172
+ if max_chars and len(text) > max_chars:
173
+ text = text[:max_chars] + "...\n\n[Truncated]"
174
+
175
+ return text
176
+
177
+
178
+ def display(text: str, *, title: str | None = None, lang: str = "yaml") -> None:
179
+ """Display text with Rich formatting if available.
180
+
181
+ Args:
182
+ text: Text to display.
183
+ title: Optional panel title.
184
+ lang: Syntax language for highlighting.
185
+ """
186
+ console = _get_console()
187
+
188
+ if console and in_console():
189
+ syntax = Syntax(
190
+ text,
191
+ lang,
192
+ theme="github-dark",
193
+ line_numbers=False,
194
+ word_wrap=True,
195
+ background_color="default",
196
+ )
197
+ content = syntax
198
+ if title:
199
+ content = Panel(
200
+ Align.left(syntax, pad=False),
201
+ title=title,
202
+ title_align="left",
203
+ border_style="panel.border",
204
+ box=ROUNDED,
205
+ width=min(console.width - 4, 140),
206
+ expand=False,
207
+ )
208
+ console.print(Padding(content, (0, 0, 0, 2)))
209
+ else:
210
+ console.print(Padding(content, (0, 0, 0, 2)))
211
+ return
212
+
213
+ # Fallback
214
+ if title:
215
+ print(f"\n--- {title} ---")
216
+ print(text)
217
+
218
+
219
+ def status(msg: str, *, style: str = "info") -> None:
220
+ """Print a status message with optional Rich styling.
221
+
222
+ Args:
223
+ msg: Status message.
224
+ style: Rich style name (info, success, warning, error).
225
+ """
226
+ console = _get_console()
227
+ if console and in_console():
228
+ console.print(f" [{style}]{msg}[/{style}]")
229
+ else:
230
+ print(f" {msg}")
231
+
232
+
233
+ def phase(title: str) -> None:
234
+ """Print a phase header."""
235
+ console = _get_console()
236
+ if console and in_console():
237
+ console.print(f"\n[bold bright_cyan]=== {title} ===[/bold bright_cyan]")
238
+ else:
239
+ print(f"\n=== {title} ===")
240
+
241
+
242
+ class Timer:
243
+ """Simple context manager for timing operations."""
244
+
245
+ def __init__(self, label: str = ""):
246
+ self.label = label
247
+ self.start = 0.0
248
+ self.elapsed = 0.0
249
+
250
+ def __enter__(self):
251
+ self.start = time.monotonic()
252
+ return self
253
+
254
+ def __exit__(self, *args):
255
+ self.elapsed = time.monotonic() - self.start
256
+ if self.label:
257
+ status(f"{self.label}: {self.elapsed:.1f}s", style="success")
@@ -1,14 +1,19 @@
1
1
  from ._extract_json import extract_json
2
2
  from ._fuzzy_json import fuzzy_json
3
- from ._fuzzy_match import fuzzy_match_keys
3
+ from ._fuzzy_match import HandleUnmatched, fuzzy_match_keys
4
4
  from ._string_similarity import SimilarityAlgo, string_similarity
5
5
  from ._to_dict import to_dict
6
6
 
7
+ # Alias for backward compatibility
8
+ fuzzy_validate_mapping = fuzzy_match_keys
9
+
7
10
  __all__ = (
8
11
  "extract_json",
9
12
  "fuzzy_json",
10
13
  "fuzzy_match_keys",
14
+ "fuzzy_validate_mapping",
11
15
  "string_similarity",
12
16
  "SimilarityAlgo",
13
17
  "to_dict",
18
+ "HandleUnmatched",
14
19
  )
@@ -1,18 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from enum import Enum
4
- from typing import TYPE_CHECKING, Any, Literal
4
+ from typing import TYPE_CHECKING, Any
5
5
 
6
6
  if TYPE_CHECKING:
7
- from kronos.types import KeysLike
7
+ from krons.core.types import KeysLike
8
8
 
9
- from kronos.types._sentinel import Unset
9
+ from krons.core.types._sentinel import Unset
10
10
 
11
11
  from ._string_similarity import SimilarityAlgo, string_similarity
12
12
 
13
- __all__ = ("fuzzy_match_keys",)
14
-
15
- HandleUnmatched = Literal["ignore", "raise", "remove", "fill", "force"]
13
+ __all__ = ("fuzzy_match_keys", "HandleUnmatched")
16
14
 
17
15
 
18
16
  class HandleUnmatched(Enum):
@@ -114,7 +112,13 @@ def fuzzy_match_keys(
114
112
  )
115
113
 
116
114
  if matches:
117
- match = matches if isinstance(matches, str) else matches[0] if matches else None
115
+ match = (
116
+ matches
117
+ if isinstance(matches, str)
118
+ else matches[0]
119
+ if matches
120
+ else None
121
+ )
118
122
  if match:
119
123
  corrected_out[match] = d_[key]
120
124
  matched_expected.add(match)
@@ -136,7 +140,9 @@ def fuzzy_match_keys(
136
140
  elif handle_unmatched in (HandleUnmatched.FILL, HandleUnmatched.FORCE):
137
141
  for key in unmatched_expected:
138
142
  corrected_out[key] = (
139
- fill_mapping[key] if fill_mapping and key in fill_mapping else fill_value
143
+ fill_mapping[key]
144
+ if fill_mapping and key in fill_mapping
145
+ else fill_value
140
146
  )
141
147
 
142
148
  if handle_unmatched == HandleUnmatched.FILL:
@@ -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"