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,312 @@
|
|
|
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 abc import abstractmethod
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import IntEnum, auto
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from kronos.errors import ValidationError
|
|
12
|
+
from kronos.types import Params
|
|
13
|
+
|
|
14
|
+
__all__ = ("Rule", "RuleParams", "RuleQualifier", "ValidationError")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RuleQualifier(IntEnum):
|
|
18
|
+
"""Qualifier types for rules - determines WHEN a rule applies.
|
|
19
|
+
|
|
20
|
+
- FIELD: Match by field name (e.g., "confidence", "output")
|
|
21
|
+
- ANNOTATION: Match by type annotation (e.g., str, int, float)
|
|
22
|
+
- CONDITION: Match by custom condition (e.g., is BaseModel subclass)
|
|
23
|
+
|
|
24
|
+
Default precedence order: FIELD > ANNOTATION > CONDITION
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
FIELD = auto()
|
|
28
|
+
ANNOTATION = auto()
|
|
29
|
+
CONDITION = auto()
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_str(cls, s: str) -> RuleQualifier:
|
|
33
|
+
"""Convert string to RuleQualifier."""
|
|
34
|
+
s = s.strip().upper()
|
|
35
|
+
if s == "FIELD":
|
|
36
|
+
return cls.FIELD
|
|
37
|
+
elif s == "ANNOTATION":
|
|
38
|
+
return cls.ANNOTATION
|
|
39
|
+
elif s == "CONDITION":
|
|
40
|
+
return cls.CONDITION
|
|
41
|
+
else:
|
|
42
|
+
raise ValueError(f"Unknown RuleQualifier: {s}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _decide_qualifier_order(
|
|
46
|
+
qualifier: str | RuleQualifier | None = None,
|
|
47
|
+
) -> list[RuleQualifier]:
|
|
48
|
+
"""Determine qualifier precedence order (default: FIELD > ANNOTATION > CONDITION).
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
qualifier: Preferred qualifier moved to front of order. None uses default.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
List of RuleQualifier in precedence order.
|
|
55
|
+
"""
|
|
56
|
+
default_order = [
|
|
57
|
+
RuleQualifier.FIELD,
|
|
58
|
+
RuleQualifier.ANNOTATION,
|
|
59
|
+
RuleQualifier.CONDITION,
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
if qualifier is None:
|
|
63
|
+
return default_order
|
|
64
|
+
|
|
65
|
+
if isinstance(qualifier, str):
|
|
66
|
+
qualifier = RuleQualifier.from_str(qualifier)
|
|
67
|
+
|
|
68
|
+
default_order.remove(qualifier)
|
|
69
|
+
return [qualifier, *default_order]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(slots=True, frozen=True)
|
|
73
|
+
class RuleParams(Params):
|
|
74
|
+
"""Immutable configuration for rules.
|
|
75
|
+
|
|
76
|
+
Defines:
|
|
77
|
+
- WHAT the rule applies to (types or fields)
|
|
78
|
+
- HOW to determine applicability (default_qualifier)
|
|
79
|
+
- WHETHER to auto-fix (auto_fix)
|
|
80
|
+
- ADDITIONAL validation parameters (kw)
|
|
81
|
+
|
|
82
|
+
Uses kron.types.Params (leaner than Pydantic BaseModel).
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
apply_types: set[type] = field(default_factory=set)
|
|
86
|
+
"""Types this rule applies to (e.g., {str, int})"""
|
|
87
|
+
|
|
88
|
+
apply_fields: set[str] = field(default_factory=set)
|
|
89
|
+
"""Field names this rule applies to (e.g., {"confidence", "output"})"""
|
|
90
|
+
|
|
91
|
+
default_qualifier: RuleQualifier = RuleQualifier.FIELD
|
|
92
|
+
"""Preferred qualifier type"""
|
|
93
|
+
|
|
94
|
+
auto_fix: bool = False
|
|
95
|
+
"""Enable automatic fixing on validation failure"""
|
|
96
|
+
|
|
97
|
+
kw: dict = field(default_factory=dict)
|
|
98
|
+
"""Additional validation parameters"""
|
|
99
|
+
|
|
100
|
+
def __post_init__(self) -> None:
|
|
101
|
+
"""Validate after dataclass initialization."""
|
|
102
|
+
self._validate()
|
|
103
|
+
|
|
104
|
+
def _validate(self) -> None:
|
|
105
|
+
"""Validate params consistency (extensible hook for subclasses).
|
|
106
|
+
|
|
107
|
+
Rules use multiple qualifier mechanisms (OR matching):
|
|
108
|
+
- apply_types: ANNOTATION qualifier (type-based)
|
|
109
|
+
- apply_fields: FIELD qualifier (name-based)
|
|
110
|
+
- rule_condition(): CONDITION qualifier (custom logic)
|
|
111
|
+
|
|
112
|
+
Empty sets are valid for explicit/manual rule invocation.
|
|
113
|
+
"""
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Rule:
|
|
118
|
+
"""Base validation rule with auto-correction support.
|
|
119
|
+
|
|
120
|
+
Pattern from lionagi v0.2.2 + lionherd-old:
|
|
121
|
+
1. apply() - Does this rule apply? (uses qualifiers)
|
|
122
|
+
2. validate() - Is the value valid? (abstract, subclass implements)
|
|
123
|
+
3. perform_fix() - Can we auto-correct? (optional, if auto_fix=True)
|
|
124
|
+
|
|
125
|
+
Usage:
|
|
126
|
+
# Create rule
|
|
127
|
+
rule = StringRule(min_length=1, max_length=100)
|
|
128
|
+
|
|
129
|
+
# Check if applies
|
|
130
|
+
if await rule.apply("name", "Ocean", str):
|
|
131
|
+
# Validate + fix
|
|
132
|
+
result = await rule.invoke("name", "Ocean", str)
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(self, params: RuleParams, **kw):
|
|
136
|
+
"""Initialize rule with parameters.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
params: Rule configuration
|
|
140
|
+
**kw: Additional validation kwargs (merged with params.kw)
|
|
141
|
+
"""
|
|
142
|
+
if kw:
|
|
143
|
+
# Merge additional kwargs using with_updates
|
|
144
|
+
params = params.with_updates(kw={**params.kw, **kw})
|
|
145
|
+
self.params = params
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def apply_types(self) -> set[type]:
|
|
149
|
+
"""Types this rule applies to (ANNOTATION qualifier)."""
|
|
150
|
+
return self.params.apply_types
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def apply_fields(self) -> set[str]:
|
|
154
|
+
"""Field names this rule applies to (FIELD qualifier)."""
|
|
155
|
+
return self.params.apply_fields
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def default_qualifier(self) -> RuleQualifier:
|
|
159
|
+
"""Preferred qualifier type for apply() precedence."""
|
|
160
|
+
return self.params.default_qualifier
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def auto_fix(self) -> bool:
|
|
164
|
+
"""Whether perform_fix() is called on validation failure."""
|
|
165
|
+
return self.params.auto_fix
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def validation_kwargs(self) -> dict:
|
|
169
|
+
"""Additional parameters passed to validate()."""
|
|
170
|
+
return self.params.kw
|
|
171
|
+
|
|
172
|
+
async def rule_condition(self, k: str, v: Any, t: type, **kw) -> bool:
|
|
173
|
+
"""Custom condition for CONDITION qualifier.
|
|
174
|
+
|
|
175
|
+
Override in subclass to use CONDITION qualifier.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
k: Field name
|
|
179
|
+
v: Field value
|
|
180
|
+
t: Field type
|
|
181
|
+
**kw: Additional kwargs
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
True if rule should apply
|
|
185
|
+
"""
|
|
186
|
+
raise NotImplementedError(
|
|
187
|
+
f"{self.__class__.__name__} must implement rule_condition() to use CONDITION qualifier"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
async def _apply(self, k: str, v: Any, t: type, q: RuleQualifier, **kw) -> bool:
|
|
191
|
+
"""Determine if rule applies based on qualifier.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
k: Field name
|
|
195
|
+
v: Field value
|
|
196
|
+
t: Field type
|
|
197
|
+
q: Qualifier type
|
|
198
|
+
**kw: Additional kwargs
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
True if rule applies
|
|
202
|
+
"""
|
|
203
|
+
match q:
|
|
204
|
+
case RuleQualifier.FIELD:
|
|
205
|
+
return k in self.apply_fields
|
|
206
|
+
|
|
207
|
+
case RuleQualifier.ANNOTATION:
|
|
208
|
+
return t in self.apply_types
|
|
209
|
+
|
|
210
|
+
case RuleQualifier.CONDITION:
|
|
211
|
+
return await self.rule_condition(k, v, t, **kw)
|
|
212
|
+
|
|
213
|
+
async def apply(
|
|
214
|
+
self,
|
|
215
|
+
k: str,
|
|
216
|
+
v: Any,
|
|
217
|
+
t: type | None = None,
|
|
218
|
+
qualifier: str | RuleQualifier | None = None,
|
|
219
|
+
**kw,
|
|
220
|
+
) -> bool:
|
|
221
|
+
"""Check if rule applies using qualifier precedence.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
k: Field name
|
|
225
|
+
v: Field value
|
|
226
|
+
t: Field type (optional)
|
|
227
|
+
qualifier: Override default qualifier order
|
|
228
|
+
**kw: Additional kwargs for condition checking
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
True if rule applies (any qualifier matches)
|
|
232
|
+
"""
|
|
233
|
+
_order = _decide_qualifier_order(qualifier)
|
|
234
|
+
|
|
235
|
+
for q in _order:
|
|
236
|
+
try:
|
|
237
|
+
if await self._apply(k, v, t or type(v), q, **kw):
|
|
238
|
+
return True
|
|
239
|
+
except NotImplementedError:
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
@abstractmethod
|
|
245
|
+
async def validate(self, v: Any, t: type, **kw) -> None:
|
|
246
|
+
"""Validate value (abstract, implement in subclass).
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
v: Value to validate
|
|
250
|
+
t: Expected type
|
|
251
|
+
**kw: Additional validation parameters
|
|
252
|
+
|
|
253
|
+
Raises:
|
|
254
|
+
Exception: If validation fails
|
|
255
|
+
"""
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
async def perform_fix(self, v: Any, t: type) -> Any:
|
|
259
|
+
"""Attempt to fix invalid value (optional, override in subclass).
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
v: Value to fix
|
|
263
|
+
t: Expected type
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Fixed value
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
NotImplementedError: If auto_fix=True but perform_fix not implemented
|
|
270
|
+
"""
|
|
271
|
+
raise NotImplementedError(
|
|
272
|
+
f"{self.__class__.__name__} must implement perform_fix() to use auto_fix=True"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
async def invoke(
|
|
276
|
+
self, k: str, v: Any, t: type | None = None, *, auto_fix: bool | None = None
|
|
277
|
+
) -> Any:
|
|
278
|
+
"""Execute validation with optional auto-fixing.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
k: Field name (for error messages)
|
|
282
|
+
v: Value to validate
|
|
283
|
+
t: Field type (optional)
|
|
284
|
+
auto_fix: Override self.auto_fix for this invocation (thread-safe)
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Validated (and possibly fixed) value
|
|
288
|
+
|
|
289
|
+
Raises:
|
|
290
|
+
ValidationError: If validation fails and auto_fix disabled
|
|
291
|
+
"""
|
|
292
|
+
effective_type = t or type(v)
|
|
293
|
+
should_auto_fix = auto_fix if auto_fix is not None else self.auto_fix
|
|
294
|
+
try:
|
|
295
|
+
await self.validate(v, effective_type, **self.validation_kwargs)
|
|
296
|
+
return v
|
|
297
|
+
except Exception as e:
|
|
298
|
+
if should_auto_fix:
|
|
299
|
+
try:
|
|
300
|
+
return await self.perform_fix(v, effective_type)
|
|
301
|
+
except Exception as e1:
|
|
302
|
+
raise ValidationError(f"Failed to fix field '{k}': {e1}") from e
|
|
303
|
+
raise ValidationError(f"Failed to validate field '{k}': {e}") from e
|
|
304
|
+
|
|
305
|
+
def __repr__(self) -> str:
|
|
306
|
+
"""String representation."""
|
|
307
|
+
return (
|
|
308
|
+
f"{self.__class__.__name__}("
|
|
309
|
+
f"types={self.apply_types}, "
|
|
310
|
+
f"fields={self.apply_fields}, "
|
|
311
|
+
f"auto_fix={self.auto_fix})"
|
|
312
|
+
)
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""KronService - typed action handlers with policy evaluation."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import Field, PrivateAttr
|
|
14
|
+
|
|
15
|
+
from kronos.services import ServiceBackend, ServiceConfig
|
|
16
|
+
|
|
17
|
+
from .context import RequestContext
|
|
18
|
+
from .policy import EnforcementLevel, PolicyEngine, PolicyResolver
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
__all__ = (
|
|
23
|
+
"ActionMeta",
|
|
24
|
+
"KronConfig",
|
|
25
|
+
"KronService",
|
|
26
|
+
"action",
|
|
27
|
+
"get_action_meta",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class KronConfig(ServiceConfig):
|
|
32
|
+
"""Configuration for KronService.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
operable: Canonical Operable containing all field specs for this service.
|
|
36
|
+
action_timeout: Timeout for action execution (None = no timeout).
|
|
37
|
+
use_policies: Enable policy evaluation.
|
|
38
|
+
policy_timeout: Timeout for policy evaluation.
|
|
39
|
+
fail_open_on_engine_error: Allow action if engine fails (DANGEROUS).
|
|
40
|
+
hooks: Available hooks {name: callable}.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
operable: Any = None
|
|
44
|
+
"""Canonical Operable for the service's field namespace."""
|
|
45
|
+
|
|
46
|
+
action_timeout: float | None = None
|
|
47
|
+
"""Timeout for action execution in seconds. None means no timeout."""
|
|
48
|
+
|
|
49
|
+
use_policies: bool = True
|
|
50
|
+
"""Enable policy evaluation."""
|
|
51
|
+
|
|
52
|
+
policy_timeout: float = 10.0
|
|
53
|
+
"""Timeout for policy evaluation in seconds."""
|
|
54
|
+
|
|
55
|
+
fail_open_on_engine_error: bool = False
|
|
56
|
+
"""If True, allow action when engine fails. DANGEROUS for production."""
|
|
57
|
+
|
|
58
|
+
hooks: dict[str, Callable[..., Awaitable[Any]]] = Field(default_factory=dict)
|
|
59
|
+
"""Available hooks {name: hook_function}."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_ACTION_ATTR = "_kron_action"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class ActionMeta:
|
|
67
|
+
"""Metadata for an action handler.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
name: Action identifier (e.g., "consent.grant").
|
|
71
|
+
inputs: Field names from service operable used as inputs.
|
|
72
|
+
outputs: Field names from service operable used as outputs.
|
|
73
|
+
pre_hooks: Hook names to run before action.
|
|
74
|
+
post_hooks: Hook names to run after action.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
name: str
|
|
78
|
+
inputs: frozenset[str] = frozenset()
|
|
79
|
+
outputs: frozenset[str] = frozenset()
|
|
80
|
+
pre_hooks: tuple[str, ...] = ()
|
|
81
|
+
post_hooks: tuple[str, ...] = ()
|
|
82
|
+
|
|
83
|
+
# Lazily computed types (set by service at registration)
|
|
84
|
+
_options_type: Any = None
|
|
85
|
+
_result_type: Any = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def action(
|
|
89
|
+
name: str,
|
|
90
|
+
inputs: set[str] | None = None,
|
|
91
|
+
outputs: set[str] | None = None,
|
|
92
|
+
pre_hooks: list[str] | None = None,
|
|
93
|
+
post_hooks: list[str] | None = None,
|
|
94
|
+
) -> Callable[[Callable], Callable]:
|
|
95
|
+
"""Decorator to declare action handler metadata.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
name: Action identifier (e.g., "consent.grant").
|
|
99
|
+
inputs: Field names from service operable used as inputs.
|
|
100
|
+
outputs: Field names from service operable used as outputs.
|
|
101
|
+
pre_hooks: Hook names to run before action.
|
|
102
|
+
post_hooks: Hook names to run after action.
|
|
103
|
+
|
|
104
|
+
Usage:
|
|
105
|
+
@action(
|
|
106
|
+
name="consent.grant",
|
|
107
|
+
inputs={"permissions", "subject_id"},
|
|
108
|
+
outputs={"consent_id", "granted_at"},
|
|
109
|
+
)
|
|
110
|
+
async def _handle_grant(self, options, ctx):
|
|
111
|
+
...
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def decorator(func: Callable) -> Callable:
|
|
115
|
+
meta = ActionMeta(
|
|
116
|
+
name=name,
|
|
117
|
+
inputs=frozenset(inputs or set()),
|
|
118
|
+
outputs=frozenset(outputs or set()),
|
|
119
|
+
pre_hooks=tuple(pre_hooks or []),
|
|
120
|
+
post_hooks=tuple(post_hooks or []),
|
|
121
|
+
)
|
|
122
|
+
setattr(func, _ACTION_ATTR, meta)
|
|
123
|
+
return func
|
|
124
|
+
|
|
125
|
+
return decorator
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_action_meta(handler: Callable) -> ActionMeta | None:
|
|
129
|
+
"""Get action metadata from a handler method."""
|
|
130
|
+
return getattr(handler, _ACTION_ATTR, None)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# =============================================================================
|
|
134
|
+
# KronService
|
|
135
|
+
# =============================================================================
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class KronService(ServiceBackend):
|
|
139
|
+
"""Service backend with typed actions.
|
|
140
|
+
|
|
141
|
+
Subclasses implement action handlers with @action decorator.
|
|
142
|
+
Actions derive typed I/O from service's canonical operable.
|
|
143
|
+
|
|
144
|
+
Example:
|
|
145
|
+
class ConsentService(KronService):
|
|
146
|
+
config = KronConfig(
|
|
147
|
+
name="consent",
|
|
148
|
+
operable=Operable([
|
|
149
|
+
Spec("permissions", list[str]),
|
|
150
|
+
Spec("consent_id", UUID),
|
|
151
|
+
Spec("granted_at", datetime),
|
|
152
|
+
Spec("subject_id", FK[Subject]),
|
|
153
|
+
]),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
@action(
|
|
157
|
+
name="consent.grant",
|
|
158
|
+
inputs={"permissions", "subject_id"},
|
|
159
|
+
outputs={"consent_id", "granted_at"},
|
|
160
|
+
)
|
|
161
|
+
async def _handle_grant(self, options, ctx):
|
|
162
|
+
...
|
|
163
|
+
|
|
164
|
+
service = ConsentService()
|
|
165
|
+
result = await service.call("consent.grant", options, ctx)
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
config: KronConfig = Field(default_factory=KronConfig)
|
|
169
|
+
_policy_engine: PolicyEngine | None = PrivateAttr(default=None)
|
|
170
|
+
_policy_resolver: PolicyResolver | None = PrivateAttr(default=None)
|
|
171
|
+
_action_registry: dict[str, tuple[Callable, ActionMeta]] = PrivateAttr(default_factory=dict)
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
config: KronConfig | None = None,
|
|
176
|
+
policy_engine: PolicyEngine | None = None,
|
|
177
|
+
policy_resolver: PolicyResolver | None = None,
|
|
178
|
+
**kwargs: Any,
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Initialize service with optional policy engine and resolver.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
config: Service configuration.
|
|
184
|
+
policy_engine: PolicyEngine for policy evaluation.
|
|
185
|
+
policy_resolver: PolicyResolver for determining applicable policies.
|
|
186
|
+
"""
|
|
187
|
+
super().__init__(config=config, **kwargs)
|
|
188
|
+
self._policy_engine = policy_engine
|
|
189
|
+
self._policy_resolver = policy_resolver
|
|
190
|
+
self._action_registry = {}
|
|
191
|
+
self._register_actions()
|
|
192
|
+
|
|
193
|
+
def _register_actions(self) -> None:
|
|
194
|
+
"""Scan for @action decorated methods and register them."""
|
|
195
|
+
for name in dir(self):
|
|
196
|
+
if name.startswith("_"):
|
|
197
|
+
method = getattr(self, name, None)
|
|
198
|
+
if method and callable(method):
|
|
199
|
+
meta = get_action_meta(method)
|
|
200
|
+
if meta:
|
|
201
|
+
self._action_registry[meta.name] = (method, meta)
|
|
202
|
+
self._build_action_types(meta)
|
|
203
|
+
|
|
204
|
+
def _build_action_types(self, meta: ActionMeta) -> None:
|
|
205
|
+
"""Build options_type and result_type for an action from service operable."""
|
|
206
|
+
if not self.config.operable:
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
operable = self.config.operable
|
|
210
|
+
|
|
211
|
+
# Validate inputs/outputs exist in operable
|
|
212
|
+
allowed = operable.allowed()
|
|
213
|
+
invalid_inputs = meta.inputs - allowed
|
|
214
|
+
invalid_outputs = meta.outputs - allowed
|
|
215
|
+
|
|
216
|
+
if invalid_inputs:
|
|
217
|
+
logger.warning(
|
|
218
|
+
"Action '%s' has inputs not in operable: %s",
|
|
219
|
+
meta.name,
|
|
220
|
+
invalid_inputs,
|
|
221
|
+
)
|
|
222
|
+
if invalid_outputs:
|
|
223
|
+
logger.warning(
|
|
224
|
+
"Action '%s' has outputs not in operable: %s",
|
|
225
|
+
meta.name,
|
|
226
|
+
invalid_outputs,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Build typed structures (frozen dataclasses)
|
|
230
|
+
if meta.inputs:
|
|
231
|
+
options_type = operable.compose_structure(
|
|
232
|
+
_to_pascal(meta.name) + "Options",
|
|
233
|
+
include=set(meta.inputs),
|
|
234
|
+
frozen=True,
|
|
235
|
+
)
|
|
236
|
+
object.__setattr__(meta, "_options_type", options_type)
|
|
237
|
+
|
|
238
|
+
if meta.outputs:
|
|
239
|
+
result_type = operable.compose_structure(
|
|
240
|
+
_to_pascal(meta.name) + "Result",
|
|
241
|
+
include=set(meta.outputs),
|
|
242
|
+
frozen=True,
|
|
243
|
+
)
|
|
244
|
+
object.__setattr__(meta, "_result_type", result_type)
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def has_engine(self) -> bool:
|
|
248
|
+
"""True if policy engine is configured."""
|
|
249
|
+
return self._policy_engine is not None
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def has_resolver(self) -> bool:
|
|
253
|
+
"""True if policy resolver is configured."""
|
|
254
|
+
return self._policy_resolver is not None
|
|
255
|
+
|
|
256
|
+
async def call(
|
|
257
|
+
self,
|
|
258
|
+
name: str,
|
|
259
|
+
options: Any,
|
|
260
|
+
ctx: RequestContext,
|
|
261
|
+
) -> Any:
|
|
262
|
+
"""Call an action by name.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
name: Action name (e.g., "consent.grant").
|
|
266
|
+
options: Input data (dict or typed dataclass).
|
|
267
|
+
ctx: Request context.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
Action result.
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
ValueError: If action not found.
|
|
274
|
+
PermissionError: If policy blocks action.
|
|
275
|
+
"""
|
|
276
|
+
handler, meta = self._fetch_handler(name)
|
|
277
|
+
|
|
278
|
+
# Update context
|
|
279
|
+
ctx.name = name
|
|
280
|
+
|
|
281
|
+
# Run pre-hooks
|
|
282
|
+
await self._run_hooks(meta.pre_hooks, options, ctx)
|
|
283
|
+
|
|
284
|
+
# Evaluate policies
|
|
285
|
+
if self.config.use_policies and self._policy_engine:
|
|
286
|
+
await self._evaluate_policies(ctx)
|
|
287
|
+
|
|
288
|
+
# Validate options if we have typed options_type
|
|
289
|
+
if meta._options_type and self.config.operable:
|
|
290
|
+
options = self.config.operable.validate_instance(meta._options_type, options)
|
|
291
|
+
|
|
292
|
+
# Execute handler
|
|
293
|
+
result = await handler(options, ctx)
|
|
294
|
+
|
|
295
|
+
# Run post-hooks
|
|
296
|
+
await self._run_hooks(meta.post_hooks, options, ctx, result=result)
|
|
297
|
+
|
|
298
|
+
return result
|
|
299
|
+
|
|
300
|
+
def _fetch_handler(self, name: str) -> tuple[Callable, ActionMeta]:
|
|
301
|
+
"""Fetch handler and metadata by action name.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
name: Action name.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
Tuple of (handler, ActionMeta).
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
ValueError: If action not found.
|
|
311
|
+
"""
|
|
312
|
+
if name not in self._action_registry:
|
|
313
|
+
raise ValueError(f"Unknown action: {name}")
|
|
314
|
+
return self._action_registry[name]
|
|
315
|
+
|
|
316
|
+
async def _run_hooks(
|
|
317
|
+
self,
|
|
318
|
+
hook_names: tuple[str, ...],
|
|
319
|
+
options: Any,
|
|
320
|
+
ctx: RequestContext,
|
|
321
|
+
result: Any = None,
|
|
322
|
+
) -> None:
|
|
323
|
+
"""Run named hooks from config.hooks."""
|
|
324
|
+
for hook_name in hook_names:
|
|
325
|
+
hook_fn = self.config.hooks.get(hook_name)
|
|
326
|
+
if hook_fn:
|
|
327
|
+
try:
|
|
328
|
+
await hook_fn(self, options, ctx, result)
|
|
329
|
+
except Exception as e:
|
|
330
|
+
logger.error("Hook '%s' failed: %s", hook_name, e)
|
|
331
|
+
else:
|
|
332
|
+
logger.warning("Hook '%s' not found in config.hooks", hook_name)
|
|
333
|
+
|
|
334
|
+
async def _evaluate_policies(self, ctx: RequestContext) -> None:
|
|
335
|
+
"""Evaluate policies via engine."""
|
|
336
|
+
if not self._policy_engine or not self._policy_resolver:
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
resolved = self._policy_resolver.resolve(ctx)
|
|
341
|
+
|
|
342
|
+
if not resolved:
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
policy_ids = [p.policy_id for p in resolved]
|
|
346
|
+
input_data = ctx.to_dict()
|
|
347
|
+
|
|
348
|
+
results = await self._policy_engine.evaluate_batch(policy_ids, input_data)
|
|
349
|
+
|
|
350
|
+
for result in results:
|
|
351
|
+
if EnforcementLevel.is_blocking(result):
|
|
352
|
+
raise PermissionError(f"Policy {result.policy_id} blocked: {result.message}")
|
|
353
|
+
|
|
354
|
+
except PermissionError:
|
|
355
|
+
raise
|
|
356
|
+
except Exception as e:
|
|
357
|
+
logger.error("Policy evaluation failed: %s", e)
|
|
358
|
+
if not self.config.fail_open_on_engine_error:
|
|
359
|
+
raise PermissionError(f"Policy engine error: {e}")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _to_pascal(name: str) -> str:
|
|
363
|
+
"""Convert action name to PascalCase.
|
|
364
|
+
|
|
365
|
+
consent.grant -> ConsentGrant
|
|
366
|
+
consent_grant -> ConsentGrant
|
|
367
|
+
"""
|
|
368
|
+
# Replace dots and underscores, capitalize each part
|
|
369
|
+
parts = name.replace(".", "_").split("_")
|
|
370
|
+
return "".join(part.capitalize() for part in parts)
|