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
@@ -0,0 +1,249 @@
1
+ # Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Function call parser with khive-mcp extensions for unified tool paradigm.
5
+
6
+ Core parsing with support for:
7
+ - Service namespacing: cognition.remember_episodic(...)
8
+ - Batch parsing: [call1(...), call2(...)]
9
+ - Reserved keyword handling: from= -> from_= (Python keywords as args)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ast
15
+ import re
16
+ from typing import Any
17
+
18
+ # Python reserved keywords that might be used as field names
19
+ # These get mapped to underscore versions for parsing
20
+ RESERVED_KEYWORDS = {
21
+ "from",
22
+ "import",
23
+ "class",
24
+ "def",
25
+ "return",
26
+ "yield",
27
+ "async",
28
+ "await",
29
+ }
30
+
31
+ # Regex to match keyword arguments with reserved names
32
+ # Matches: from="value" or from='value' at word boundary
33
+ _RESERVED_KWARG_PATTERN = re.compile(
34
+ r"\b(" + "|".join(RESERVED_KEYWORDS) + r")\s*=", re.MULTILINE
35
+ )
36
+
37
+ __all__ = (
38
+ "parse_function_call",
39
+ "parse_batch_function_calls",
40
+ )
41
+
42
+
43
+ def _escape_reserved_keywords(call_str: str) -> str:
44
+ """Escape Python reserved keywords used as argument names.
45
+
46
+ Converts `from=` to `from_=` so ast.parse can handle it.
47
+ The underscore version is what Pydantic expects for aliased fields.
48
+
49
+ Args:
50
+ call_str: Function call string that may contain reserved keywords
51
+
52
+ Returns:
53
+ String with reserved keywords escaped
54
+ """
55
+ return _RESERVED_KWARG_PATTERN.sub(r"\1_=", call_str)
56
+
57
+
58
+ def _ast_to_value(node: ast.AST) -> Any:
59
+ """Convert AST node to Python value with recursive dict/list processing.
60
+
61
+ Handles nested dicts, lists, tuples, and JSON-style literals (true/false/null).
62
+ Normalizes JSON literals: true->True, false->False, null->None.
63
+
64
+ Args:
65
+ node: AST node to convert
66
+
67
+ Returns:
68
+ Python value
69
+
70
+ Raises:
71
+ ValueError: If node cannot be converted to a value
72
+ """
73
+ # Handle JSON-style boolean/null names: true, false, null
74
+ if isinstance(node, ast.Name):
75
+ if node.id in ("true", "false", "null"):
76
+ return {"true": True, "false": False, "null": None}[node.id]
77
+ raise ValueError(f"Name '{node.id}' is not a valid literal")
78
+
79
+ # Handle dict nodes: {key1: val1, key2: val2, ...}
80
+ if isinstance(node, ast.Dict):
81
+ return {
82
+ _ast_to_value(k): _ast_to_value(v) for k, v in zip(node.keys, node.values)
83
+ }
84
+
85
+ # Handle list nodes: [elem1, elem2, ...]
86
+ if isinstance(node, ast.List):
87
+ return [_ast_to_value(elem) for elem in node.elts]
88
+
89
+ # Handle tuple nodes: (elem1, elem2, ...)
90
+ if isinstance(node, ast.Tuple):
91
+ return tuple(_ast_to_value(elem) for elem in node.elts)
92
+
93
+ # Handle simple literals (str, int, float, bool, None) via ast.literal_eval
94
+ try:
95
+ return ast.literal_eval(node)
96
+ except (ValueError, TypeError) as e:
97
+ raise ValueError(f"Cannot convert AST node: {type(node).__name__}") from e
98
+
99
+
100
+ def parse_function_call(call_str: str) -> dict[str, Any]:
101
+ """Parse Python function call syntax into unified tool format.
102
+
103
+ Supports service namespacing for unified tool paradigm:
104
+ - Simple: search("query") -> {operation: "search", arguments: {...}}
105
+ - Namespaced: cognition.remember("...") -> {service: "cognition", ...}
106
+ - Deep: recall.search("...") -> {service: "recall", operation: "search", ...}
107
+
108
+ Examples:
109
+ >>> parse_function_call('search("AI news")')
110
+ {'operation': 'search', 'arguments': {'query': 'AI news'}}
111
+
112
+ >>> parse_function_call('cognition.remember_episodic(content="...")')
113
+ {'service': 'cognition', 'operation': 'remember_episodic', 'arguments': {'content': '...'}}
114
+
115
+ Args:
116
+ call_str: Python function call as string
117
+
118
+ Returns:
119
+ Dict with 'operation', optional 'service', and 'arguments' keys
120
+ Legacy 'tool' key also included for backward compatibility
121
+
122
+ Raises:
123
+ ValueError: If the string is not a valid function call
124
+ """
125
+ try:
126
+ # Escape reserved keywords before parsing (e.g., from= -> from_=)
127
+ escaped_str = _escape_reserved_keywords(call_str)
128
+
129
+ # Parse the call as a Python expression
130
+ tree = ast.parse(escaped_str, mode="eval")
131
+ call = tree.body
132
+
133
+ if not isinstance(call, ast.Call):
134
+ raise ValueError("Not a function call")
135
+
136
+ # Extract function name and service namespace
137
+ service = None
138
+ operation = None
139
+
140
+ if isinstance(call.func, ast.Name):
141
+ # Simple call: search(...)
142
+ operation = call.func.id
143
+ elif isinstance(call.func, ast.Attribute):
144
+ # Namespaced call: cognition.remember(...) or recall.search(...)
145
+ operation = call.func.attr
146
+
147
+ # Walk up the attribute chain to get service name
148
+ node = call.func.value
149
+ if isinstance(node, ast.Name):
150
+ service = node.id
151
+ elif isinstance(node, ast.Attribute):
152
+ # Multi-level: could be module.service.operation
153
+ # For now, take the last attribute as service
154
+ service = node.attr
155
+ else:
156
+ raise ValueError(f"Unsupported function type: {type(call.func)}")
157
+
158
+ # Extract arguments
159
+ arguments = {}
160
+
161
+ # Positional arguments (will be mapped by parameter order in schema)
162
+ for i, arg in enumerate(call.args):
163
+ # For now, use position-based keys; will be mapped to param names later
164
+ arguments[f"_pos_{i}"] = _ast_to_value(arg)
165
+
166
+ # Keyword arguments
167
+ for keyword in call.keywords:
168
+ if keyword.arg is None:
169
+ # **kwargs syntax
170
+ raise ValueError("**kwargs not supported")
171
+ arguments[keyword.arg] = _ast_to_value(keyword.value)
172
+
173
+ # Build result with new unified format
174
+ result = {
175
+ "operation": operation,
176
+ "arguments": arguments,
177
+ "tool": operation, # Backward compatibility
178
+ }
179
+
180
+ if service:
181
+ result["service"] = service
182
+
183
+ return result
184
+
185
+ except (SyntaxError, ValueError) as e:
186
+ raise ValueError(f"Invalid function call syntax: {e}") from e
187
+
188
+
189
+ def parse_batch_function_calls(batch_str: str) -> list[dict[str, Any]]:
190
+ """Parse batch function calls (array of function calls).
191
+
192
+ Supports:
193
+ - Same service batch: [remember(...), recall(...)]
194
+ - Cross-service batch: [cognition.remember(...), waves.check_in()]
195
+
196
+ Examples:
197
+ >>> parse_batch_function_calls('[search("A"), search("B")]')
198
+ [
199
+ {'operation': 'search', 'arguments': {'query': 'A'}},
200
+ {'operation': 'search', 'arguments': {'query': 'B'}}
201
+ ]
202
+
203
+ >>> parse_batch_function_calls('[cognition.remember(...), waves.check_in()]')
204
+ [
205
+ {'service': 'cognition', 'operation': 'remember', 'arguments': {...}},
206
+ {'service': 'waves', 'operation': 'check_in', 'arguments': {}}
207
+ ]
208
+
209
+ Args:
210
+ batch_str: String containing array of function calls
211
+
212
+ Returns:
213
+ List of parsed function call dicts
214
+
215
+ Raises:
216
+ ValueError: If the string is not a valid array of function calls
217
+ """
218
+ try:
219
+ # Remove whitespace for easier parsing
220
+ batch_str = batch_str.strip()
221
+
222
+ # Must start with [ and end with ]
223
+ if not (batch_str.startswith("[") and batch_str.endswith("]")):
224
+ raise ValueError("Batch call must be enclosed in [ ]")
225
+
226
+ # Escape reserved keywords before parsing (e.g., from= -> from_=)
227
+ escaped_str = _escape_reserved_keywords(batch_str)
228
+
229
+ # Parse as Python list expression
230
+ tree = ast.parse(escaped_str, mode="eval")
231
+ if not isinstance(tree.body, ast.List):
232
+ raise ValueError("Not a list expression")
233
+
234
+ results = []
235
+ for element in tree.body.elts:
236
+ if not isinstance(element, ast.Call):
237
+ raise ValueError(
238
+ f"List element is not a function call: {ast.dump(element)}"
239
+ )
240
+
241
+ # Convert the Call node back to source code and parse it
242
+ call_str = ast.unparse(element)
243
+ parsed = parse_function_call(call_str)
244
+ results.append(parsed)
245
+
246
+ return results
247
+
248
+ except (SyntaxError, ValueError) as e:
249
+ raise ValueError(f"Invalid batch function call syntax: {e}") from e
krons/utils/_to_list.py CHANGED
@@ -29,7 +29,7 @@ def _do_init() -> None:
29
29
  from pydantic import BaseModel
30
30
  from pydantic_core import PydanticUndefinedType
31
31
 
32
- from krons.types import UndefinedType, UnsetType
32
+ from krons.core.types import UndefinedType, UnsetType
33
33
 
34
34
  global _MODEL_LIKE, _MAP_LIKE, _SINGLETONE_TYPES, _SKIP_TYPE, _SKIP_TUPLE_SET
35
35
  _MODEL_LIKE = (BaseModel,)
@@ -117,7 +117,11 @@ def to_list(
117
117
  if isinstance(input_, _BYTE_LIKE):
118
118
  return list(input_) if use_values else [input_]
119
119
  if isinstance(input_, Mapping):
120
- return list(input_.values()) if use_values and hasattr(input_, "values") else [input_]
120
+ return (
121
+ list(input_.values())
122
+ if use_values and hasattr(input_, "values")
123
+ else [input_]
124
+ )
121
125
  if isinstance(input_, _MODEL_LIKE):
122
126
  return [input_]
123
127
  if isinstance(input_, Iterable) and not isinstance(input_, _BYTE_LIKE):
@@ -129,7 +133,9 @@ def to_list(
129
133
 
130
134
  initial_list = _to_list_type(input_, use_values=use_values)
131
135
  skip_types: tuple[type, ...] = _SKIP_TYPE if flatten_tuple_set else _SKIP_TUPLE_SET
132
- processed = _process_list(initial_list, flatten=flatten, dropna=dropna, skip_types=skip_types)
136
+ processed = _process_list(
137
+ initial_list, flatten=flatten, dropna=dropna, skip_types=skip_types
138
+ )
133
139
 
134
140
  if unique:
135
141
  seen = set()
krons/utils/_utils.py CHANGED
@@ -163,10 +163,14 @@ def import_module(
163
163
  ImportError: If module or attribute not found.
164
164
  """
165
165
  try:
166
- full_import_path = f"{package_name}.{module_name}" if module_name else package_name
166
+ full_import_path = (
167
+ f"{package_name}.{module_name}" if module_name else package_name
168
+ )
167
169
 
168
170
  if import_name:
169
- import_name = [import_name] if not isinstance(import_name, list) else import_name
171
+ import_name = (
172
+ [import_name] if not isinstance(import_name, list) else import_name
173
+ )
170
174
  a = __import__(
171
175
  full_import_path,
172
176
  fromlist=import_name,
@@ -11,7 +11,7 @@ Primary exports:
11
11
  from collections.abc import AsyncGenerator, Callable
12
12
  from typing import Any, ParamSpec, TypeVar
13
13
 
14
- from krons.types._sentinel import Unset, not_sentinel
14
+ from krons.core.types._sentinel import Unset, not_sentinel
15
15
  from krons.utils._lazy_init import LazyInit
16
16
  from krons.utils._to_list import to_list
17
17
 
@@ -66,7 +66,9 @@ def _validate_func(func: Any) -> Callable:
66
66
  try:
67
67
  func_list = list(func)
68
68
  except TypeError:
69
- raise ValueError("func must be callable or an iterable containing one callable.")
69
+ raise ValueError(
70
+ "func must be callable or an iterable containing one callable."
71
+ )
70
72
 
71
73
  if len(func_list) != 1 or not callable(func_list[0]):
72
74
  raise ValueError("Only one callable function is allowed.")
@@ -48,7 +48,9 @@ def is_cancelled(exc: BaseException) -> bool:
48
48
  return isinstance(exc, anyio.get_cancelled_exc_class())
49
49
 
50
50
 
51
- async def shield(func: Callable[P, Awaitable[T]], *args: P.args, **kwargs: P.kwargs) -> T:
51
+ async def shield(
52
+ func: Callable[P, Awaitable[T]], *args: P.args, **kwargs: P.kwargs
53
+ ) -> T:
52
54
  """Execute async function protected from outer cancellation.
53
55
 
54
56
  Args:
@@ -39,7 +39,9 @@ __all__ = (
39
39
  )
40
40
 
41
41
 
42
- async def gather(*aws: Awaitable[T], return_exceptions: bool = False) -> list[T | BaseException]:
42
+ async def gather(
43
+ *aws: Awaitable[T], return_exceptions: bool = False
44
+ ) -> list[T | BaseException]:
43
45
  """Run awaitables concurrently and collect results in input order.
44
46
 
45
47
  Args:
@@ -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 krons.types import KeysLike
7
+ from krons.core.types import KeysLike
8
8
 
9
- from krons.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: