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/phrase.py DELETED
@@ -1,522 +0,0 @@
1
- """Phrase - typed operation template with auto-generated Options/Result types.
2
-
3
- A Phrase wraps an async handler with:
4
- - Typed inputs (auto-generates FrozenOptions dataclass)
5
- - Typed outputs (auto-generates FrozenResult dataclass)
6
- - Validation via Operable
7
-
8
- Usage with decorator (custom handler):
9
- from krons.core.specs import Operable, phrase
10
-
11
- consent_operable = Operable([
12
- Spec("subject_id", UUID),
13
- Spec("scope", str),
14
- Spec("has_consent", bool),
15
- Spec("token_id", UUID | None),
16
- ])
17
-
18
- @phrase(consent_operable, inputs={"subject_id", "scope"}, outputs={"has_consent", "token_id"})
19
- async def verify_consent(options, ctx):
20
- # options is VerifyConsentOptions (frozen dataclass)
21
- # return dict with output fields
22
- return {"has_consent": True, "token_id": some_id}
23
-
24
- # Call it
25
- result = await verify_consent({"subject_id": id, "scope": "background"}, ctx)
26
-
27
- Usage with CrudPattern (declarative):
28
- from krons.core.specs import Operable, phrase, CrudPattern
29
-
30
- def check_has_consent(row):
31
- return {"has_consent": row["status"] in {"active"} if row else False}
32
-
33
- verify_consent = phrase(
34
- consent_operable,
35
- inputs={"subject_id", "scope"},
36
- outputs={"has_consent", "token_id"},
37
- crud=CrudPattern(
38
- table="consent_tokens",
39
- operation="read",
40
- lookup={"subject_id", "scope"},
41
- ),
42
- result_parser=check_has_consent,
43
- name="verify_consent",
44
- )
45
- """
46
-
47
- from collections.abc import Awaitable, Callable, Mapping
48
- from dataclasses import dataclass
49
- from enum import Enum
50
- from types import MappingProxyType
51
- from typing import TYPE_CHECKING, Any
52
-
53
- from krons.core.specs.operable import Operable
54
- from krons.core.types import Unset, is_unset
55
- from krons.utils.sql import validate_identifier
56
-
57
- if TYPE_CHECKING:
58
- from krons.work.operations.node import Operation
59
-
60
- __all__ = ("CrudPattern", "CrudOperation", "Phrase", "phrase")
61
-
62
-
63
- class CrudOperation(str, Enum):
64
- """CRUD operation types for declarative phrases."""
65
-
66
- READ = "read"
67
- INSERT = "insert"
68
- UPDATE = "update"
69
- SOFT_DELETE = "soft_delete"
70
-
71
-
72
- _EMPTY_MAP: MappingProxyType = MappingProxyType({})
73
-
74
-
75
- @dataclass(frozen=True, slots=True)
76
- class CrudPattern:
77
- """Declarative CRUD pattern for auto-generating phrase handlers.
78
-
79
- Attributes:
80
- table: Validated database table name (alphanumeric + underscores).
81
- operation: CRUD operation type (read, insert, update, soft_delete).
82
- lookup: Fields from options used in WHERE clause (for read/update/delete).
83
- filters: Static key-value pairs added to WHERE clause. Use for
84
- hardcoded filters like {"status": "active"}.
85
- set_fields: Explicit field mappings for update. Values can be:
86
- - Field name (str): copy from options
87
- - "ctx.{attr}": read from context (e.g., "ctx.now", "ctx.user_id")
88
- - Literal value: use directly
89
- defaults: Static default values for insert.
90
-
91
- The auto-handler resolves output fields in order:
92
- 1. ctx metadata attribute (e.g., tenant_id — only if explicitly set)
93
- 2. options pass-through (if field in inputs)
94
- 3. row column (direct from query result)
95
- 4. result_parser (for computed fields)
96
- """
97
-
98
- table: str
99
- operation: CrudOperation | str = CrudOperation.READ
100
- lookup: frozenset[str] = frozenset()
101
- filters: Mapping[str, Any] = None # type: ignore[assignment]
102
- set_fields: Mapping[str, Any] = None # type: ignore[assignment]
103
- defaults: Mapping[str, Any] = None # type: ignore[assignment]
104
-
105
- def __post_init__(self):
106
- # Validate table name against SQL injection
107
- validate_identifier(self.table, "table")
108
- # Normalize operation to enum
109
- if isinstance(self.operation, str):
110
- object.__setattr__(self, "operation", CrudOperation(self.operation))
111
- # Normalize lookup to frozenset
112
- if not isinstance(self.lookup, frozenset):
113
- object.__setattr__(self, "lookup", frozenset(self.lookup))
114
- # Normalize None mappings to immutable empty maps; freeze mutable dicts
115
- object.__setattr__(
116
- self,
117
- "filters",
118
- (
119
- _EMPTY_MAP
120
- if self.filters is None
121
- else MappingProxyType(dict(self.filters))
122
- ),
123
- )
124
- object.__setattr__(
125
- self,
126
- "set_fields",
127
- (
128
- _EMPTY_MAP
129
- if self.set_fields is None
130
- else MappingProxyType(dict(self.set_fields))
131
- ),
132
- )
133
- object.__setattr__(
134
- self,
135
- "defaults",
136
- (
137
- _EMPTY_MAP
138
- if self.defaults is None
139
- else MappingProxyType(dict(self.defaults))
140
- ),
141
- )
142
-
143
-
144
- class Phrase:
145
- """A typed operation template with auto-generated Options/Result types.
146
-
147
- Phrases can be created two ways:
148
- 1. With a custom handler (decorator pattern)
149
- 2. With a CrudPattern (declarative pattern, auto-generates handler)
150
- """
151
-
152
- def __init__(
153
- self,
154
- name: str,
155
- operable: Operable,
156
- inputs: set[str],
157
- outputs: set[str],
158
- handler: Callable[..., Awaitable] | None = None,
159
- crud: CrudPattern | None = None,
160
- result_parser: Callable[[dict | None], dict] | None = None,
161
- ):
162
- """
163
- Args:
164
- name: Snake_case phrase name.
165
- operable: Operable defining field specs for inputs/outputs.
166
- inputs: Set of field names that form the options type.
167
- outputs: Set of field names that form the result type.
168
- handler: Async function (options, ctx) -> result dict. Required if no crud.
169
- crud: CrudPattern for declarative CRUD operations. If provided, handler
170
- is auto-generated.
171
- result_parser: Function (row) -> dict for computed output fields.
172
- Only used with crud pattern. Row may be None if not found.
173
- """
174
- if handler is None and crud is None:
175
- raise ValueError("Either handler or crud must be provided")
176
-
177
- self.name = name
178
- self.operable = Operable(operable.get_specs(), adapter="dataclass")
179
- self.inputs = tuple(inputs)
180
- self.outputs = tuple(outputs)
181
- self.crud = crud
182
- self.result_parser = result_parser
183
- self._options_type: Any = Unset
184
- self._result_type: Any = Unset
185
-
186
- # Use provided handler or generate from crud
187
- if handler is not None:
188
- self.handler = handler
189
- else:
190
- self.handler = self._make_crud_handler()
191
-
192
- def _make_crud_handler(self) -> Callable[..., Awaitable]:
193
- """Generate handler from CrudPattern."""
194
- crud = self.crud
195
- inputs = set(self.inputs)
196
- outputs = set(self.outputs)
197
- result_parser = self.result_parser
198
-
199
- async def _crud_handler(options: Any, ctx: Any) -> dict:
200
- # Get the query backend from context
201
- query_fn = getattr(ctx, "query_fn", None)
202
- if query_fn is None:
203
- raise RuntimeError(
204
- "Context must provide query_fn for crud patterns. "
205
- "Ensure ctx.query_fn is set."
206
- )
207
-
208
- # Helper: check ctx metadata for a key
209
- _meta = getattr(ctx, "metadata", {})
210
-
211
- row = None
212
-
213
- if crud.operation == CrudOperation.READ:
214
- # Build WHERE from lookup fields + filters + tenant_id
215
- where = {field: getattr(options, field) for field in crud.lookup}
216
- where.update(crud.filters)
217
- if "tenant_id" in _meta:
218
- where["tenant_id"] = _meta["tenant_id"]
219
- row = await query_fn(crud.table, "select_one", where, None, ctx)
220
-
221
- elif crud.operation == CrudOperation.INSERT:
222
- # Build data from input fields + defaults
223
- data = {}
224
- for field in inputs:
225
- if hasattr(options, field):
226
- data[field] = getattr(options, field)
227
- # Add defaults
228
- for key, value in crud.defaults.items():
229
- if key not in data:
230
- data[key] = value
231
- # Add tenant_id
232
- if "tenant_id" in _meta:
233
- data["tenant_id"] = _meta["tenant_id"]
234
- row = await query_fn(crud.table, "insert", None, data, ctx)
235
-
236
- elif crud.operation == CrudOperation.UPDATE:
237
- # Build WHERE from lookup fields
238
- where = {field: getattr(options, field) for field in crud.lookup}
239
- if "tenant_id" in _meta:
240
- where["tenant_id"] = _meta["tenant_id"]
241
- # Build SET data
242
- data = {}
243
- for key, value in crud.set_fields.items():
244
- if isinstance(value, str) and value.startswith("ctx."):
245
- attr_name = value[4:]
246
- if attr_name in _meta:
247
- data[key] = _meta[attr_name]
248
- else:
249
- data[key] = getattr(ctx, attr_name)
250
- elif isinstance(value, str) and hasattr(options, value):
251
- data[key] = getattr(options, value)
252
- else:
253
- data[key] = value
254
- row = await query_fn(crud.table, "update", where, data, ctx)
255
-
256
- elif crud.operation == CrudOperation.SOFT_DELETE:
257
- # Build WHERE from lookup fields
258
- where = {field: getattr(options, field) for field in crud.lookup}
259
- if "tenant_id" in _meta:
260
- where["tenant_id"] = _meta["tenant_id"]
261
- # Soft delete sets is_deleted=True, deleted_at=now
262
- data = {"is_deleted": True}
263
- if ctx.now is not None:
264
- data["deleted_at"] = ctx.now
265
- row = await query_fn(crud.table, "update", where, data, ctx)
266
-
267
- # Build result from auto-mapping + result_parser
268
- result = {}
269
-
270
- for field in outputs:
271
- # Priority 1: ctx metadata attribute (only if explicitly set)
272
- if field in _meta:
273
- result[field] = _meta[field]
274
- # Priority 2: pass-through from options
275
- elif field in inputs and hasattr(options, field):
276
- result[field] = getattr(options, field)
277
- # Priority 3: direct from row
278
- elif row and field in row:
279
- result[field] = row[field]
280
-
281
- # Priority 4: computed fields from result_parser
282
- if result_parser is not None:
283
- computed = result_parser(row)
284
- if computed:
285
- result.update(computed)
286
-
287
- return result
288
-
289
- return _crud_handler
290
-
291
- async def __call__(self, options: Any, ctx: Any) -> Any:
292
- # If options is already the correct type, use it directly
293
- # Otherwise validate/construct from dict
294
- if not isinstance(options, self.options_type):
295
- options = self.operable.validate_instance(self.options_type, options)
296
- result = await self.handler(options, ctx)
297
- return self.operable.validate_instance(self.result_type, result)
298
-
299
- @property
300
- def options_type(self) -> Any:
301
- if not is_unset(self._options_type):
302
- return self._options_type
303
-
304
- _opt_type_name = _to_pascal(self.name) + "Options"
305
- self._options_type = self.operable.compose_structure(
306
- _opt_type_name,
307
- include=set(self.inputs),
308
- frozen=True,
309
- )
310
- return self._options_type
311
-
312
- @property
313
- def result_type(self) -> Any:
314
- if not is_unset(self._result_type):
315
- return self._result_type
316
-
317
- _res_type_name = _to_pascal(self.name) + "Result"
318
- self._result_type = self.operable.compose_structure(
319
- _res_type_name,
320
- include=set(self.outputs),
321
- frozen=True,
322
- )
323
- return self._result_type
324
-
325
- # --- Form-like interface ---
326
-
327
- @property
328
- def input_fields(self) -> list[str]:
329
- """Input field names (Form-compatible interface)."""
330
- return list(self.inputs)
331
-
332
- @property
333
- def output_fields(self) -> list[str]:
334
- """Output field names (Form-compatible interface)."""
335
- return list(self.outputs)
336
-
337
- def is_workable(self, available_data: dict[str, Any]) -> bool:
338
- """Check if all inputs are available in data dict.
339
-
340
- Enables Form/Report-style scheduling based on data availability.
341
- """
342
- return all(field in available_data for field in self.inputs)
343
-
344
- def extract_inputs(self, available_data: dict[str, Any]) -> dict[str, Any]:
345
- """Extract input values from available data.
346
-
347
- Returns:
348
- Dict with only the fields declared as inputs.
349
-
350
- Raises:
351
- KeyError: If any required input is missing.
352
- """
353
- return {field: available_data[field] for field in self.inputs}
354
-
355
- # --- Operation bridge ---
356
-
357
- def as_operation(
358
- self,
359
- options: dict[str, Any] | None = None,
360
- *,
361
- available_data: dict[str, Any] | None = None,
362
- ctx: Any = None,
363
- **metadata,
364
- ) -> "Operation":
365
- """Create an Operation that invokes this phrase.
366
-
367
- The Operation can participate in DAG flow while preserving
368
- the phrase's typed I/O semantics.
369
-
370
- Args:
371
- options: Direct input values (takes precedence)
372
- available_data: Data dict to extract inputs from (if options not given)
373
- ctx: Execution context passed to phrase handler
374
- **metadata: Additional metadata for Operation
375
-
376
- Returns:
377
- PhraseOperation instance ready for DAG execution.
378
-
379
- Example:
380
- # Direct options
381
- op = phrase.as_operation({"subject_id": id, "scope": "read"})
382
-
383
- # From available data (Form/Report pattern)
384
- op = phrase.as_operation(available_data=report.available_data)
385
-
386
- # Build DAG
387
- graph = Graph()
388
- graph.add_node(phrase1.as_operation({"input": "x"}))
389
- await flow(session, graph)
390
- """
391
- from krons.work.operations.node import Operation
392
-
393
- # Resolve options
394
- if options is not None:
395
- resolved_options = dict(options)
396
- elif available_data is not None:
397
- if not self.is_workable(available_data):
398
- missing = [f for f in self.inputs if f not in available_data]
399
- raise ValueError(f"Missing required inputs: {missing}")
400
- resolved_options = self.extract_inputs(available_data)
401
- else:
402
- raise ValueError("Either options or available_data must be provided")
403
-
404
- # Create closure-based operation that bypasses registry
405
- phrase = self
406
- bound_ctx = ctx
407
-
408
- class PhraseOperation(Operation):
409
- """Operation wrapping a Phrase for direct invocation."""
410
-
411
- async def _invoke(self) -> Any:
412
- # Direct phrase invocation - no registry lookup
413
- return await phrase(self.parameters, bound_ctx)
414
-
415
- return PhraseOperation(
416
- operation_type=self.name,
417
- parameters=resolved_options,
418
- metadata={
419
- "name": self.name,
420
- "phrase": True,
421
- "input_fields": self.input_fields,
422
- "output_fields": self.output_fields,
423
- **metadata,
424
- },
425
- )
426
-
427
-
428
- def _to_pascal(snake_name: str) -> str:
429
- """Convert snake_case name to PascalCase.
430
-
431
- Examples:
432
- require_monitoring_active -> RequireMonitoringActive
433
- verify_consent_token -> VerifyConsentToken
434
- """
435
- return "".join(word.capitalize() for word in snake_name.split("_"))
436
-
437
-
438
- def phrase(
439
- operable: Operable,
440
- *,
441
- inputs: set[str],
442
- outputs: set[str],
443
- name: str | None = None,
444
- crud: CrudPattern | None = None,
445
- result_parser: Callable[[dict | None], dict] | None = None,
446
- ) -> Phrase | Callable[[Callable[..., Awaitable]], Phrase]:
447
- """Create a Phrase, either as decorator or directly with CrudPattern.
448
-
449
- Two usage modes:
450
-
451
- 1. Decorator mode (custom handler):
452
- @phrase(operable, inputs={...}, outputs={...})
453
- async def my_phrase(options, ctx):
454
- return {...}
455
-
456
- 2. Direct mode (declarative crud):
457
- my_phrase = phrase(
458
- operable,
459
- inputs={...},
460
- outputs={...},
461
- crud=CrudPattern(table="...", operation="read", lookup={...}),
462
- result_parser=lambda row: {...},
463
- name="my_phrase",
464
- )
465
-
466
- Args:
467
- operable: Operable defining the field specs for inputs/outputs.
468
- inputs: Set of field names that form the options type.
469
- outputs: Set of field names that form the result type.
470
- name: Phrase name. Required for direct mode, optional for decorator mode.
471
- crud: CrudPattern for declarative CRUD. If provided, returns Phrase directly.
472
- result_parser: Function (row) -> dict for computed fields. Only with crud.
473
-
474
- Returns:
475
- - If crud provided: Phrase instance directly
476
- - If no crud: Decorator that wraps async function into Phrase
477
-
478
- Examples:
479
- # Decorator mode
480
- @phrase(my_operable, inputs={"subject_id", "scope"}, outputs={"valid", "reason"})
481
- async def verify_consent(options, ctx):
482
- return {"valid": True, "reason": None}
483
-
484
- # Direct mode with CrudPattern
485
- verify_consent = phrase(
486
- my_operable,
487
- inputs={"subject_id", "scope"},
488
- outputs={"has_consent", "token_id"},
489
- crud=CrudPattern(
490
- table="consent_tokens",
491
- operation="read",
492
- lookup={"subject_id", "scope"},
493
- ),
494
- result_parser=lambda row: {"has_consent": row["status"] == "active" if row else False},
495
- name="verify_consent",
496
- )
497
- """
498
- # Direct mode: crud provided, return Phrase immediately
499
- if crud is not None:
500
- if name is None:
501
- raise ValueError("name is required when using crud pattern")
502
- return Phrase(
503
- name=name,
504
- operable=operable,
505
- inputs=inputs,
506
- outputs=outputs,
507
- crud=crud,
508
- result_parser=result_parser,
509
- )
510
-
511
- # Decorator mode: return decorator function
512
- def decorator(func: Callable[..., Awaitable]) -> Phrase:
513
- phrase_name = name or func.__name__
514
- return Phrase(
515
- name=phrase_name,
516
- operable=operable,
517
- inputs=inputs,
518
- outputs=outputs,
519
- handler=func,
520
- )
521
-
522
- return decorator
krons/work/policy.py DELETED
@@ -1,80 +0,0 @@
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 krons.core.specs.catalog._enforcement import EnforcementLevel
17
- from krons.core.types.base import DataClass
18
- from krons.work.operations.context import RequestContext
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
- ...