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
|
@@ -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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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")
|
krons/utils/fuzzy/__init__.py
CHANGED
|
@@ -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
|
|
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 =
|
|
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]
|
|
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:
|