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.
Files changed (101) hide show
  1. kronos/__init__.py +0 -0
  2. kronos/core/__init__.py +145 -0
  3. kronos/core/broadcaster.py +116 -0
  4. kronos/core/element.py +225 -0
  5. kronos/core/event.py +316 -0
  6. kronos/core/eventbus.py +116 -0
  7. kronos/core/flow.py +356 -0
  8. kronos/core/graph.py +442 -0
  9. kronos/core/node.py +982 -0
  10. kronos/core/pile.py +575 -0
  11. kronos/core/processor.py +494 -0
  12. kronos/core/progression.py +296 -0
  13. kronos/enforcement/__init__.py +57 -0
  14. kronos/enforcement/common/__init__.py +34 -0
  15. kronos/enforcement/common/boolean.py +85 -0
  16. kronos/enforcement/common/choice.py +97 -0
  17. kronos/enforcement/common/mapping.py +118 -0
  18. kronos/enforcement/common/model.py +102 -0
  19. kronos/enforcement/common/number.py +98 -0
  20. kronos/enforcement/common/string.py +140 -0
  21. kronos/enforcement/context.py +129 -0
  22. kronos/enforcement/policy.py +80 -0
  23. kronos/enforcement/registry.py +153 -0
  24. kronos/enforcement/rule.py +312 -0
  25. kronos/enforcement/service.py +370 -0
  26. kronos/enforcement/validator.py +198 -0
  27. kronos/errors.py +146 -0
  28. kronos/operations/__init__.py +32 -0
  29. kronos/operations/builder.py +228 -0
  30. kronos/operations/flow.py +398 -0
  31. kronos/operations/node.py +101 -0
  32. kronos/operations/registry.py +92 -0
  33. kronos/protocols.py +414 -0
  34. kronos/py.typed +0 -0
  35. kronos/services/__init__.py +81 -0
  36. kronos/services/backend.py +286 -0
  37. kronos/services/endpoint.py +608 -0
  38. kronos/services/hook.py +471 -0
  39. kronos/services/imodel.py +465 -0
  40. kronos/services/registry.py +115 -0
  41. kronos/services/utilities/__init__.py +36 -0
  42. kronos/services/utilities/header_factory.py +87 -0
  43. kronos/services/utilities/rate_limited_executor.py +271 -0
  44. kronos/services/utilities/rate_limiter.py +180 -0
  45. kronos/services/utilities/resilience.py +414 -0
  46. kronos/session/__init__.py +41 -0
  47. kronos/session/exchange.py +258 -0
  48. kronos/session/message.py +60 -0
  49. kronos/session/session.py +411 -0
  50. kronos/specs/__init__.py +25 -0
  51. kronos/specs/adapters/__init__.py +0 -0
  52. kronos/specs/adapters/_utils.py +45 -0
  53. kronos/specs/adapters/dataclass_field.py +246 -0
  54. kronos/specs/adapters/factory.py +56 -0
  55. kronos/specs/adapters/pydantic_adapter.py +309 -0
  56. kronos/specs/adapters/sql_ddl.py +946 -0
  57. kronos/specs/catalog/__init__.py +36 -0
  58. kronos/specs/catalog/_audit.py +39 -0
  59. kronos/specs/catalog/_common.py +43 -0
  60. kronos/specs/catalog/_content.py +59 -0
  61. kronos/specs/catalog/_enforcement.py +70 -0
  62. kronos/specs/factory.py +120 -0
  63. kronos/specs/operable.py +314 -0
  64. kronos/specs/phrase.py +405 -0
  65. kronos/specs/protocol.py +140 -0
  66. kronos/specs/spec.py +506 -0
  67. kronos/types/__init__.py +60 -0
  68. kronos/types/_sentinel.py +311 -0
  69. kronos/types/base.py +369 -0
  70. kronos/types/db_types.py +260 -0
  71. kronos/types/identity.py +66 -0
  72. kronos/utils/__init__.py +40 -0
  73. kronos/utils/_hash.py +234 -0
  74. kronos/utils/_json_dump.py +392 -0
  75. kronos/utils/_lazy_init.py +63 -0
  76. kronos/utils/_to_list.py +165 -0
  77. kronos/utils/_to_num.py +85 -0
  78. kronos/utils/_utils.py +375 -0
  79. kronos/utils/concurrency/__init__.py +205 -0
  80. kronos/utils/concurrency/_async_call.py +333 -0
  81. kronos/utils/concurrency/_cancel.py +122 -0
  82. kronos/utils/concurrency/_errors.py +96 -0
  83. kronos/utils/concurrency/_patterns.py +363 -0
  84. kronos/utils/concurrency/_primitives.py +328 -0
  85. kronos/utils/concurrency/_priority_queue.py +135 -0
  86. kronos/utils/concurrency/_resource_tracker.py +110 -0
  87. kronos/utils/concurrency/_run_async.py +67 -0
  88. kronos/utils/concurrency/_task.py +95 -0
  89. kronos/utils/concurrency/_utils.py +79 -0
  90. kronos/utils/fuzzy/__init__.py +14 -0
  91. kronos/utils/fuzzy/_extract_json.py +90 -0
  92. kronos/utils/fuzzy/_fuzzy_json.py +288 -0
  93. kronos/utils/fuzzy/_fuzzy_match.py +149 -0
  94. kronos/utils/fuzzy/_string_similarity.py +187 -0
  95. kronos/utils/fuzzy/_to_dict.py +396 -0
  96. kronos/utils/sql/__init__.py +13 -0
  97. kronos/utils/sql/_sql_validation.py +142 -0
  98. krons-0.1.0.dist-info/METADATA +70 -0
  99. krons-0.1.0.dist-info/RECORD +101 -0
  100. krons-0.1.0.dist-info/WHEEL +4 -0
  101. 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)