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/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