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 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 between Phrase (definition) and Operation (execution).
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 TYPE_CHECKING, Any
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. It can be created:
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
- phrase_info = f", phrase={self._phrase.name}" if self._phrase else ""
305
- return f"Form('{self.assignment}', {status}{phrase_info})"
242
+ return f"Form('{self.assignment}', {status})"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: krons
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Spec-based composable framework for building type-safe systems
5
5
  Project-URL: Homepage, https://github.com/khive-ai/krons
6
6
  Project-URL: Repository, https://github.com/khive-ai/krons
@@ -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=L7PoU-Y_RFQCiw8HqC7KSp4KkgyardWbB5mmnNu0cjs,3967
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=wxfjwCe8JglpeoRcflWquMSSldCCU4kOL26Q_r47GGE,9268
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.0.dist-info/METADATA,sha256=BibIkvS6d8-3CoLW3Toj0hCpeQiWdv9PgGmk17CUx3g,2527
152
- krons-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
153
- krons-0.2.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
154
- krons-0.2.0.dist-info/RECORD,,
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