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,102 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from ..rule import Rule, RuleParams, RuleQualifier
|
|
11
|
+
|
|
12
|
+
__all__ = ("BaseModelRule",)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get_basemodel_params() -> RuleParams:
|
|
16
|
+
"""Default params: applies to BaseModel via ANNOTATION qualifier, auto_fix enabled."""
|
|
17
|
+
return RuleParams(
|
|
18
|
+
apply_types={BaseModel},
|
|
19
|
+
apply_fields=set(),
|
|
20
|
+
default_qualifier=RuleQualifier.ANNOTATION,
|
|
21
|
+
auto_fix=True,
|
|
22
|
+
kw={},
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BaseModelRule(Rule):
|
|
27
|
+
"""Rule for validating Pydantic BaseModel subclasses.
|
|
28
|
+
|
|
29
|
+
Validates values against expected Pydantic model types with auto-conversion from dict.
|
|
30
|
+
Uses model_validate() for dict-to-model conversion when auto_fix is enabled.
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
rule = BaseModelRule()
|
|
34
|
+
result = await rule.invoke("config", {"name": "test"}, MyModel) # -> MyModel instance
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
params: RuleParams | None = None,
|
|
40
|
+
**kw,
|
|
41
|
+
):
|
|
42
|
+
"""Initialize BaseModel rule.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
params: Custom RuleParams (uses default if None)
|
|
46
|
+
**kw: Additional validation kwargs
|
|
47
|
+
"""
|
|
48
|
+
if params is None:
|
|
49
|
+
params = _get_basemodel_params()
|
|
50
|
+
super().__init__(params, **kw)
|
|
51
|
+
|
|
52
|
+
async def validate(self, v: Any, t: type, **kw) -> None:
|
|
53
|
+
"""Validate value as a Pydantic model.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
v: Value to validate
|
|
57
|
+
t: Expected BaseModel subclass
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
ValueError: If value cannot be validated as the model type
|
|
61
|
+
"""
|
|
62
|
+
if not isinstance(t, type) or not issubclass(t, BaseModel):
|
|
63
|
+
raise ValueError(f"expected_type must be a BaseModel subclass, got {t}")
|
|
64
|
+
|
|
65
|
+
if isinstance(v, t):
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
if isinstance(v, dict):
|
|
69
|
+
try:
|
|
70
|
+
t.model_validate(v)
|
|
71
|
+
return
|
|
72
|
+
except Exception as e:
|
|
73
|
+
raise ValueError(f"Dict validation failed: {e}") from e
|
|
74
|
+
|
|
75
|
+
raise ValueError(f"Cannot validate {type(v).__name__} as {t.__name__}")
|
|
76
|
+
|
|
77
|
+
async def perform_fix(self, v: Any, t: type) -> Any:
|
|
78
|
+
"""Attempt to convert value to model using standard validation.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
v: Value to fix
|
|
82
|
+
t: Expected BaseModel subclass
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Validated model instance
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
ValueError: If conversion fails
|
|
89
|
+
"""
|
|
90
|
+
if not isinstance(t, type) or not issubclass(t, BaseModel):
|
|
91
|
+
raise ValueError(f"expected_type must be a BaseModel subclass, got {t}")
|
|
92
|
+
|
|
93
|
+
if isinstance(v, t):
|
|
94
|
+
return v
|
|
95
|
+
|
|
96
|
+
if isinstance(v, dict):
|
|
97
|
+
try:
|
|
98
|
+
return t.model_validate(v)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
raise ValueError(f"Cannot convert dict to {t.__name__}: {e}") from e
|
|
101
|
+
|
|
102
|
+
raise ValueError(f"Cannot convert {type(v).__name__} to {t.__name__}")
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..rule import Rule, RuleParams, RuleQualifier
|
|
7
|
+
|
|
8
|
+
__all__ = ("NumberRule",)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_number_params() -> RuleParams:
|
|
12
|
+
"""Default params: applies to int/float via ANNOTATION qualifier, auto_fix enabled."""
|
|
13
|
+
return RuleParams(
|
|
14
|
+
apply_types={int, float},
|
|
15
|
+
apply_fields=set(),
|
|
16
|
+
default_qualifier=RuleQualifier.ANNOTATION,
|
|
17
|
+
auto_fix=True,
|
|
18
|
+
kw={},
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NumberRule(Rule):
|
|
23
|
+
"""Rule for validating and converting numeric values.
|
|
24
|
+
|
|
25
|
+
Features:
|
|
26
|
+
- Type checking (int or float)
|
|
27
|
+
- Range constraints (ge, gt, le, lt)
|
|
28
|
+
- Auto-conversion from string/other types to number
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
rule = NumberRule(ge=0.0, le=1.0) # Confidence score
|
|
32
|
+
result = await rule.invoke("confidence", 0.95, float)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
ge: int | float | None = None,
|
|
38
|
+
gt: int | float | None = None,
|
|
39
|
+
le: int | float | None = None,
|
|
40
|
+
lt: int | float | None = None,
|
|
41
|
+
params: RuleParams | None = None,
|
|
42
|
+
**kw,
|
|
43
|
+
):
|
|
44
|
+
"""Initialize number rule.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
ge: Greater than or equal to (>=)
|
|
48
|
+
gt: Greater than (>)
|
|
49
|
+
le: Less than or equal to (<=)
|
|
50
|
+
lt: Less than (<)
|
|
51
|
+
params: Custom RuleParams (uses default if None)
|
|
52
|
+
**kw: Additional validation kwargs
|
|
53
|
+
"""
|
|
54
|
+
if params is None:
|
|
55
|
+
params = _get_number_params()
|
|
56
|
+
super().__init__(params, **kw)
|
|
57
|
+
self.ge = ge
|
|
58
|
+
self.gt = gt
|
|
59
|
+
self.le = le
|
|
60
|
+
self.lt = lt
|
|
61
|
+
|
|
62
|
+
async def validate(self, v: Any, t: type, **kw) -> None:
|
|
63
|
+
"""Validate that value is a number within constraints.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: If not a number or constraints violated
|
|
67
|
+
"""
|
|
68
|
+
if not isinstance(v, (int, float)):
|
|
69
|
+
raise ValueError(f"Invalid number value: expected int or float, got {type(v).__name__}")
|
|
70
|
+
|
|
71
|
+
if self.ge is not None and v < self.ge:
|
|
72
|
+
raise ValueError(f"Number too small: {v} < {self.ge}")
|
|
73
|
+
if self.gt is not None and v <= self.gt:
|
|
74
|
+
raise ValueError(f"Number too small: {v} <= {self.gt}")
|
|
75
|
+
if self.le is not None and v > self.le:
|
|
76
|
+
raise ValueError(f"Number too large: {v} > {self.le}")
|
|
77
|
+
if self.lt is not None and v >= self.lt:
|
|
78
|
+
raise ValueError(f"Number too large: {v} >= {self.lt}")
|
|
79
|
+
|
|
80
|
+
async def perform_fix(self, v: Any, t: type) -> Any:
|
|
81
|
+
"""Attempt to convert value to number and re-validate.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Numeric value (int or float), validated against constraints
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: If conversion or re-validation fails
|
|
88
|
+
"""
|
|
89
|
+
fixed: int | float
|
|
90
|
+
try:
|
|
91
|
+
if isinstance(v, str):
|
|
92
|
+
v = v.strip()
|
|
93
|
+
fixed = int(v) if t is int else float(v)
|
|
94
|
+
except (ValueError, TypeError) as e:
|
|
95
|
+
raise ValueError(f"Failed to convert {v} to number") from e
|
|
96
|
+
|
|
97
|
+
await self.validate(fixed, t)
|
|
98
|
+
return fixed
|
|
@@ -0,0 +1,140 @@
|
|
|
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 re import Pattern
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..rule import Rule, RuleParams, RuleQualifier
|
|
9
|
+
|
|
10
|
+
__all__ = ("StringRule",)
|
|
11
|
+
|
|
12
|
+
# ReDoS protection: max input length for regex matching
|
|
13
|
+
DEFAULT_REGEX_MAX_INPUT_LENGTH = 10_000
|
|
14
|
+
|
|
15
|
+
# Heuristic ReDoS detection (nested quantifiers). Not exhaustive - for untrusted
|
|
16
|
+
# patterns use google-re2 or regex timeout. Input length limit provides additional mitigation.
|
|
17
|
+
_REDOS_PATTERNS = [
|
|
18
|
+
r"\(\.\*\)\*",
|
|
19
|
+
r"\(\.\+\)\*",
|
|
20
|
+
r"\(\.\*\)\+",
|
|
21
|
+
r"\(\.\+\)\+",
|
|
22
|
+
r"\(\[.*?\]\+\)\+",
|
|
23
|
+
r"\(\[.*?\]\*\)\*",
|
|
24
|
+
]
|
|
25
|
+
_REDOS_DETECTOR = re.compile("|".join(_REDOS_PATTERNS))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_string_params() -> RuleParams:
|
|
29
|
+
"""Default params: applies to str via ANNOTATION qualifier, auto_fix enabled."""
|
|
30
|
+
return RuleParams(
|
|
31
|
+
apply_types={str},
|
|
32
|
+
apply_fields=set(),
|
|
33
|
+
default_qualifier=RuleQualifier.ANNOTATION,
|
|
34
|
+
auto_fix=True,
|
|
35
|
+
kw={},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StringRule(Rule):
|
|
40
|
+
"""Rule for validating and converting string values.
|
|
41
|
+
|
|
42
|
+
Features:
|
|
43
|
+
- Type checking (must be str)
|
|
44
|
+
- Length constraints (min_length, max_length)
|
|
45
|
+
- Pattern matching (regex) with ReDoS protection
|
|
46
|
+
- Auto-conversion from any type to string
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
rule = StringRule(min_length=1, max_length=100, pattern=r'^[A-Za-z]+$')
|
|
50
|
+
result = await rule.invoke("name", "Ocean", str)
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
min_length: int | None = None,
|
|
56
|
+
max_length: int | None = None,
|
|
57
|
+
pattern: str | None = None,
|
|
58
|
+
regex_max_input_length: int = DEFAULT_REGEX_MAX_INPUT_LENGTH,
|
|
59
|
+
params: RuleParams | None = None,
|
|
60
|
+
**kw,
|
|
61
|
+
):
|
|
62
|
+
"""Initialize string rule.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
min_length: Minimum string length (inclusive)
|
|
66
|
+
max_length: Maximum string length (inclusive)
|
|
67
|
+
pattern: Regex pattern to match. Patterns with nested quantifiers
|
|
68
|
+
are rejected to prevent ReDoS attacks.
|
|
69
|
+
regex_max_input_length: Maximum input length for regex matching
|
|
70
|
+
(default 10,000 chars). Inputs exceeding this are rejected.
|
|
71
|
+
params: Custom RuleParams (uses default if None)
|
|
72
|
+
**kw: Additional validation kwargs
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
ValueError: If pattern contains potential ReDoS vulnerabilities
|
|
76
|
+
"""
|
|
77
|
+
if params is None:
|
|
78
|
+
params = _get_string_params()
|
|
79
|
+
super().__init__(params, **kw)
|
|
80
|
+
self.min_length = min_length
|
|
81
|
+
self.max_length = max_length
|
|
82
|
+
self.regex_max_input_length = regex_max_input_length
|
|
83
|
+
|
|
84
|
+
self._compiled_pattern: Pattern[str] | None
|
|
85
|
+
if pattern is not None:
|
|
86
|
+
if _REDOS_DETECTOR.search(pattern):
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"Pattern '{pattern}' contains nested quantifiers that could cause "
|
|
89
|
+
"ReDoS (Regular Expression Denial of Service). Use simpler patterns."
|
|
90
|
+
)
|
|
91
|
+
try:
|
|
92
|
+
self._compiled_pattern = re.compile(pattern)
|
|
93
|
+
except re.error as e:
|
|
94
|
+
raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e
|
|
95
|
+
else:
|
|
96
|
+
self._compiled_pattern = None
|
|
97
|
+
self.pattern = pattern
|
|
98
|
+
|
|
99
|
+
async def validate(self, v: Any, t: type, **kw) -> None:
|
|
100
|
+
"""Validate that value is a string with correct length and pattern.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ValueError: If not a string or constraints violated
|
|
104
|
+
"""
|
|
105
|
+
if not isinstance(v, str):
|
|
106
|
+
raise ValueError(f"Invalid string value: expected str, got {type(v).__name__}")
|
|
107
|
+
|
|
108
|
+
if self.min_length is not None and len(v) < self.min_length:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"String too short: got {len(v)} characters, minimum {self.min_length}"
|
|
111
|
+
)
|
|
112
|
+
if self.max_length is not None and len(v) > self.max_length:
|
|
113
|
+
raise ValueError(f"String too long: got {len(v)} characters, maximum {self.max_length}")
|
|
114
|
+
|
|
115
|
+
if self._compiled_pattern is not None:
|
|
116
|
+
if len(v) > self.regex_max_input_length:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"String too long for regex matching: got {len(v)} characters, "
|
|
119
|
+
f"maximum {self.regex_max_input_length}"
|
|
120
|
+
)
|
|
121
|
+
if not self._compiled_pattern.match(v):
|
|
122
|
+
raise ValueError(f"String does not match required pattern: {self.pattern}")
|
|
123
|
+
|
|
124
|
+
async def perform_fix(self, v: Any, t: type) -> Any:
|
|
125
|
+
"""Attempt to convert value to string and re-validate.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
String representation of value (validated)
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
ValueError: If conversion or re-validation fails
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
fixed = str(v)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
raise ValueError(f"Failed to convert {v} to string") from e
|
|
137
|
+
|
|
138
|
+
# Re-validate the fixed value
|
|
139
|
+
await self.validate(fixed, t)
|
|
140
|
+
return fixed
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Request context for service operations."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Awaitable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
12
|
+
from uuid import UUID, uuid4
|
|
13
|
+
|
|
14
|
+
from kronos.types.base import DataClass
|
|
15
|
+
from kronos.types.identity import ID
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from kronos.session import Branch, Session
|
|
19
|
+
|
|
20
|
+
__all__ = ("QueryFn", "RequestContext")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class QueryFn(Protocol):
|
|
24
|
+
"""Protocol for CRUD query functions used by declarative phrases.
|
|
25
|
+
|
|
26
|
+
The query_fn is the bridge between declarative CrudPattern phrases
|
|
27
|
+
and the actual database backend. Implementations MUST use parameterized
|
|
28
|
+
queries to prevent SQL injection.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
table: Validated database table name (alphanumeric + underscores only).
|
|
32
|
+
operation: CRUD operation enum value.
|
|
33
|
+
where: WHERE clause as dict of column -> value. None for insert.
|
|
34
|
+
data: Data dict for insert/update. None for select/delete.
|
|
35
|
+
ctx: The RequestContext (for connection, tenant isolation, etc).
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Row as dict if found/affected, None otherwise.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __call__(
|
|
42
|
+
self,
|
|
43
|
+
table: str,
|
|
44
|
+
operation: str,
|
|
45
|
+
where: dict[str, Any] | None,
|
|
46
|
+
data: dict[str, Any] | None,
|
|
47
|
+
ctx: RequestContext,
|
|
48
|
+
) -> Awaitable[dict[str, Any] | None]: ...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(slots=True)
|
|
52
|
+
class RequestContext(DataClass):
|
|
53
|
+
"""Context for a service request.
|
|
54
|
+
|
|
55
|
+
Carries identity, scope, and metadata through the call chain.
|
|
56
|
+
Extra kwargs become metadata entries accessible as attributes.
|
|
57
|
+
|
|
58
|
+
Core fields (slots):
|
|
59
|
+
name, id, session_id, branch_id, conn, query_fn, now
|
|
60
|
+
|
|
61
|
+
Extension fields (metadata, accessible via attribute access):
|
|
62
|
+
Any kwarg passed to __init__ is stored in metadata and accessible
|
|
63
|
+
as ctx.field_name. Common extensions:
|
|
64
|
+
- tenant_id: Tenant scope (auto-added to WHERE clauses)
|
|
65
|
+
- actor_id: Who is performing the action
|
|
66
|
+
- subject_id: Person subject of the operation
|
|
67
|
+
- correlation_id: Trace ID linking related operations
|
|
68
|
+
- causation_id: Parent operation that caused this
|
|
69
|
+
- charter: Active governance document
|
|
70
|
+
- jurisdictions: Jurisdiction scope tuple
|
|
71
|
+
|
|
72
|
+
Usage:
|
|
73
|
+
ctx = RequestContext(
|
|
74
|
+
name="consent.grant",
|
|
75
|
+
conn=connection,
|
|
76
|
+
tenant_id=tenant_uuid,
|
|
77
|
+
actor_id=actor_uuid,
|
|
78
|
+
subject_id=subject_uuid,
|
|
79
|
+
correlation_id=trace_id,
|
|
80
|
+
)
|
|
81
|
+
ctx.tenant_id # -> tenant_uuid (from metadata)
|
|
82
|
+
ctx.subject_id # -> subject_uuid (from metadata)
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
name: str
|
|
86
|
+
id: UUID = field(default_factory=uuid4)
|
|
87
|
+
session_id: ID[Session] | None = None
|
|
88
|
+
branch_id: ID[Branch] | None = None
|
|
89
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
90
|
+
conn: Any | None = None
|
|
91
|
+
query_fn: QueryFn | None = None
|
|
92
|
+
now: datetime | None = None
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
name: str,
|
|
97
|
+
session_id: ID[Session] | None = None,
|
|
98
|
+
branch_id: ID[Branch] | None = None,
|
|
99
|
+
id: UUID | None = None,
|
|
100
|
+
conn: Any | None = None,
|
|
101
|
+
query_fn: QueryFn | None = None,
|
|
102
|
+
now: datetime | None = None,
|
|
103
|
+
**kwargs: Any,
|
|
104
|
+
):
|
|
105
|
+
self.name = name
|
|
106
|
+
self.id = id or uuid4()
|
|
107
|
+
self.session_id = session_id
|
|
108
|
+
self.branch_id = branch_id
|
|
109
|
+
self.conn = conn
|
|
110
|
+
self.query_fn = query_fn
|
|
111
|
+
self.now = now
|
|
112
|
+
self.metadata = kwargs
|
|
113
|
+
|
|
114
|
+
def __getattr__(self, name: str) -> Any:
|
|
115
|
+
"""Look up unknown attributes in metadata.
|
|
116
|
+
|
|
117
|
+
Called only when normal attribute lookup fails (slots miss).
|
|
118
|
+
Raises AttributeError for keys not present in metadata so that
|
|
119
|
+
hasattr() works correctly.
|
|
120
|
+
"""
|
|
121
|
+
if name.startswith("_"):
|
|
122
|
+
raise AttributeError(name)
|
|
123
|
+
metadata = object.__getattribute__(self, "metadata")
|
|
124
|
+
try:
|
|
125
|
+
return metadata[name]
|
|
126
|
+
except KeyError:
|
|
127
|
+
raise AttributeError(
|
|
128
|
+
f"'{type(self).__name__}' has no attribute '{name}'"
|
|
129
|
+
) from None
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Policy protocols and types.
|
|
5
|
+
|
|
6
|
+
Defines contracts for policy resolution and evaluation.
|
|
7
|
+
Implementations provided by domain libs (e.g., canon-core).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
from kronos.enforcement.context import RequestContext
|
|
17
|
+
from kronos.specs.catalog._enforcement import EnforcementLevel
|
|
18
|
+
from kronos.types.base import DataClass
|
|
19
|
+
|
|
20
|
+
__all__ = (
|
|
21
|
+
"EnforcementLevel",
|
|
22
|
+
"PolicyEngine",
|
|
23
|
+
"PolicyResolver",
|
|
24
|
+
"ResolvedPolicy",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class ResolvedPolicy(DataClass):
|
|
30
|
+
"""A policy resolved for evaluation.
|
|
31
|
+
|
|
32
|
+
Returned by PolicyResolver.resolve(). Contains policy ID and
|
|
33
|
+
any resolution metadata needed by the engine.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
policy_id: str
|
|
37
|
+
enforcement: str = EnforcementLevel.HARD_MANDATORY.value
|
|
38
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@runtime_checkable
|
|
42
|
+
class PolicyEngine(Protocol):
|
|
43
|
+
"""Abstract policy evaluation engine.
|
|
44
|
+
|
|
45
|
+
kron defines the contract. Implementations:
|
|
46
|
+
- canon-core: OPAEngine (Rego/Regorus evaluation)
|
|
47
|
+
- Testing: MockPolicyEngine
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
async def evaluate(
|
|
51
|
+
self,
|
|
52
|
+
policy_id: str,
|
|
53
|
+
input_data: dict[str, Any],
|
|
54
|
+
**options: Any,
|
|
55
|
+
) -> Any:
|
|
56
|
+
"""Evaluate a single policy against input."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
async def evaluate_batch(
|
|
60
|
+
self,
|
|
61
|
+
policy_ids: Sequence[str],
|
|
62
|
+
input_data: dict[str, Any],
|
|
63
|
+
**options: Any,
|
|
64
|
+
) -> list[Any]:
|
|
65
|
+
"""Evaluate multiple policies."""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@runtime_checkable
|
|
70
|
+
class PolicyResolver(Protocol):
|
|
71
|
+
"""Resolves which policies apply to a given context.
|
|
72
|
+
|
|
73
|
+
kron defines the contract. Implementations:
|
|
74
|
+
- canon-core: CharteredResolver (charter-based resolution)
|
|
75
|
+
- Testing: MockPolicyResolver, StaticPolicyResolver
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def resolve(self, ctx: RequestContext) -> Sequence[ResolvedPolicy]:
|
|
79
|
+
"""Determine applicable policies for context."""
|
|
80
|
+
...
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .rule import Rule
|
|
10
|
+
|
|
11
|
+
__all__ = ("RuleRegistry", "get_default_registry")
|
|
12
|
+
|
|
13
|
+
_default_registry: RuleRegistry | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RuleRegistry:
|
|
17
|
+
"""Registry mapping types to Rule classes/instances.
|
|
18
|
+
|
|
19
|
+
Provides automatic Rule assignment based on Spec.base_type.
|
|
20
|
+
Supports inheritance-based lookup (subclasses inherit parent rules).
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
registry = RuleRegistry()
|
|
24
|
+
registry.register(str, StringRule(min_length=1))
|
|
25
|
+
registry.register(int, NumberRule(ge=0))
|
|
26
|
+
|
|
27
|
+
rule = registry.get_rule(str) # Returns StringRule instance
|
|
28
|
+
rule = registry.get_rule(MyStr) # Returns StringRule (inherited)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
"""Initialize empty registry."""
|
|
33
|
+
self._type_rules: dict[type, Rule] = {}
|
|
34
|
+
self._name_rules: dict[str, Rule] = {}
|
|
35
|
+
|
|
36
|
+
def register(
|
|
37
|
+
self,
|
|
38
|
+
key: type | str,
|
|
39
|
+
rule: Rule,
|
|
40
|
+
*,
|
|
41
|
+
replace: bool = False,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Register a Rule for a type or field name.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
key: Type or field name to register
|
|
47
|
+
rule: Rule instance to use
|
|
48
|
+
replace: Allow replacing existing registration
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ValueError: If key already registered and replace=False
|
|
52
|
+
"""
|
|
53
|
+
if isinstance(key, str):
|
|
54
|
+
if key in self._name_rules and not replace:
|
|
55
|
+
raise ValueError(f"Rule already registered for field '{key}'")
|
|
56
|
+
self._name_rules[key] = rule
|
|
57
|
+
else:
|
|
58
|
+
if key in self._type_rules and not replace:
|
|
59
|
+
raise ValueError(f"Rule already registered for type {key}")
|
|
60
|
+
self._type_rules[key] = rule
|
|
61
|
+
|
|
62
|
+
def get_rule(
|
|
63
|
+
self,
|
|
64
|
+
base_type: type | None = None,
|
|
65
|
+
field_name: str | None = None,
|
|
66
|
+
) -> Rule | None:
|
|
67
|
+
"""Get Rule for a type or field name.
|
|
68
|
+
|
|
69
|
+
Priority:
|
|
70
|
+
1. Exact field name match
|
|
71
|
+
2. Exact type match
|
|
72
|
+
3. Inheritance-based type match (check base classes)
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
base_type: Type to look up
|
|
76
|
+
field_name: Field name to look up
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Rule instance or None if not found
|
|
80
|
+
"""
|
|
81
|
+
if field_name and field_name in self._name_rules:
|
|
82
|
+
return self._name_rules[field_name]
|
|
83
|
+
|
|
84
|
+
if base_type and base_type in self._type_rules:
|
|
85
|
+
return self._type_rules[base_type]
|
|
86
|
+
|
|
87
|
+
if base_type:
|
|
88
|
+
for registered_type, rule in self._type_rules.items():
|
|
89
|
+
try:
|
|
90
|
+
if issubclass(base_type, registered_type):
|
|
91
|
+
return rule
|
|
92
|
+
except TypeError:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
def has_rule(self, key: type | str) -> bool:
|
|
98
|
+
"""Check if a rule is registered for type or name."""
|
|
99
|
+
if isinstance(key, str):
|
|
100
|
+
return key in self._name_rules
|
|
101
|
+
return key in self._type_rules or any(
|
|
102
|
+
issubclass(key, t) for t in self._type_rules if isinstance(key, type)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def list_types(self) -> list[type]:
|
|
106
|
+
"""List all registered types."""
|
|
107
|
+
return list(self._type_rules.keys())
|
|
108
|
+
|
|
109
|
+
def list_names(self) -> list[str]:
|
|
110
|
+
"""List all registered field names."""
|
|
111
|
+
return list(self._name_rules.keys())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_default_registry() -> RuleRegistry:
|
|
115
|
+
"""Get the default rule registry with standard rules.
|
|
116
|
+
|
|
117
|
+
Standard mappings:
|
|
118
|
+
str -> StringRule
|
|
119
|
+
int -> NumberRule
|
|
120
|
+
float -> NumberRule
|
|
121
|
+
bool -> BooleanRule
|
|
122
|
+
dict -> MappingRule
|
|
123
|
+
BaseModel -> BaseModelRule (catches all Pydantic models)
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
RuleRegistry with default rules registered
|
|
127
|
+
"""
|
|
128
|
+
global _default_registry
|
|
129
|
+
|
|
130
|
+
if _default_registry is None:
|
|
131
|
+
from pydantic import BaseModel
|
|
132
|
+
|
|
133
|
+
from .common.boolean import BooleanRule
|
|
134
|
+
from .common.mapping import MappingRule
|
|
135
|
+
from .common.model import BaseModelRule
|
|
136
|
+
from .common.number import NumberRule
|
|
137
|
+
from .common.string import StringRule
|
|
138
|
+
|
|
139
|
+
_default_registry = RuleRegistry()
|
|
140
|
+
_default_registry.register(str, StringRule())
|
|
141
|
+
_default_registry.register(int, NumberRule())
|
|
142
|
+
_default_registry.register(float, NumberRule())
|
|
143
|
+
_default_registry.register(bool, BooleanRule())
|
|
144
|
+
_default_registry.register(dict, MappingRule())
|
|
145
|
+
_default_registry.register(BaseModel, BaseModelRule())
|
|
146
|
+
|
|
147
|
+
return _default_registry
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def reset_default_registry() -> None:
|
|
151
|
+
"""Reset the default registry (for testing)."""
|
|
152
|
+
global _default_registry
|
|
153
|
+
_default_registry = None
|