krons 0.1.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.
- kronos/__init__.py +0 -0
- kronos/core/__init__.py +145 -0
- kronos/core/broadcaster.py +116 -0
- kronos/core/element.py +225 -0
- kronos/core/event.py +316 -0
- kronos/core/eventbus.py +116 -0
- kronos/core/flow.py +356 -0
- kronos/core/graph.py +442 -0
- kronos/core/node.py +982 -0
- kronos/core/pile.py +575 -0
- kronos/core/processor.py +494 -0
- kronos/core/progression.py +296 -0
- kronos/enforcement/__init__.py +57 -0
- kronos/enforcement/common/__init__.py +34 -0
- kronos/enforcement/common/boolean.py +85 -0
- kronos/enforcement/common/choice.py +97 -0
- kronos/enforcement/common/mapping.py +118 -0
- kronos/enforcement/common/model.py +102 -0
- kronos/enforcement/common/number.py +98 -0
- kronos/enforcement/common/string.py +140 -0
- kronos/enforcement/context.py +129 -0
- kronos/enforcement/policy.py +80 -0
- kronos/enforcement/registry.py +153 -0
- kronos/enforcement/rule.py +312 -0
- kronos/enforcement/service.py +370 -0
- kronos/enforcement/validator.py +198 -0
- kronos/errors.py +146 -0
- kronos/operations/__init__.py +32 -0
- kronos/operations/builder.py +228 -0
- kronos/operations/flow.py +398 -0
- kronos/operations/node.py +101 -0
- kronos/operations/registry.py +92 -0
- kronos/protocols.py +414 -0
- kronos/py.typed +0 -0
- kronos/services/__init__.py +81 -0
- kronos/services/backend.py +286 -0
- kronos/services/endpoint.py +608 -0
- kronos/services/hook.py +471 -0
- kronos/services/imodel.py +465 -0
- kronos/services/registry.py +115 -0
- kronos/services/utilities/__init__.py +36 -0
- kronos/services/utilities/header_factory.py +87 -0
- kronos/services/utilities/rate_limited_executor.py +271 -0
- kronos/services/utilities/rate_limiter.py +180 -0
- kronos/services/utilities/resilience.py +414 -0
- kronos/session/__init__.py +41 -0
- kronos/session/exchange.py +258 -0
- kronos/session/message.py +60 -0
- kronos/session/session.py +411 -0
- kronos/specs/__init__.py +25 -0
- kronos/specs/adapters/__init__.py +0 -0
- kronos/specs/adapters/_utils.py +45 -0
- kronos/specs/adapters/dataclass_field.py +246 -0
- kronos/specs/adapters/factory.py +56 -0
- kronos/specs/adapters/pydantic_adapter.py +309 -0
- kronos/specs/adapters/sql_ddl.py +946 -0
- kronos/specs/catalog/__init__.py +36 -0
- kronos/specs/catalog/_audit.py +39 -0
- kronos/specs/catalog/_common.py +43 -0
- kronos/specs/catalog/_content.py +59 -0
- kronos/specs/catalog/_enforcement.py +70 -0
- kronos/specs/factory.py +120 -0
- kronos/specs/operable.py +314 -0
- kronos/specs/phrase.py +405 -0
- kronos/specs/protocol.py +140 -0
- kronos/specs/spec.py +506 -0
- kronos/types/__init__.py +60 -0
- kronos/types/_sentinel.py +311 -0
- kronos/types/base.py +369 -0
- kronos/types/db_types.py +260 -0
- kronos/types/identity.py +66 -0
- kronos/utils/__init__.py +40 -0
- kronos/utils/_hash.py +234 -0
- kronos/utils/_json_dump.py +392 -0
- kronos/utils/_lazy_init.py +63 -0
- kronos/utils/_to_list.py +165 -0
- kronos/utils/_to_num.py +85 -0
- kronos/utils/_utils.py +375 -0
- kronos/utils/concurrency/__init__.py +205 -0
- kronos/utils/concurrency/_async_call.py +333 -0
- kronos/utils/concurrency/_cancel.py +122 -0
- kronos/utils/concurrency/_errors.py +96 -0
- kronos/utils/concurrency/_patterns.py +363 -0
- kronos/utils/concurrency/_primitives.py +328 -0
- kronos/utils/concurrency/_priority_queue.py +135 -0
- kronos/utils/concurrency/_resource_tracker.py +110 -0
- kronos/utils/concurrency/_run_async.py +67 -0
- kronos/utils/concurrency/_task.py +95 -0
- kronos/utils/concurrency/_utils.py +79 -0
- kronos/utils/fuzzy/__init__.py +14 -0
- kronos/utils/fuzzy/_extract_json.py +90 -0
- kronos/utils/fuzzy/_fuzzy_json.py +288 -0
- kronos/utils/fuzzy/_fuzzy_match.py +149 -0
- kronos/utils/fuzzy/_string_similarity.py +187 -0
- kronos/utils/fuzzy/_to_dict.py +396 -0
- kronos/utils/sql/__init__.py +13 -0
- kronos/utils/sql/_sql_validation.py +142 -0
- krons-0.1.0.dist-info/METADATA +70 -0
- krons-0.1.0.dist-info/RECORD +101 -0
- krons-0.1.0.dist-info/WHEEL +4 -0
- krons-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Dictionary conversion utilities with recursive processing and JSON parsing."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import dataclasses
|
|
10
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
11
|
+
from enum import Enum as _Enum
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
import orjson
|
|
15
|
+
|
|
16
|
+
from ._fuzzy_json import fuzzy_json
|
|
17
|
+
|
|
18
|
+
__all__ = ("to_dict",)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def to_dict(
|
|
22
|
+
input_: Any,
|
|
23
|
+
/,
|
|
24
|
+
*,
|
|
25
|
+
prioritize_model_dump: bool = False,
|
|
26
|
+
fuzzy_parse: bool = False,
|
|
27
|
+
suppress: bool = False,
|
|
28
|
+
parser: Callable[[str], Any] | None = None,
|
|
29
|
+
recursive: bool = False,
|
|
30
|
+
max_recursive_depth: int | None = None,
|
|
31
|
+
recursive_python_only: bool = True,
|
|
32
|
+
use_enum_values: bool = False,
|
|
33
|
+
**kwargs: Any,
|
|
34
|
+
) -> dict[str | int, Any]:
|
|
35
|
+
"""Convert input to dictionary with optional recursive processing.
|
|
36
|
+
|
|
37
|
+
Type handling:
|
|
38
|
+
- Mapping: copied to dict
|
|
39
|
+
- str: parsed as JSON (orjson or custom parser)
|
|
40
|
+
- set: {v: v for v in set}
|
|
41
|
+
- Enum class: {name: member} or {name: value} if use_enum_values
|
|
42
|
+
- list/tuple: {0: v0, 1: v1, ...} (enumerated)
|
|
43
|
+
- Pydantic BaseModel: model_dump() or dict-like conversion
|
|
44
|
+
- dataclass: dataclasses.asdict()
|
|
45
|
+
- objects with __dict__: returns __dict__
|
|
46
|
+
- None/Undefined: {}
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
input_: Object to convert.
|
|
50
|
+
prioritize_model_dump: Call .model_dump() first for Pydantic models.
|
|
51
|
+
fuzzy_parse: Use fuzzy_json() for malformed JSON strings.
|
|
52
|
+
suppress: Return {} on errors instead of raising.
|
|
53
|
+
parser: Custom parser(str, **kwargs) -> Any for string inputs.
|
|
54
|
+
recursive: Recursively process nested structures.
|
|
55
|
+
max_recursive_depth: Max depth (default 5, clamped to 10).
|
|
56
|
+
recursive_python_only: Only recurse into Python builtins (not custom objects).
|
|
57
|
+
use_enum_values: Use .value for Enum members.
|
|
58
|
+
**kwargs: Passed to parser and model_dump().
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Dictionary representation. Keys are str or int (for enumerated iterables).
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: max_recursive_depth negative or >10.
|
|
65
|
+
Exception: Conversion failure (unless suppress=True).
|
|
66
|
+
|
|
67
|
+
Edge Cases:
|
|
68
|
+
- Empty string with suppress=False: raises
|
|
69
|
+
- Empty string with suppress=True: returns {}
|
|
70
|
+
- Circular references: limited by max_recursive_depth
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
if not isinstance(max_recursive_depth, int):
|
|
74
|
+
max_depth = 5
|
|
75
|
+
else:
|
|
76
|
+
if max_recursive_depth < 0:
|
|
77
|
+
raise ValueError("max_recursive_depth must be a non-negative integer")
|
|
78
|
+
if max_recursive_depth > 10:
|
|
79
|
+
raise ValueError("max_recursive_depth must be less than or equal to 10")
|
|
80
|
+
max_depth = max_recursive_depth
|
|
81
|
+
|
|
82
|
+
str_parse_opts = {
|
|
83
|
+
"fuzzy_parse": fuzzy_parse,
|
|
84
|
+
"parser": parser,
|
|
85
|
+
"use_enum_values": use_enum_values,
|
|
86
|
+
**kwargs,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
obj = input_
|
|
90
|
+
if recursive:
|
|
91
|
+
obj = _preprocess_recursive(
|
|
92
|
+
obj,
|
|
93
|
+
depth=0,
|
|
94
|
+
max_depth=max_depth,
|
|
95
|
+
recursive_custom_types=not recursive_python_only,
|
|
96
|
+
str_parse_opts=str_parse_opts,
|
|
97
|
+
prioritize_model_dump=prioritize_model_dump,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return _convert_top_level_to_dict(
|
|
101
|
+
obj,
|
|
102
|
+
fuzzy_parse=fuzzy_parse,
|
|
103
|
+
parser=parser,
|
|
104
|
+
prioritize_model_dump=prioritize_model_dump,
|
|
105
|
+
use_enum_values=use_enum_values,
|
|
106
|
+
**kwargs,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
except Exception as e:
|
|
110
|
+
if suppress or input_ == "":
|
|
111
|
+
return {}
|
|
112
|
+
raise e
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _is_na(obj: Any) -> bool:
|
|
116
|
+
"""Check if obj is None or a Pydantic/kron undefined sentinel (by typename)."""
|
|
117
|
+
if obj is None:
|
|
118
|
+
return True
|
|
119
|
+
tname = type(obj).__name__
|
|
120
|
+
return tname in {
|
|
121
|
+
"Undefined",
|
|
122
|
+
"UndefinedType",
|
|
123
|
+
"PydanticUndefined",
|
|
124
|
+
"PydanticUndefinedType",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _enum_class_to_dict(enum_cls: type[_Enum], use_enum_values: bool) -> dict[str, Any]:
|
|
129
|
+
"""Convert Enum class to {name: member} or {name: value} dict."""
|
|
130
|
+
members = dict(enum_cls.__members__)
|
|
131
|
+
if use_enum_values:
|
|
132
|
+
return {k: v.value for k, v in members.items()}
|
|
133
|
+
return {k: v for k, v in members.items()}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_str(
|
|
137
|
+
s: str,
|
|
138
|
+
*,
|
|
139
|
+
fuzzy_parse: bool,
|
|
140
|
+
parser: Callable[[str], Any] | None,
|
|
141
|
+
**kwargs: Any,
|
|
142
|
+
) -> Any:
|
|
143
|
+
"""Parse string to Python object via JSON or custom parser.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
s: String to parse.
|
|
147
|
+
fuzzy_parse: Use fuzzy_json() for malformed JSON.
|
|
148
|
+
parser: Custom parser(s, **kwargs). If provided, takes precedence.
|
|
149
|
+
**kwargs: Passed to custom parser only (orjson.loads ignores them).
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Parsed Python object.
|
|
153
|
+
"""
|
|
154
|
+
if parser is not None:
|
|
155
|
+
return parser(s, **kwargs)
|
|
156
|
+
if fuzzy_parse:
|
|
157
|
+
with contextlib.suppress(NameError):
|
|
158
|
+
return fuzzy_json(s)
|
|
159
|
+
return orjson.loads(s)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _object_to_mapping_like(
|
|
163
|
+
obj: Any,
|
|
164
|
+
*,
|
|
165
|
+
prioritize_model_dump: bool = False,
|
|
166
|
+
**kwargs: Any,
|
|
167
|
+
) -> Mapping | dict | Any:
|
|
168
|
+
"""Convert custom object to mapping-like via duck-typing.
|
|
169
|
+
|
|
170
|
+
Conversion order:
|
|
171
|
+
1. Pydantic model_dump() (if prioritize_model_dump)
|
|
172
|
+
2. Common methods: to_dict, model_dump, dict, to_json, json
|
|
173
|
+
3. dataclasses.asdict()
|
|
174
|
+
4. __dict__ attribute
|
|
175
|
+
5. dict(obj) fallback
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
obj: Object to convert.
|
|
179
|
+
prioritize_model_dump: Try model_dump() first.
|
|
180
|
+
**kwargs: Passed to conversion methods.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Mapping-like object or dict.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
TypeError: If obj is not convertible (from dict() fallback).
|
|
187
|
+
"""
|
|
188
|
+
if prioritize_model_dump and hasattr(obj, "model_dump"):
|
|
189
|
+
return obj.model_dump(**kwargs)
|
|
190
|
+
|
|
191
|
+
for name in ("to_dict", "model_dump", "dict", "to_json", "json"):
|
|
192
|
+
if hasattr(obj, name):
|
|
193
|
+
res = getattr(obj, name)(**kwargs)
|
|
194
|
+
return orjson.loads(res) if isinstance(res, str) else res
|
|
195
|
+
|
|
196
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
197
|
+
return dataclasses.asdict(obj)
|
|
198
|
+
|
|
199
|
+
if hasattr(obj, "__dict__"):
|
|
200
|
+
return obj.__dict__
|
|
201
|
+
|
|
202
|
+
return dict(obj)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _enumerate_iterable(it: Iterable) -> dict[int, Any]:
|
|
206
|
+
"""Convert iterable to dict with integer indices as keys."""
|
|
207
|
+
return {i: v for i, v in enumerate(it)}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _preprocess_recursive(
|
|
211
|
+
obj: Any,
|
|
212
|
+
*,
|
|
213
|
+
depth: int,
|
|
214
|
+
max_depth: int,
|
|
215
|
+
recursive_custom_types: bool,
|
|
216
|
+
str_parse_opts: dict[str, Any],
|
|
217
|
+
prioritize_model_dump: bool,
|
|
218
|
+
) -> Any:
|
|
219
|
+
"""Recursively preprocess nested structures before final conversion.
|
|
220
|
+
|
|
221
|
+
Processing:
|
|
222
|
+
- Strings: parsed as JSON, then recursed
|
|
223
|
+
- Mappings: recurse into values (keys unchanged)
|
|
224
|
+
- list/tuple/set/frozenset: recurse into items, preserve container type
|
|
225
|
+
- Enum classes: convert to dict, then recurse
|
|
226
|
+
- Custom objects (if recursive_custom_types): convert to mapping, then recurse
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
obj: Object to process.
|
|
230
|
+
depth: Current recursion depth.
|
|
231
|
+
max_depth: Maximum depth (stops recursion when reached).
|
|
232
|
+
recursive_custom_types: Also convert custom objects.
|
|
233
|
+
str_parse_opts: Options for _parse_str().
|
|
234
|
+
prioritize_model_dump: Passed to _object_to_mapping_like().
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Preprocessed object with same container types.
|
|
238
|
+
"""
|
|
239
|
+
if depth >= max_depth:
|
|
240
|
+
return obj
|
|
241
|
+
|
|
242
|
+
# Fast paths by exact type where possible
|
|
243
|
+
t = type(obj)
|
|
244
|
+
|
|
245
|
+
# Strings: try to parse; on failure, keep as-is
|
|
246
|
+
if t is str:
|
|
247
|
+
with contextlib.suppress(Exception):
|
|
248
|
+
return _preprocess_recursive(
|
|
249
|
+
_parse_str(obj, **str_parse_opts),
|
|
250
|
+
depth=depth + 1,
|
|
251
|
+
max_depth=max_depth,
|
|
252
|
+
recursive_custom_types=recursive_custom_types,
|
|
253
|
+
str_parse_opts=str_parse_opts,
|
|
254
|
+
prioritize_model_dump=prioritize_model_dump,
|
|
255
|
+
)
|
|
256
|
+
return obj
|
|
257
|
+
|
|
258
|
+
# Dict-like
|
|
259
|
+
if isinstance(obj, Mapping):
|
|
260
|
+
# Recurse only into values (keys kept as-is)
|
|
261
|
+
return {
|
|
262
|
+
k: _preprocess_recursive(
|
|
263
|
+
v,
|
|
264
|
+
depth=depth + 1,
|
|
265
|
+
max_depth=max_depth,
|
|
266
|
+
recursive_custom_types=recursive_custom_types,
|
|
267
|
+
str_parse_opts=str_parse_opts,
|
|
268
|
+
prioritize_model_dump=prioritize_model_dump,
|
|
269
|
+
)
|
|
270
|
+
for k, v in obj.items()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
# Sequence/Set-like (but not str)
|
|
274
|
+
if isinstance(obj, list | tuple | set | frozenset):
|
|
275
|
+
items = [
|
|
276
|
+
_preprocess_recursive(
|
|
277
|
+
v,
|
|
278
|
+
depth=depth + 1,
|
|
279
|
+
max_depth=max_depth,
|
|
280
|
+
recursive_custom_types=recursive_custom_types,
|
|
281
|
+
str_parse_opts=str_parse_opts,
|
|
282
|
+
prioritize_model_dump=prioritize_model_dump,
|
|
283
|
+
)
|
|
284
|
+
for v in obj
|
|
285
|
+
]
|
|
286
|
+
if t is list:
|
|
287
|
+
return items
|
|
288
|
+
if t is tuple:
|
|
289
|
+
return tuple(items)
|
|
290
|
+
if t is set:
|
|
291
|
+
return set(items)
|
|
292
|
+
if t is frozenset:
|
|
293
|
+
return frozenset(items)
|
|
294
|
+
|
|
295
|
+
if isinstance(obj, type) and issubclass(obj, _Enum):
|
|
296
|
+
with contextlib.suppress(Exception):
|
|
297
|
+
enum_map = _enum_class_to_dict(
|
|
298
|
+
obj,
|
|
299
|
+
use_enum_values=str_parse_opts.get("use_enum_values", True),
|
|
300
|
+
)
|
|
301
|
+
return _preprocess_recursive(
|
|
302
|
+
enum_map,
|
|
303
|
+
depth=depth + 1,
|
|
304
|
+
max_depth=max_depth,
|
|
305
|
+
recursive_custom_types=recursive_custom_types,
|
|
306
|
+
str_parse_opts=str_parse_opts,
|
|
307
|
+
prioritize_model_dump=prioritize_model_dump,
|
|
308
|
+
)
|
|
309
|
+
return obj
|
|
310
|
+
|
|
311
|
+
if recursive_custom_types:
|
|
312
|
+
with contextlib.suppress(Exception):
|
|
313
|
+
mapped = _object_to_mapping_like(obj, prioritize_model_dump=prioritize_model_dump)
|
|
314
|
+
return _preprocess_recursive(
|
|
315
|
+
mapped,
|
|
316
|
+
depth=depth + 1,
|
|
317
|
+
max_depth=max_depth,
|
|
318
|
+
recursive_custom_types=recursive_custom_types,
|
|
319
|
+
str_parse_opts=str_parse_opts,
|
|
320
|
+
prioritize_model_dump=prioritize_model_dump,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
return obj
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _convert_top_level_to_dict(
|
|
327
|
+
obj: Any,
|
|
328
|
+
*,
|
|
329
|
+
fuzzy_parse: bool,
|
|
330
|
+
parser: Callable[[str], Any] | None,
|
|
331
|
+
prioritize_model_dump: bool,
|
|
332
|
+
use_enum_values: bool,
|
|
333
|
+
**kwargs: Any,
|
|
334
|
+
) -> dict[str | int, Any]:
|
|
335
|
+
"""Convert single object to dict (final conversion step).
|
|
336
|
+
|
|
337
|
+
Conversion order:
|
|
338
|
+
1. set -> {v: v}
|
|
339
|
+
2. Enum class -> {name: member/value}
|
|
340
|
+
3. Mapping -> dict(obj)
|
|
341
|
+
4. None/undefined -> {}
|
|
342
|
+
5. str -> parse as JSON
|
|
343
|
+
6. Non-sequence objects -> _object_to_mapping_like
|
|
344
|
+
7. Iterables -> enumerate to {int: value}
|
|
345
|
+
8. Dataclass fallback
|
|
346
|
+
9. dict(obj) last resort
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
obj: Object to convert.
|
|
350
|
+
fuzzy_parse: Use fuzzy JSON parsing.
|
|
351
|
+
parser: Custom string parser.
|
|
352
|
+
prioritize_model_dump: Prefer model_dump() for Pydantic.
|
|
353
|
+
use_enum_values: Use .value for Enum members.
|
|
354
|
+
**kwargs: Passed to conversion methods.
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
Dictionary with str or int keys.
|
|
358
|
+
"""
|
|
359
|
+
if isinstance(obj, set):
|
|
360
|
+
return cast(dict[str | int, Any], {v: v for v in obj})
|
|
361
|
+
|
|
362
|
+
if isinstance(obj, type) and issubclass(obj, _Enum):
|
|
363
|
+
return cast(dict[str | int, Any], _enum_class_to_dict(obj, use_enum_values))
|
|
364
|
+
|
|
365
|
+
if isinstance(obj, Mapping):
|
|
366
|
+
return cast(dict[str | int, Any], dict(obj))
|
|
367
|
+
|
|
368
|
+
if _is_na(obj):
|
|
369
|
+
return cast(dict[str | int, Any], {})
|
|
370
|
+
|
|
371
|
+
if isinstance(obj, str):
|
|
372
|
+
return _parse_str(obj, fuzzy_parse=fuzzy_parse, parser=parser, **kwargs)
|
|
373
|
+
|
|
374
|
+
with contextlib.suppress(Exception):
|
|
375
|
+
if not isinstance(obj, Sequence):
|
|
376
|
+
converted = _object_to_mapping_like(
|
|
377
|
+
obj, prioritize_model_dump=prioritize_model_dump, **kwargs
|
|
378
|
+
)
|
|
379
|
+
if isinstance(converted, str):
|
|
380
|
+
return _parse_str(converted, fuzzy_parse=fuzzy_parse, parser=None)
|
|
381
|
+
if isinstance(converted, Mapping):
|
|
382
|
+
return dict(converted)
|
|
383
|
+
if isinstance(converted, Iterable) and not isinstance(
|
|
384
|
+
converted, str | bytes | bytearray
|
|
385
|
+
):
|
|
386
|
+
return cast(dict[str | int, Any], _enumerate_iterable(converted))
|
|
387
|
+
return dict(converted)
|
|
388
|
+
|
|
389
|
+
if isinstance(obj, Iterable) and not isinstance(obj, str | bytes | bytearray):
|
|
390
|
+
return cast(dict[str | int, Any], _enumerate_iterable(obj))
|
|
391
|
+
|
|
392
|
+
with contextlib.suppress(Exception):
|
|
393
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
394
|
+
return cast(dict[str | int, Any], dataclasses.asdict(obj))
|
|
395
|
+
|
|
396
|
+
return dict(obj)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from ._sql_validation import (
|
|
2
|
+
MAX_IDENTIFIER_LENGTH,
|
|
3
|
+
SAFE_IDENTIFIER_PATTERN,
|
|
4
|
+
sanitize_order_by,
|
|
5
|
+
validate_identifier,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
"validate_identifier",
|
|
10
|
+
"sanitize_order_by",
|
|
11
|
+
"MAX_IDENTIFIER_LENGTH",
|
|
12
|
+
"SAFE_IDENTIFIER_PATTERN",
|
|
13
|
+
)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""SQL validation utilities for injection prevention.
|
|
5
|
+
|
|
6
|
+
Validates SQL identifiers and clauses before interpolation into DDL/queries.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from kronos.errors import ValidationError
|
|
14
|
+
|
|
15
|
+
__all__ = (
|
|
16
|
+
"validate_identifier",
|
|
17
|
+
"sanitize_order_by",
|
|
18
|
+
"MAX_IDENTIFIER_LENGTH",
|
|
19
|
+
"SAFE_IDENTIFIER_PATTERN",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# SQL identifier: alphanumeric and underscores, starting with letter/underscore
|
|
23
|
+
SAFE_IDENTIFIER_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
|
24
|
+
VALID_DIRECTIONS = frozenset({"ASC", "DESC"})
|
|
25
|
+
MAX_IDENTIFIER_LENGTH = 63 # PostgreSQL limit
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_identifier(name: str, kind: str = "identifier") -> str:
|
|
29
|
+
"""Validate SQL identifier to prevent injection.
|
|
30
|
+
|
|
31
|
+
Checks that identifier:
|
|
32
|
+
- Is non-empty
|
|
33
|
+
- Does not exceed PostgreSQL's 63 character limit
|
|
34
|
+
- Contains only alphanumeric characters and underscores
|
|
35
|
+
- Starts with a letter or underscore
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
name: The identifier to validate.
|
|
39
|
+
kind: Description for error messages (e.g., "table", "column").
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
The validated identifier (unchanged if valid).
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
ValidationError: If identifier is empty, too long, or contains unsafe chars.
|
|
46
|
+
|
|
47
|
+
Example:
|
|
48
|
+
>>> validate_identifier("user_name", "column")
|
|
49
|
+
'user_name'
|
|
50
|
+
>>> validate_identifier("123bad", "table") # Raises ValidationError
|
|
51
|
+
"""
|
|
52
|
+
if not name:
|
|
53
|
+
raise ValidationError(
|
|
54
|
+
f"Empty {kind} name not allowed",
|
|
55
|
+
details={"kind": kind, "value": name},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if len(name) > MAX_IDENTIFIER_LENGTH:
|
|
59
|
+
raise ValidationError(
|
|
60
|
+
f"{kind.capitalize()} identifier too long: {name!r} ({len(name)} chars). "
|
|
61
|
+
f"Maximum is {MAX_IDENTIFIER_LENGTH} characters.",
|
|
62
|
+
details={"kind": kind, "value": name, "length": len(name)},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if not SAFE_IDENTIFIER_PATTERN.match(name):
|
|
66
|
+
raise ValidationError(
|
|
67
|
+
f"Unsafe {kind} identifier: {name!r}. "
|
|
68
|
+
f"Must be alphanumeric/underscore, starting with letter or underscore.",
|
|
69
|
+
details={"kind": kind, "value": name},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return name
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def sanitize_order_by(order_by: str) -> str:
|
|
76
|
+
"""Sanitize ORDER BY clause to prevent SQL injection.
|
|
77
|
+
|
|
78
|
+
Accepts formats:
|
|
79
|
+
- "column"
|
|
80
|
+
- "column ASC"
|
|
81
|
+
- "column DESC"
|
|
82
|
+
- "column1, column2 DESC"
|
|
83
|
+
|
|
84
|
+
Returns safely quoted SQL fragment.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
order_by: The ORDER BY clause to sanitize.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Sanitized ORDER BY clause with quoted identifiers.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
ValidationError: If column name or direction is invalid.
|
|
94
|
+
|
|
95
|
+
Example:
|
|
96
|
+
>>> sanitize_order_by("name, created_at DESC")
|
|
97
|
+
'"name" ASC, "created_at" DESC'
|
|
98
|
+
"""
|
|
99
|
+
parts: list[str] = []
|
|
100
|
+
|
|
101
|
+
for clause in order_by.split(","):
|
|
102
|
+
clause = clause.strip()
|
|
103
|
+
if not clause:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
tokens = clause.split()
|
|
107
|
+
if len(tokens) == 1:
|
|
108
|
+
column = tokens[0]
|
|
109
|
+
direction = "ASC"
|
|
110
|
+
elif len(tokens) == 2:
|
|
111
|
+
column, direction = tokens
|
|
112
|
+
direction = direction.upper()
|
|
113
|
+
else:
|
|
114
|
+
raise ValidationError(
|
|
115
|
+
f"Invalid ORDER BY clause: {clause!r}. Expected 'column' or 'column ASC/DESC'.",
|
|
116
|
+
details={"clause": clause},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Validate column name
|
|
120
|
+
if not SAFE_IDENTIFIER_PATTERN.match(column):
|
|
121
|
+
raise ValidationError(
|
|
122
|
+
f"Invalid column in ORDER BY: {column!r}. "
|
|
123
|
+
"Must be alphanumeric/underscore identifier.",
|
|
124
|
+
details={"column": column},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Validate direction
|
|
128
|
+
if direction not in VALID_DIRECTIONS:
|
|
129
|
+
raise ValidationError(
|
|
130
|
+
f"Invalid direction in ORDER BY: {direction!r}. Must be ASC or DESC.",
|
|
131
|
+
details={"direction": direction},
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
parts.append(f'"{column}" {direction}')
|
|
135
|
+
|
|
136
|
+
if not parts:
|
|
137
|
+
raise ValidationError(
|
|
138
|
+
f"Empty ORDER BY clause: {order_by!r}",
|
|
139
|
+
details={"order_by": order_by},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return ", ".join(parts)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: krons
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Spec-based composable framework for building type-safe systems
|
|
5
|
+
Project-URL: Homepage, https://github.com/khive-ai/kronos
|
|
6
|
+
Project-URL: Repository, https://github.com/khive-ai/kronos
|
|
7
|
+
Project-URL: Issues, https://github.com/khive-ai/kronos/issues
|
|
8
|
+
Author-email: HaiyangLi <quantocean.li@gmail.com>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: composable,framework,pydantic,spec,type-safe,validation
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: anyio>=4.10.0
|
|
22
|
+
Requires-Dist: httpx>=0.26.0
|
|
23
|
+
Requires-Dist: orjson>=3.10.0
|
|
24
|
+
Requires-Dist: pydantic>=2.10.0
|
|
25
|
+
Requires-Dist: rapidfuzz>=3.10.0
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# krons
|
|
29
|
+
|
|
30
|
+
Spec-based composable framework for building type-safe systems.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install krons
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- **Spec/Operable**: Type-safe field definitions with validation, defaults, and DB metadata
|
|
41
|
+
- **Node**: Polymorphic content containers with DB serialization
|
|
42
|
+
- **Services**: Unified service interfaces (iModel) with hooks and rate limiting
|
|
43
|
+
- **Enforcement**: Policy evaluation and action handlers with typed I/O
|
|
44
|
+
- **Protocols**: Runtime-checkable protocols with `@implements` decorator
|
|
45
|
+
|
|
46
|
+
## Quick Start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from kronos.specs import Spec, Operable
|
|
50
|
+
|
|
51
|
+
# Define specs
|
|
52
|
+
name_spec = Spec(str, name="name")
|
|
53
|
+
count_spec = Spec(int, name="count", default=0, ge=0)
|
|
54
|
+
|
|
55
|
+
# Compose into structure
|
|
56
|
+
operable = Operable([name_spec, count_spec])
|
|
57
|
+
MyModel = operable.compose_structure("MyModel")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Requirements
|
|
61
|
+
|
|
62
|
+
- Python 3.11+
|
|
63
|
+
- pydantic 2.x
|
|
64
|
+
- anyio
|
|
65
|
+
- httpx
|
|
66
|
+
- orjson
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
Apache-2.0
|