krons 0.2.0__py3-none-any.whl → 0.2.1__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/__init__.py +0 -11
- krons/work/form.py +4 -67
- {krons-0.2.0.dist-info → krons-0.2.1.dist-info}/METADATA +1 -1
- {krons-0.2.0.dist-info → krons-0.2.1.dist-info}/RECORD +6 -9
- 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.1.dist-info}/WHEEL +0 -0
- {krons-0.2.0.dist-info → krons-0.2.1.dist-info}/licenses/LICENSE +0 -0
krons/work/__init__.py
CHANGED
|
@@ -32,7 +32,6 @@ Two complementary patterns at different abstraction levels:
|
|
|
32
32
|
return await self.llm.chat(**kwargs)
|
|
33
33
|
|
|
34
34
|
Core concepts:
|
|
35
|
-
- Phrase: Typed operation signature (inputs -> outputs)
|
|
36
35
|
- Form: Data binding + scheduling (stateful artifact)
|
|
37
36
|
- Report: Multi-step workflow declaration (stateful artifact)
|
|
38
37
|
- Worker: Execution capability (stateless station)
|
|
@@ -53,11 +52,6 @@ _LAZY_IMPORTS: dict[str, tuple[str, str]] = {
|
|
|
53
52
|
"ParsedAssignment": ("krons.work.form", "ParsedAssignment"),
|
|
54
53
|
"parse_assignment": ("krons.work.form", "parse_assignment"),
|
|
55
54
|
"parse_full_assignment": ("krons.work.form", "parse_full_assignment"),
|
|
56
|
-
# phrase
|
|
57
|
-
"CrudOperation": ("krons.work.phrase", "CrudOperation"),
|
|
58
|
-
"CrudPattern": ("krons.work.phrase", "CrudPattern"),
|
|
59
|
-
"Phrase": ("krons.work.phrase", "Phrase"),
|
|
60
|
-
"phrase": ("krons.work.phrase", "phrase"),
|
|
61
55
|
# report
|
|
62
56
|
"Report": ("krons.work.report", "Report"),
|
|
63
57
|
# worker
|
|
@@ -102,16 +96,12 @@ if TYPE_CHECKING:
|
|
|
102
96
|
parse_assignment,
|
|
103
97
|
parse_full_assignment,
|
|
104
98
|
)
|
|
105
|
-
from krons.work.phrase import CrudOperation, CrudPattern, Phrase, phrase
|
|
106
99
|
from krons.work.report import Report
|
|
107
100
|
from krons.work.worker import WorkConfig, Worker, WorkLink, work, worklink
|
|
108
101
|
|
|
109
102
|
__all__ = (
|
|
110
|
-
"CrudOperation",
|
|
111
|
-
"CrudPattern",
|
|
112
103
|
"Form",
|
|
113
104
|
"ParsedAssignment",
|
|
114
|
-
"Phrase",
|
|
115
105
|
"Report",
|
|
116
106
|
"WorkConfig",
|
|
117
107
|
"WorkLink",
|
|
@@ -120,7 +110,6 @@ __all__ = (
|
|
|
120
110
|
"WorkerTask",
|
|
121
111
|
"parse_assignment",
|
|
122
112
|
"parse_full_assignment",
|
|
123
|
-
"phrase",
|
|
124
113
|
"work",
|
|
125
114
|
"worklink",
|
|
126
115
|
)
|
krons/work/form.py
CHANGED
|
@@ -6,23 +6,19 @@
|
|
|
6
6
|
A Form represents an instantiated work unit with:
|
|
7
7
|
- Data binding (input values)
|
|
8
8
|
- Execution state tracking (filled, workable)
|
|
9
|
-
- Optional Phrase reference for typed I/O
|
|
10
9
|
|
|
11
|
-
Forms are the stateful layer
|
|
10
|
+
Forms are the stateful scheduling layer for Operations.
|
|
12
11
|
"""
|
|
13
12
|
|
|
14
13
|
from __future__ import annotations
|
|
15
14
|
|
|
16
15
|
from dataclasses import dataclass
|
|
17
|
-
from typing import
|
|
16
|
+
from typing import Any
|
|
18
17
|
|
|
19
18
|
from pydantic import Field
|
|
20
19
|
|
|
21
20
|
from krons.core import Element
|
|
22
21
|
|
|
23
|
-
if TYPE_CHECKING:
|
|
24
|
-
from .phrase import Phrase
|
|
25
|
-
|
|
26
22
|
__all__ = ("Form", "ParsedAssignment", "parse_assignment", "parse_full_assignment")
|
|
27
23
|
|
|
28
24
|
|
|
@@ -123,9 +119,7 @@ def parse_full_assignment(assignment: str) -> ParsedAssignment:
|
|
|
123
119
|
class Form(Element):
|
|
124
120
|
"""Data binding container for work units.
|
|
125
121
|
|
|
126
|
-
A Form binds input data and tracks execution state.
|
|
127
|
-
1. From a Phrase (typed I/O)
|
|
128
|
-
2. From an assignment string (dynamic fields)
|
|
122
|
+
A Form binds input data and tracks execution state.
|
|
129
123
|
|
|
130
124
|
Assignment DSL supports full format:
|
|
131
125
|
"branch: inputs -> outputs | resource"
|
|
@@ -144,7 +138,6 @@ class Form(Element):
|
|
|
144
138
|
available_data: Current data values
|
|
145
139
|
output: Execution result
|
|
146
140
|
filled: Whether form has been executed
|
|
147
|
-
phrase: Optional Phrase reference for typed execution
|
|
148
141
|
"""
|
|
149
142
|
|
|
150
143
|
assignment: str = Field(
|
|
@@ -165,9 +158,6 @@ class Form(Element):
|
|
|
165
158
|
output: Any = Field(default=None)
|
|
166
159
|
filled: bool = Field(default=False)
|
|
167
160
|
|
|
168
|
-
# Optional phrase reference (set via from_phrase())
|
|
169
|
-
_phrase: "Phrase | None" = None
|
|
170
|
-
|
|
171
161
|
def model_post_init(self, _: Any) -> None:
|
|
172
162
|
"""Parse assignment to derive fields if not already set."""
|
|
173
163
|
if self.assignment and not self.input_fields and not self.output_fields:
|
|
@@ -179,35 +169,6 @@ class Form(Element):
|
|
|
179
169
|
if parsed.resource and self.resource is None:
|
|
180
170
|
self.resource = parsed.resource
|
|
181
171
|
|
|
182
|
-
@classmethod
|
|
183
|
-
def from_phrase(
|
|
184
|
-
cls,
|
|
185
|
-
phrase: "Phrase",
|
|
186
|
-
**initial_data: Any,
|
|
187
|
-
) -> "Form":
|
|
188
|
-
"""Create Form from a Phrase with optional initial data.
|
|
189
|
-
|
|
190
|
-
Args:
|
|
191
|
-
phrase: Phrase defining typed I/O
|
|
192
|
-
**initial_data: Initial input values
|
|
193
|
-
|
|
194
|
-
Returns:
|
|
195
|
-
Form bound to the phrase
|
|
196
|
-
"""
|
|
197
|
-
form = cls(
|
|
198
|
-
assignment=f"{', '.join(phrase.inputs)} -> {', '.join(phrase.outputs)}",
|
|
199
|
-
input_fields=list(phrase.inputs),
|
|
200
|
-
output_fields=list(phrase.outputs),
|
|
201
|
-
available_data=dict(initial_data),
|
|
202
|
-
)
|
|
203
|
-
form._phrase = phrase
|
|
204
|
-
return form
|
|
205
|
-
|
|
206
|
-
@property
|
|
207
|
-
def phrase(self) -> "Phrase | None":
|
|
208
|
-
"""Get bound phrase if any."""
|
|
209
|
-
return self._phrase
|
|
210
|
-
|
|
211
172
|
def is_workable(self) -> bool:
|
|
212
173
|
"""Check if form is ready for execution.
|
|
213
174
|
|
|
@@ -274,32 +235,8 @@ class Form(Element):
|
|
|
274
235
|
result[field] = self.available_data[field]
|
|
275
236
|
return result
|
|
276
237
|
|
|
277
|
-
async def execute(self, ctx: Any = None) -> Any:
|
|
278
|
-
"""Execute the form if it has a bound phrase.
|
|
279
|
-
|
|
280
|
-
Args:
|
|
281
|
-
ctx: Execution context
|
|
282
|
-
|
|
283
|
-
Returns:
|
|
284
|
-
Execution result
|
|
285
|
-
|
|
286
|
-
Raises:
|
|
287
|
-
RuntimeError: If no phrase bound or form not workable
|
|
288
|
-
"""
|
|
289
|
-
if self._phrase is None:
|
|
290
|
-
raise RuntimeError("Form has no bound phrase - cannot execute")
|
|
291
|
-
|
|
292
|
-
if not self.is_workable():
|
|
293
|
-
missing = [f for f in self.input_fields if f not in self.available_data]
|
|
294
|
-
raise RuntimeError(f"Form not workable - missing inputs: {missing}")
|
|
295
|
-
|
|
296
|
-
result = await self._phrase(self.get_inputs(), ctx)
|
|
297
|
-
self.set_output(result)
|
|
298
|
-
return result
|
|
299
|
-
|
|
300
238
|
def __repr__(self) -> str:
|
|
301
239
|
status = (
|
|
302
240
|
"filled" if self.filled else ("ready" if self.is_workable() else "pending")
|
|
303
241
|
)
|
|
304
|
-
|
|
305
|
-
return f"Form('{self.assignment}', {status}{phrase_info})"
|
|
242
|
+
return f"Form('{self.assignment}', {status})"
|
|
@@ -123,13 +123,10 @@ krons/utils/sql/__init__.py,sha256=yNjm9Dr-ZjrZSD3Lext7fPR80qX5svGT2PnXqlb5qXs,2
|
|
|
123
123
|
krons/utils/sql/_sql_validation.py,sha256=smxNU3HjPhQuRK5ehG9Fl4DeljKK9tqUQIvRUopgvc8,4229
|
|
124
124
|
krons/utils/validators/__init__.py,sha256=qmCYoL6fQL1O6-GcfcUARii_fjXYNed6rKZWOp3skj8,87
|
|
125
125
|
krons/utils/validators/_validate_image_url.py,sha256=WAffNY3WpPFe_lqFLHumJS0_hm92NwIOmDOK1GR61ew,1858
|
|
126
|
-
krons/work/__init__.py,sha256=
|
|
126
|
+
krons/work/__init__.py,sha256=8C3ypK0FInl6YSos-F_MkuDhu_Vb_uN8HEegFq8pvwc,3541
|
|
127
127
|
krons/work/engine.py,sha256=ZHZzzRqedpLEkcX5eQ6zZQMXYfgmLMRN2JOwkLajND4,10924
|
|
128
|
-
krons/work/form.py,sha256=
|
|
129
|
-
krons/work/phrase.py,sha256=7PYPcprA_p7_eZx32EzHd6d7Nc2zLjiIyYTJmqwRUNo,18949
|
|
130
|
-
krons/work/policy.py,sha256=3fp8L0R2lxJ6w5YkgWF_0B1gkXyPx1nCjSwu0_3mmcY,2146
|
|
128
|
+
krons/work/form.py,sha256=QwXYclRR-MxneNLF0CKbMvk_K-piZBmMXyCd_Y3XOeE,7264
|
|
131
129
|
krons/work/report.py,sha256=wxjMGnUc8b2Jn26l0t0k0cWetZFDyvyqBz0ldae4BD0,9027
|
|
132
|
-
krons/work/service.py,sha256=yayqnZHb14rhJoXfj_70Xh0WqdAkgwtTvw7OLzBlCY8,12103
|
|
133
130
|
krons/work/worker.py,sha256=hK7t5ztG_5wKPScwjtsctP4-ob6bHw9WKRJTw_gw_Fc,8494
|
|
134
131
|
krons/work/operations/__init__.py,sha256=vU-jy0Xy25W4vj98wV3sk3c8x6hKc5sf4nW3U_FQGdY,999
|
|
135
132
|
krons/work/operations/builder.py,sha256=_OGvooSl8s3Pmx8En15SX4XxQ12wF2ZszwVb5Hqzdpc,7779
|
|
@@ -148,7 +145,7 @@ krons/work/rules/common/mapping.py,sha256=Loq54MNEtwpnHN0aypTjFOqwoOKLEysddHh-JE
|
|
|
148
145
|
krons/work/rules/common/model.py,sha256=xmM6coEThf_fgIiqJiyDgvdfib_FpVeY6LgWPVcWSwU,3026
|
|
149
146
|
krons/work/rules/common/number.py,sha256=cCukgMSpQu5RdYK5rXAUyop9qXgDRfLCioMvE8kIzHg,3162
|
|
150
147
|
krons/work/rules/common/string.py,sha256=zHp_OLh0FL4PvmSlyDTEzb2I97-DBSEyI2zcMo10voA,5090
|
|
151
|
-
krons-0.2.
|
|
152
|
-
krons-0.2.
|
|
153
|
-
krons-0.2.
|
|
154
|
-
krons-0.2.
|
|
148
|
+
krons-0.2.1.dist-info/METADATA,sha256=O9pmDvpEfTq1HydsJRGUwTA6IPn_veB57Em2udfOO9g,2527
|
|
149
|
+
krons-0.2.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
150
|
+
krons-0.2.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
151
|
+
krons-0.2.1.dist-info/RECORD,,
|
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
|
-
...
|
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
|