krons 0.2.0__py3-none-any.whl → 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- krons/core/__init__.py +3 -0
- krons/core/base/__init__.py +4 -0
- krons/core/base/log.py +32 -0
- krons/session/session.py +114 -1
- krons/work/__init__.py +0 -11
- krons/work/form.py +4 -67
- {krons-0.2.0.dist-info → krons-0.2.2.dist-info}/METADATA +1 -1
- {krons-0.2.0.dist-info → krons-0.2.2.dist-info}/RECORD +10 -12
- krons/work/phrase.py +0 -522
- krons/work/policy.py +0 -80
- krons/work/service.py +0 -379
- {krons-0.2.0.dist-info → krons-0.2.2.dist-info}/WHEEL +0 -0
- {krons-0.2.0.dist-info → krons-0.2.2.dist-info}/licenses/LICENSE +0 -0
krons/work/service.py
DELETED
|
@@ -1,379 +0,0 @@
|
|
|
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 krons.resource import ResourceBackend, ResourceConfig
|
|
16
|
-
from krons.work.operations.context import RequestContext
|
|
17
|
-
|
|
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(ResourceConfig):
|
|
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(ResourceBackend):
|
|
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(
|
|
172
|
-
default_factory=dict
|
|
173
|
-
)
|
|
174
|
-
|
|
175
|
-
def __init__(
|
|
176
|
-
self,
|
|
177
|
-
config: KronConfig | None = None,
|
|
178
|
-
policy_engine: PolicyEngine | None = None,
|
|
179
|
-
policy_resolver: PolicyResolver | None = None,
|
|
180
|
-
**kwargs: Any,
|
|
181
|
-
) -> None:
|
|
182
|
-
"""Initialize service with optional policy engine and resolver.
|
|
183
|
-
|
|
184
|
-
Args:
|
|
185
|
-
config: Service configuration.
|
|
186
|
-
policy_engine: PolicyEngine for policy evaluation.
|
|
187
|
-
policy_resolver: PolicyResolver for determining applicable policies.
|
|
188
|
-
"""
|
|
189
|
-
super().__init__(config=config, **kwargs)
|
|
190
|
-
self._policy_engine = policy_engine
|
|
191
|
-
self._policy_resolver = policy_resolver
|
|
192
|
-
self._action_registry = {}
|
|
193
|
-
self._register_actions()
|
|
194
|
-
|
|
195
|
-
def _register_actions(self) -> None:
|
|
196
|
-
"""Scan for @action decorated methods and register them."""
|
|
197
|
-
for name in dir(self):
|
|
198
|
-
# Skip dunder attributes to avoid Pydantic deprecation warnings
|
|
199
|
-
if name.startswith("__"):
|
|
200
|
-
continue
|
|
201
|
-
if name.startswith("_"):
|
|
202
|
-
method = getattr(self, name, None)
|
|
203
|
-
if method and callable(method):
|
|
204
|
-
meta = get_action_meta(method)
|
|
205
|
-
if meta:
|
|
206
|
-
self._action_registry[meta.name] = (method, meta)
|
|
207
|
-
self._build_action_types(meta)
|
|
208
|
-
|
|
209
|
-
def _build_action_types(self, meta: ActionMeta) -> None:
|
|
210
|
-
"""Build options_type and result_type for an action from service operable."""
|
|
211
|
-
if not self.config.operable:
|
|
212
|
-
return
|
|
213
|
-
|
|
214
|
-
operable = self.config.operable
|
|
215
|
-
|
|
216
|
-
# Validate inputs/outputs exist in operable
|
|
217
|
-
allowed = operable.allowed()
|
|
218
|
-
invalid_inputs = meta.inputs - allowed
|
|
219
|
-
invalid_outputs = meta.outputs - allowed
|
|
220
|
-
|
|
221
|
-
if invalid_inputs:
|
|
222
|
-
logger.warning(
|
|
223
|
-
"Action '%s' has inputs not in operable: %s",
|
|
224
|
-
meta.name,
|
|
225
|
-
invalid_inputs,
|
|
226
|
-
)
|
|
227
|
-
if invalid_outputs:
|
|
228
|
-
logger.warning(
|
|
229
|
-
"Action '%s' has outputs not in operable: %s",
|
|
230
|
-
meta.name,
|
|
231
|
-
invalid_outputs,
|
|
232
|
-
)
|
|
233
|
-
|
|
234
|
-
# Build typed structures (frozen dataclasses)
|
|
235
|
-
if meta.inputs:
|
|
236
|
-
options_type = operable.compose_structure(
|
|
237
|
-
_to_pascal(meta.name) + "Options",
|
|
238
|
-
include=set(meta.inputs),
|
|
239
|
-
frozen=True,
|
|
240
|
-
)
|
|
241
|
-
object.__setattr__(meta, "_options_type", options_type)
|
|
242
|
-
|
|
243
|
-
if meta.outputs:
|
|
244
|
-
result_type = operable.compose_structure(
|
|
245
|
-
_to_pascal(meta.name) + "Result",
|
|
246
|
-
include=set(meta.outputs),
|
|
247
|
-
frozen=True,
|
|
248
|
-
)
|
|
249
|
-
object.__setattr__(meta, "_result_type", result_type)
|
|
250
|
-
|
|
251
|
-
@property
|
|
252
|
-
def has_engine(self) -> bool:
|
|
253
|
-
"""True if policy engine is configured."""
|
|
254
|
-
return self._policy_engine is not None
|
|
255
|
-
|
|
256
|
-
@property
|
|
257
|
-
def has_resolver(self) -> bool:
|
|
258
|
-
"""True if policy resolver is configured."""
|
|
259
|
-
return self._policy_resolver is not None
|
|
260
|
-
|
|
261
|
-
async def call(
|
|
262
|
-
self,
|
|
263
|
-
name: str,
|
|
264
|
-
options: Any,
|
|
265
|
-
ctx: RequestContext,
|
|
266
|
-
) -> Any:
|
|
267
|
-
"""Call an action by name.
|
|
268
|
-
|
|
269
|
-
Args:
|
|
270
|
-
name: Action name (e.g., "consent.grant").
|
|
271
|
-
options: Input data (dict or typed dataclass).
|
|
272
|
-
ctx: Request context.
|
|
273
|
-
|
|
274
|
-
Returns:
|
|
275
|
-
Action result.
|
|
276
|
-
|
|
277
|
-
Raises:
|
|
278
|
-
ValueError: If action not found.
|
|
279
|
-
PermissionError: If policy blocks action.
|
|
280
|
-
"""
|
|
281
|
-
handler, meta = self._fetch_handler(name)
|
|
282
|
-
|
|
283
|
-
# Update context
|
|
284
|
-
ctx.name = name
|
|
285
|
-
|
|
286
|
-
# Run pre-hooks
|
|
287
|
-
await self._run_hooks(meta.pre_hooks, options, ctx)
|
|
288
|
-
|
|
289
|
-
# Evaluate policies
|
|
290
|
-
if self.config.use_policies and self._policy_engine:
|
|
291
|
-
await self._evaluate_policies(ctx)
|
|
292
|
-
|
|
293
|
-
# Validate options if we have typed options_type
|
|
294
|
-
if meta._options_type and self.config.operable:
|
|
295
|
-
options = self.config.operable.validate_instance(
|
|
296
|
-
meta._options_type, options
|
|
297
|
-
)
|
|
298
|
-
|
|
299
|
-
# Execute handler
|
|
300
|
-
result = await handler(options, ctx)
|
|
301
|
-
|
|
302
|
-
# Run post-hooks
|
|
303
|
-
await self._run_hooks(meta.post_hooks, options, ctx, result=result)
|
|
304
|
-
|
|
305
|
-
return result
|
|
306
|
-
|
|
307
|
-
def _fetch_handler(self, name: str) -> tuple[Callable, ActionMeta]:
|
|
308
|
-
"""Fetch handler and metadata by action name.
|
|
309
|
-
|
|
310
|
-
Args:
|
|
311
|
-
name: Action name.
|
|
312
|
-
|
|
313
|
-
Returns:
|
|
314
|
-
Tuple of (handler, ActionMeta).
|
|
315
|
-
|
|
316
|
-
Raises:
|
|
317
|
-
ValueError: If action not found.
|
|
318
|
-
"""
|
|
319
|
-
if name not in self._action_registry:
|
|
320
|
-
raise ValueError(f"Unknown action: {name}")
|
|
321
|
-
return self._action_registry[name]
|
|
322
|
-
|
|
323
|
-
async def _run_hooks(
|
|
324
|
-
self,
|
|
325
|
-
hook_names: tuple[str, ...],
|
|
326
|
-
options: Any,
|
|
327
|
-
ctx: RequestContext,
|
|
328
|
-
result: Any = None,
|
|
329
|
-
) -> None:
|
|
330
|
-
"""Run named hooks from config.hooks."""
|
|
331
|
-
for hook_name in hook_names:
|
|
332
|
-
hook_fn = self.config.hooks.get(hook_name)
|
|
333
|
-
if hook_fn:
|
|
334
|
-
try:
|
|
335
|
-
await hook_fn(self, options, ctx, result)
|
|
336
|
-
except Exception as e:
|
|
337
|
-
logger.error("Hook '%s' failed: %s", hook_name, e)
|
|
338
|
-
else:
|
|
339
|
-
logger.warning("Hook '%s' not found in config.hooks", hook_name)
|
|
340
|
-
|
|
341
|
-
async def _evaluate_policies(self, ctx: RequestContext) -> None:
|
|
342
|
-
"""Evaluate policies via engine."""
|
|
343
|
-
if not self._policy_engine or not self._policy_resolver:
|
|
344
|
-
return
|
|
345
|
-
|
|
346
|
-
try:
|
|
347
|
-
resolved = self._policy_resolver.resolve(ctx)
|
|
348
|
-
|
|
349
|
-
if not resolved:
|
|
350
|
-
return
|
|
351
|
-
|
|
352
|
-
policy_ids = [p.policy_id for p in resolved]
|
|
353
|
-
input_data = ctx.to_dict()
|
|
354
|
-
|
|
355
|
-
results = await self._policy_engine.evaluate_batch(policy_ids, input_data)
|
|
356
|
-
|
|
357
|
-
for result in results:
|
|
358
|
-
if EnforcementLevel.is_blocking(result):
|
|
359
|
-
raise PermissionError(
|
|
360
|
-
f"Policy {result.policy_id} blocked: {result.message}"
|
|
361
|
-
)
|
|
362
|
-
|
|
363
|
-
except PermissionError:
|
|
364
|
-
raise
|
|
365
|
-
except Exception as e:
|
|
366
|
-
logger.error("Policy evaluation failed: %s", e)
|
|
367
|
-
if not self.config.fail_open_on_engine_error:
|
|
368
|
-
raise PermissionError(f"Policy engine error: {e}")
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
def _to_pascal(name: str) -> str:
|
|
372
|
-
"""Convert action name to PascalCase.
|
|
373
|
-
|
|
374
|
-
consent.grant -> ConsentGrant
|
|
375
|
-
consent_grant -> ConsentGrant
|
|
376
|
-
"""
|
|
377
|
-
# Replace dots and underscores, capitalize each part
|
|
378
|
-
parts = name.replace(".", "_").split("_")
|
|
379
|
-
return "".join(part.capitalize() for part in parts)
|
|
File without changes
|
|
File without changes
|