krons 0.1.0__py3-none-any.whl → 0.2.0__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/__init__.py +49 -0
- krons/agent/__init__.py +144 -0
- krons/agent/mcps/__init__.py +14 -0
- krons/agent/mcps/loader.py +287 -0
- krons/agent/mcps/wrapper.py +799 -0
- krons/agent/message/__init__.py +20 -0
- krons/agent/message/action.py +69 -0
- krons/agent/message/assistant.py +52 -0
- krons/agent/message/common.py +49 -0
- krons/agent/message/instruction.py +130 -0
- krons/agent/message/prepare_msg.py +187 -0
- krons/agent/message/role.py +53 -0
- krons/agent/message/system.py +53 -0
- krons/agent/operations/__init__.py +82 -0
- krons/agent/operations/act.py +100 -0
- krons/agent/operations/generate.py +145 -0
- krons/agent/operations/llm_reparse.py +89 -0
- krons/agent/operations/operate.py +247 -0
- krons/agent/operations/parse.py +243 -0
- krons/agent/operations/react.py +286 -0
- krons/agent/operations/specs.py +235 -0
- krons/agent/operations/structure.py +151 -0
- krons/agent/operations/utils.py +79 -0
- krons/agent/providers/__init__.py +17 -0
- krons/agent/providers/anthropic_messages.py +146 -0
- krons/agent/providers/claude_code.py +276 -0
- krons/agent/providers/gemini.py +268 -0
- krons/agent/providers/match.py +75 -0
- krons/agent/providers/oai_chat.py +174 -0
- krons/agent/third_party/__init__.py +2 -0
- krons/agent/third_party/anthropic_models.py +154 -0
- krons/agent/third_party/claude_code.py +682 -0
- krons/agent/third_party/gemini_models.py +508 -0
- krons/agent/third_party/openai_models.py +295 -0
- krons/agent/tool.py +291 -0
- krons/core/__init__.py +127 -0
- krons/core/base/__init__.py +121 -0
- {kronos/core → krons/core/base}/broadcaster.py +7 -3
- {kronos/core → krons/core/base}/element.py +15 -7
- {kronos/core → krons/core/base}/event.py +41 -8
- {kronos/core → krons/core/base}/eventbus.py +4 -2
- {kronos/core → krons/core/base}/flow.py +14 -7
- {kronos/core → krons/core/base}/graph.py +27 -11
- {kronos/core → krons/core/base}/node.py +47 -22
- {kronos/core → krons/core/base}/pile.py +26 -12
- {kronos/core → krons/core/base}/processor.py +23 -9
- {kronos/core → krons/core/base}/progression.py +5 -3
- {kronos → krons/core}/specs/__init__.py +0 -5
- {kronos → krons/core}/specs/adapters/dataclass_field.py +16 -8
- {kronos → krons/core}/specs/adapters/pydantic_adapter.py +11 -5
- {kronos → krons/core}/specs/adapters/sql_ddl.py +16 -10
- {kronos → krons/core}/specs/catalog/__init__.py +2 -2
- {kronos → krons/core}/specs/catalog/_audit.py +3 -3
- {kronos → krons/core}/specs/catalog/_common.py +2 -2
- {kronos → krons/core}/specs/catalog/_content.py +5 -5
- {kronos → krons/core}/specs/catalog/_enforcement.py +4 -4
- {kronos → krons/core}/specs/factory.py +7 -7
- {kronos → krons/core}/specs/operable.py +9 -3
- {kronos → krons/core}/specs/protocol.py +4 -2
- {kronos → krons/core}/specs/spec.py +25 -13
- {kronos → krons/core}/types/base.py +7 -5
- {kronos → krons/core}/types/db_types.py +2 -2
- {kronos → krons/core}/types/identity.py +1 -1
- {kronos → krons}/errors.py +13 -13
- {kronos → krons}/protocols.py +9 -4
- krons/resource/__init__.py +89 -0
- {kronos/services → krons/resource}/backend.py +50 -24
- {kronos/services → krons/resource}/endpoint.py +28 -14
- {kronos/services → krons/resource}/hook.py +22 -9
- {kronos/services → krons/resource}/imodel.py +50 -32
- {kronos/services → krons/resource}/registry.py +27 -25
- {kronos/services → krons/resource}/utilities/rate_limited_executor.py +10 -6
- {kronos/services → krons/resource}/utilities/rate_limiter.py +4 -2
- {kronos/services → krons/resource}/utilities/resilience.py +17 -7
- krons/resource/utilities/token_calculator.py +185 -0
- {kronos → krons}/session/__init__.py +12 -17
- krons/session/constraints.py +70 -0
- {kronos → krons}/session/exchange.py +14 -6
- {kronos → krons}/session/message.py +4 -2
- krons/session/registry.py +35 -0
- {kronos → krons}/session/session.py +165 -174
- krons/utils/__init__.py +85 -0
- krons/utils/_function_arg_parser.py +99 -0
- krons/utils/_pythonic_function_call.py +249 -0
- {kronos → krons}/utils/_to_list.py +9 -3
- {kronos → krons}/utils/_utils.py +9 -5
- {kronos → krons}/utils/concurrency/__init__.py +38 -38
- {kronos → krons}/utils/concurrency/_async_call.py +6 -4
- {kronos → krons}/utils/concurrency/_errors.py +3 -1
- {kronos → krons}/utils/concurrency/_patterns.py +3 -1
- {kronos → krons}/utils/concurrency/_resource_tracker.py +6 -2
- krons/utils/display.py +257 -0
- {kronos → krons}/utils/fuzzy/__init__.py +6 -1
- {kronos → krons}/utils/fuzzy/_fuzzy_match.py +14 -8
- {kronos → krons}/utils/fuzzy/_string_similarity.py +3 -1
- {kronos → krons}/utils/fuzzy/_to_dict.py +3 -1
- krons/utils/schemas/__init__.py +26 -0
- krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
- krons/utils/schemas/_formatter.py +72 -0
- krons/utils/schemas/_minimal_yaml.py +151 -0
- krons/utils/schemas/_typescript.py +153 -0
- {kronos → krons}/utils/sql/_sql_validation.py +1 -1
- krons/utils/validators/__init__.py +3 -0
- krons/utils/validators/_validate_image_url.py +56 -0
- krons/work/__init__.py +126 -0
- krons/work/engine.py +333 -0
- krons/work/form.py +305 -0
- {kronos → krons/work}/operations/__init__.py +7 -4
- {kronos → krons/work}/operations/builder.py +4 -4
- {kronos/enforcement → krons/work/operations}/context.py +37 -6
- {kronos → krons/work}/operations/flow.py +17 -9
- krons/work/operations/node.py +103 -0
- krons/work/operations/registry.py +103 -0
- {kronos/specs → krons/work}/phrase.py +131 -14
- {kronos/enforcement → krons/work}/policy.py +3 -3
- krons/work/report.py +268 -0
- krons/work/rules/__init__.py +47 -0
- {kronos/enforcement → krons/work/rules}/common/boolean.py +3 -1
- {kronos/enforcement → krons/work/rules}/common/choice.py +9 -3
- {kronos/enforcement → krons/work/rules}/common/number.py +3 -1
- {kronos/enforcement → krons/work/rules}/common/string.py +9 -3
- {kronos/enforcement → krons/work/rules}/rule.py +2 -2
- {kronos/enforcement → krons/work/rules}/validator.py +21 -6
- {kronos/enforcement → krons/work}/service.py +16 -7
- krons/work/worker.py +266 -0
- {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/METADATA +19 -5
- krons-0.2.0.dist-info/RECORD +154 -0
- kronos/core/__init__.py +0 -145
- kronos/enforcement/__init__.py +0 -57
- kronos/operations/node.py +0 -101
- kronos/operations/registry.py +0 -92
- kronos/services/__init__.py +0 -81
- kronos/specs/adapters/__init__.py +0 -0
- kronos/utils/__init__.py +0 -40
- krons-0.1.0.dist-info/RECORD +0 -101
- {kronos → krons/core/specs/adapters}/__init__.py +0 -0
- {kronos → krons/core}/specs/adapters/_utils.py +0 -0
- {kronos → krons/core}/specs/adapters/factory.py +0 -0
- {kronos → krons/core}/types/__init__.py +0 -0
- {kronos → krons/core}/types/_sentinel.py +0 -0
- {kronos → krons}/py.typed +0 -0
- {kronos/services → krons/resource}/utilities/__init__.py +0 -0
- {kronos/services → krons/resource}/utilities/header_factory.py +0 -0
- {kronos → krons}/utils/_hash.py +0 -0
- {kronos → krons}/utils/_json_dump.py +0 -0
- {kronos → krons}/utils/_lazy_init.py +0 -0
- {kronos → krons}/utils/_to_num.py +0 -0
- {kronos → krons}/utils/concurrency/_cancel.py +0 -0
- {kronos → krons}/utils/concurrency/_primitives.py +0 -0
- {kronos → krons}/utils/concurrency/_priority_queue.py +0 -0
- {kronos → krons}/utils/concurrency/_run_async.py +0 -0
- {kronos → krons}/utils/concurrency/_task.py +0 -0
- {kronos → krons}/utils/concurrency/_utils.py +0 -0
- {kronos → krons}/utils/fuzzy/_extract_json.py +0 -0
- {kronos → krons}/utils/fuzzy/_fuzzy_json.py +0 -0
- {kronos → krons}/utils/sql/__init__.py +0 -0
- {kronos/enforcement → krons/work/rules}/common/__init__.py +0 -0
- {kronos/enforcement → krons/work/rules}/common/mapping.py +0 -0
- {kronos/enforcement → krons/work/rules}/common/model.py +0 -0
- {kronos/enforcement → krons/work/rules}/registry.py +0 -0
- {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/WHEEL +0 -0
- {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/licenses/LICENSE +0 -0
krons/work/report.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Report - Multi-step workflow orchestration.
|
|
5
|
+
|
|
6
|
+
A Report orchestrates multiple Forms based on data availability:
|
|
7
|
+
- Schedules forms when their inputs become available
|
|
8
|
+
- Groups forms by branch for sequential execution within branch
|
|
9
|
+
- Propagates outputs between forms
|
|
10
|
+
- Tracks overall workflow completion
|
|
11
|
+
|
|
12
|
+
This is the scheduling layer that enables data-driven DAG execution.
|
|
13
|
+
|
|
14
|
+
The Report pattern supports declarative workflow definition:
|
|
15
|
+
- Fields as class attributes (typed outputs)
|
|
16
|
+
- form_assignments DSL with branch/resource hints
|
|
17
|
+
- Implicit dependencies from data flow
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections import defaultdict
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from pydantic import Field
|
|
26
|
+
|
|
27
|
+
from krons.core import Element, Pile
|
|
28
|
+
|
|
29
|
+
from .form import Form, parse_assignment
|
|
30
|
+
|
|
31
|
+
__all__ = ("Report",)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Report(Element):
|
|
35
|
+
"""Workflow orchestrator - schedules forms based on field availability.
|
|
36
|
+
|
|
37
|
+
A Report manages a collection of Forms, executing them as their
|
|
38
|
+
inputs become available. Forms are grouped by branch - forms on the
|
|
39
|
+
same branch execute sequentially, different branches can run in parallel.
|
|
40
|
+
|
|
41
|
+
Example (simple):
|
|
42
|
+
report = Report(
|
|
43
|
+
assignment="context -> final_score",
|
|
44
|
+
form_assignments=[
|
|
45
|
+
"context -> analysis",
|
|
46
|
+
"analysis -> score",
|
|
47
|
+
"score -> final_score",
|
|
48
|
+
],
|
|
49
|
+
)
|
|
50
|
+
report.initialize(context="some input")
|
|
51
|
+
|
|
52
|
+
while not report.is_complete():
|
|
53
|
+
for form in report.next_forms():
|
|
54
|
+
await form.execute(ctx)
|
|
55
|
+
report.complete_form(form)
|
|
56
|
+
|
|
57
|
+
Example (with branches and resources):
|
|
58
|
+
class HiringBriefReport(Report):
|
|
59
|
+
role_classification: RoleClassification | None = None
|
|
60
|
+
strategic_context: StrategicContext | None = None
|
|
61
|
+
|
|
62
|
+
assignment: str = "job_input -> executive_summary"
|
|
63
|
+
|
|
64
|
+
form_assignments: list[str] = [
|
|
65
|
+
"classifier: job_input -> role_classification | api:fast",
|
|
66
|
+
"strategist: job_input, role_classification -> strategic_context | api:synthesis",
|
|
67
|
+
"writer: strategic_context -> executive_summary | api:reasoning",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
assignment: Overall workflow 'inputs -> final_outputs'
|
|
72
|
+
form_assignments: List of form assignments with optional branch/resource
|
|
73
|
+
input_fields: Workflow input fields
|
|
74
|
+
output_fields: Workflow output fields
|
|
75
|
+
forms: All forms in workflow
|
|
76
|
+
completed_forms: Forms that have finished
|
|
77
|
+
available_data: Current state of all field values
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
assignment: str = Field(
|
|
81
|
+
default="",
|
|
82
|
+
description="Overall workflow: 'inputs -> final_outputs'",
|
|
83
|
+
)
|
|
84
|
+
form_assignments: list[str] = Field(
|
|
85
|
+
default_factory=list,
|
|
86
|
+
description="List of form assignments: ['branch: a -> b | resource', ...]",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
input_fields: list[str] = Field(default_factory=list)
|
|
90
|
+
output_fields: list[str] = Field(default_factory=list)
|
|
91
|
+
|
|
92
|
+
forms: Pile[Form] = Field(
|
|
93
|
+
default_factory=lambda: Pile(item_type=Form),
|
|
94
|
+
description="All forms in the workflow",
|
|
95
|
+
)
|
|
96
|
+
completed_forms: Pile[Form] = Field(
|
|
97
|
+
default_factory=lambda: Pile(item_type=Form),
|
|
98
|
+
description="Completed forms",
|
|
99
|
+
)
|
|
100
|
+
available_data: dict[str, Any] = Field(
|
|
101
|
+
default_factory=dict,
|
|
102
|
+
description="Current state of all field values",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Branch tracking: branch_name -> list of form IDs in order
|
|
106
|
+
_branch_forms: dict[str, list[Form]] = {}
|
|
107
|
+
# Track last completed form per branch for sequential execution
|
|
108
|
+
_branch_progress: dict[str, int] = {}
|
|
109
|
+
|
|
110
|
+
def model_post_init(self, _: Any) -> None:
|
|
111
|
+
"""Parse assignment and create forms."""
|
|
112
|
+
self._branch_forms = defaultdict(list)
|
|
113
|
+
self._branch_progress = defaultdict(int)
|
|
114
|
+
|
|
115
|
+
if not self.assignment:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
# Parse overall assignment
|
|
119
|
+
self.input_fields, self.output_fields = parse_assignment(self.assignment)
|
|
120
|
+
|
|
121
|
+
# Create forms from form_assignments
|
|
122
|
+
for fa in self.form_assignments:
|
|
123
|
+
form = Form(assignment=fa)
|
|
124
|
+
self.forms.include(form)
|
|
125
|
+
|
|
126
|
+
# Track by branch
|
|
127
|
+
branch = form.branch or "_default"
|
|
128
|
+
self._branch_forms[branch].append(form)
|
|
129
|
+
|
|
130
|
+
def initialize(self, **inputs: Any) -> None:
|
|
131
|
+
"""Provide initial input data.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
**inputs: Initial field values
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
ValueError: If required input is missing
|
|
138
|
+
"""
|
|
139
|
+
for field in self.input_fields:
|
|
140
|
+
if field not in inputs:
|
|
141
|
+
raise ValueError(f"Missing required input: '{field}'")
|
|
142
|
+
self.available_data[field] = inputs[field]
|
|
143
|
+
|
|
144
|
+
def next_forms(self) -> list[Form]:
|
|
145
|
+
"""Get forms that are ready to execute.
|
|
146
|
+
|
|
147
|
+
Forms with explicit branches execute sequentially within their branch.
|
|
148
|
+
Forms without branches (None) execute in parallel based on data availability.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
List of forms with all inputs available and not yet filled
|
|
152
|
+
"""
|
|
153
|
+
ready = []
|
|
154
|
+
|
|
155
|
+
for branch, forms in self._branch_forms.items():
|
|
156
|
+
if branch == "_default":
|
|
157
|
+
# No explicit branch - parallel execution based on data
|
|
158
|
+
for form in forms:
|
|
159
|
+
if form.filled:
|
|
160
|
+
continue
|
|
161
|
+
form.available_data = self.available_data.copy()
|
|
162
|
+
if form.is_workable():
|
|
163
|
+
ready.append(form)
|
|
164
|
+
else:
|
|
165
|
+
# Explicit branch - sequential execution
|
|
166
|
+
progress = self._branch_progress[branch]
|
|
167
|
+
|
|
168
|
+
# Only consider the next form in this branch
|
|
169
|
+
if progress < len(forms):
|
|
170
|
+
form = forms[progress]
|
|
171
|
+
if form.filled:
|
|
172
|
+
# Already done, advance progress
|
|
173
|
+
self._branch_progress[branch] += 1
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
form.available_data = self.available_data.copy()
|
|
177
|
+
if form.is_workable():
|
|
178
|
+
ready.append(form)
|
|
179
|
+
|
|
180
|
+
return ready
|
|
181
|
+
|
|
182
|
+
def complete_form(self, form: Form) -> None:
|
|
183
|
+
"""Mark a form as completed and update available data.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
form: The completed form
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
ValueError: If form is not filled
|
|
190
|
+
"""
|
|
191
|
+
if not form.filled:
|
|
192
|
+
raise ValueError("Form is not filled")
|
|
193
|
+
|
|
194
|
+
self.completed_forms.include(form)
|
|
195
|
+
|
|
196
|
+
# Advance branch progress
|
|
197
|
+
branch = form.branch or "_default"
|
|
198
|
+
if branch in self._branch_forms:
|
|
199
|
+
forms = self._branch_forms[branch]
|
|
200
|
+
progress = self._branch_progress[branch]
|
|
201
|
+
if progress < len(forms) and forms[progress].id == form.id:
|
|
202
|
+
self._branch_progress[branch] += 1
|
|
203
|
+
|
|
204
|
+
# Update available data with form outputs
|
|
205
|
+
output_data = form.get_output_data()
|
|
206
|
+
self.available_data.update(output_data)
|
|
207
|
+
|
|
208
|
+
def is_complete(self) -> bool:
|
|
209
|
+
"""Check if all output fields are available.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
True if workflow is complete
|
|
213
|
+
"""
|
|
214
|
+
return all(field in self.available_data for field in self.output_fields)
|
|
215
|
+
|
|
216
|
+
def get_deliverable(self) -> dict[str, Any]:
|
|
217
|
+
"""Get final output values.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Dict of output field values
|
|
221
|
+
"""
|
|
222
|
+
return {f: self.available_data.get(f) for f in self.output_fields}
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def progress(self) -> tuple[int, int]:
|
|
226
|
+
"""Get progress as (completed, total).
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Tuple of (completed forms, total forms)
|
|
230
|
+
"""
|
|
231
|
+
return len(self.completed_forms), len(self.forms)
|
|
232
|
+
|
|
233
|
+
def get_forms_by_branch(self, branch: str) -> list[Form]:
|
|
234
|
+
"""Get all forms for a specific branch.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
branch: Branch name
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
List of forms on that branch (in order)
|
|
241
|
+
"""
|
|
242
|
+
return list(self._branch_forms.get(branch, []))
|
|
243
|
+
|
|
244
|
+
def get_forms_by_resource(self, resource: str) -> list[Form]:
|
|
245
|
+
"""Get all forms requiring a specific resource.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
resource: Resource hint (e.g., 'api:fast')
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
List of forms with that resource hint
|
|
252
|
+
"""
|
|
253
|
+
return [f for f in self.forms if f.resource == resource]
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def branches(self) -> list[str]:
|
|
257
|
+
"""Get all branch names in this report."""
|
|
258
|
+
return list(self._branch_forms.keys())
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def resources(self) -> set[str]:
|
|
262
|
+
"""Get all resource hints used in this report."""
|
|
263
|
+
return {f.resource for f in self.forms if f.resource}
|
|
264
|
+
|
|
265
|
+
def __repr__(self) -> str:
|
|
266
|
+
completed, total = self.progress
|
|
267
|
+
branches = len(self._branch_forms)
|
|
268
|
+
return f"Report('{self.assignment}', {completed}/{total} forms, {branches} branches)"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Rules module: validation rules with auto-correction support.
|
|
5
|
+
|
|
6
|
+
Core exports:
|
|
7
|
+
- Rule, RuleParams, RuleQualifier: Base rule classes
|
|
8
|
+
- ValidationError: Validation failure exception
|
|
9
|
+
- Validator: Spec-aware validation orchestrator
|
|
10
|
+
- RuleRegistry: Type-to-rule mapping with inheritance
|
|
11
|
+
- Common rules: StringRule, NumberRule, BooleanRule, ChoiceRule, MappingRule, BaseModelRule
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from krons.errors import ValidationError
|
|
15
|
+
|
|
16
|
+
from .common import (
|
|
17
|
+
BaseModelRule,
|
|
18
|
+
BooleanRule,
|
|
19
|
+
ChoiceRule,
|
|
20
|
+
MappingRule,
|
|
21
|
+
NumberRule,
|
|
22
|
+
StringRule,
|
|
23
|
+
)
|
|
24
|
+
from .registry import RuleRegistry, get_default_registry, reset_default_registry
|
|
25
|
+
from .rule import Rule, RuleParams, RuleQualifier
|
|
26
|
+
from .validator import Validator
|
|
27
|
+
|
|
28
|
+
__all__ = (
|
|
29
|
+
# Base classes
|
|
30
|
+
"Rule",
|
|
31
|
+
"RuleParams",
|
|
32
|
+
"RuleQualifier",
|
|
33
|
+
"ValidationError",
|
|
34
|
+
# Validator
|
|
35
|
+
"Validator",
|
|
36
|
+
# Registry
|
|
37
|
+
"RuleRegistry",
|
|
38
|
+
"get_default_registry",
|
|
39
|
+
"reset_default_registry",
|
|
40
|
+
# Common rules
|
|
41
|
+
"BaseModelRule",
|
|
42
|
+
"BooleanRule",
|
|
43
|
+
"ChoiceRule",
|
|
44
|
+
"MappingRule",
|
|
45
|
+
"NumberRule",
|
|
46
|
+
"StringRule",
|
|
47
|
+
)
|
|
@@ -50,7 +50,9 @@ class BooleanRule(Rule):
|
|
|
50
50
|
ValueError: If not a boolean
|
|
51
51
|
"""
|
|
52
52
|
if not isinstance(v, bool):
|
|
53
|
-
raise ValueError(
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"Invalid boolean value: expected bool, got {type(v).__name__}"
|
|
55
|
+
)
|
|
54
56
|
|
|
55
57
|
async def perform_fix(self, v: Any, _t: type) -> Any:
|
|
56
58
|
"""Attempt to convert value to boolean.
|
|
@@ -58,7 +58,9 @@ class ChoiceRule(Rule):
|
|
|
58
58
|
self.case_sensitive = case_sensitive
|
|
59
59
|
|
|
60
60
|
if not case_sensitive:
|
|
61
|
-
self._lower_map = {
|
|
61
|
+
self._lower_map = {
|
|
62
|
+
str(c).lower(): c for c in self.choices if isinstance(c, str)
|
|
63
|
+
}
|
|
62
64
|
|
|
63
65
|
async def validate(self, v: Any, t: type, **kw) -> None:
|
|
64
66
|
"""Validate that value is in allowed choices (exact match only).
|
|
@@ -72,7 +74,9 @@ class ChoiceRule(Rule):
|
|
|
72
74
|
if v in self.choices:
|
|
73
75
|
return
|
|
74
76
|
|
|
75
|
-
raise ValueError(
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"Invalid choice: {v} not in {sorted(str(c) for c in self.choices)}"
|
|
79
|
+
)
|
|
76
80
|
|
|
77
81
|
async def perform_fix(self, v: Any, _t: type) -> Any:
|
|
78
82
|
"""Attempt to fix value to closest choice.
|
|
@@ -94,4 +98,6 @@ class ChoiceRule(Rule):
|
|
|
94
98
|
if v_lower in self._lower_map:
|
|
95
99
|
return self._lower_map[v_lower]
|
|
96
100
|
|
|
97
|
-
raise ValueError(
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"Cannot fix choice: {v} not in {sorted(str(c) for c in self.choices)}"
|
|
103
|
+
)
|
|
@@ -66,7 +66,9 @@ class NumberRule(Rule):
|
|
|
66
66
|
ValueError: If not a number or constraints violated
|
|
67
67
|
"""
|
|
68
68
|
if not isinstance(v, (int, float)):
|
|
69
|
-
raise ValueError(
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"Invalid number value: expected int or float, got {type(v).__name__}"
|
|
71
|
+
)
|
|
70
72
|
|
|
71
73
|
if self.ge is not None and v < self.ge:
|
|
72
74
|
raise ValueError(f"Number too small: {v} < {self.ge}")
|
|
@@ -103,14 +103,18 @@ class StringRule(Rule):
|
|
|
103
103
|
ValueError: If not a string or constraints violated
|
|
104
104
|
"""
|
|
105
105
|
if not isinstance(v, str):
|
|
106
|
-
raise ValueError(
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"Invalid string value: expected str, got {type(v).__name__}"
|
|
108
|
+
)
|
|
107
109
|
|
|
108
110
|
if self.min_length is not None and len(v) < self.min_length:
|
|
109
111
|
raise ValueError(
|
|
110
112
|
f"String too short: got {len(v)} characters, minimum {self.min_length}"
|
|
111
113
|
)
|
|
112
114
|
if self.max_length is not None and len(v) > self.max_length:
|
|
113
|
-
raise ValueError(
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"String too long: got {len(v)} characters, maximum {self.max_length}"
|
|
117
|
+
)
|
|
114
118
|
|
|
115
119
|
if self._compiled_pattern is not None:
|
|
116
120
|
if len(v) > self.regex_max_input_length:
|
|
@@ -119,7 +123,9 @@ class StringRule(Rule):
|
|
|
119
123
|
f"maximum {self.regex_max_input_length}"
|
|
120
124
|
)
|
|
121
125
|
if not self._compiled_pattern.match(v):
|
|
122
|
-
raise ValueError(
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"String does not match required pattern: {self.pattern}"
|
|
128
|
+
)
|
|
123
129
|
|
|
124
130
|
async def perform_fix(self, v: Any, t: type) -> Any:
|
|
125
131
|
"""Attempt to convert value to string and re-validate.
|
|
@@ -8,8 +8,8 @@ from dataclasses import dataclass, field
|
|
|
8
8
|
from enum import IntEnum, auto
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
|
-
from
|
|
12
|
-
from
|
|
11
|
+
from krons.core.types import Params
|
|
12
|
+
from krons.errors import ValidationError
|
|
13
13
|
|
|
14
14
|
__all__ = ("Rule", "RuleParams", "RuleQualifier", "ValidationError")
|
|
15
15
|
|
|
@@ -7,14 +7,16 @@ from collections import deque
|
|
|
7
7
|
from datetime import datetime
|
|
8
8
|
from typing import TYPE_CHECKING, Any, ClassVar
|
|
9
9
|
|
|
10
|
-
from
|
|
11
|
-
from
|
|
10
|
+
from krons.core.types import is_sentinel, not_sentinel
|
|
11
|
+
from krons.utils.concurrency import is_coro_func
|
|
12
12
|
|
|
13
13
|
from .registry import RuleRegistry, get_default_registry
|
|
14
14
|
from .rule import Rule, ValidationError
|
|
15
15
|
|
|
16
16
|
if TYPE_CHECKING:
|
|
17
|
-
from
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
from krons.core.specs import Operable, Spec
|
|
18
20
|
|
|
19
21
|
__all__ = ("Validator",)
|
|
20
22
|
|
|
@@ -29,7 +31,9 @@ class Validator:
|
|
|
29
31
|
):
|
|
30
32
|
self.registry = registry or get_default_registry()
|
|
31
33
|
max_entries = (
|
|
32
|
-
max_log_entries
|
|
34
|
+
max_log_entries
|
|
35
|
+
if max_log_entries is not None
|
|
36
|
+
else self.DEFAULT_MAX_LOG_ENTRIES
|
|
33
37
|
)
|
|
34
38
|
self.validation_log: deque[dict[str, Any]] = deque(
|
|
35
39
|
maxlen=max_entries if max_entries > 0 else None
|
|
@@ -141,7 +145,9 @@ class Validator:
|
|
|
141
145
|
raise ValidationError(error_msg)
|
|
142
146
|
else:
|
|
143
147
|
try:
|
|
144
|
-
value = await rule.invoke(
|
|
148
|
+
value = await rule.invoke(
|
|
149
|
+
field_name, value, spec.base_type, auto_fix=auto_fix
|
|
150
|
+
)
|
|
145
151
|
except Exception as e:
|
|
146
152
|
self.log_validation_error(field_name, value, str(e))
|
|
147
153
|
raise
|
|
@@ -171,14 +177,20 @@ class Validator:
|
|
|
171
177
|
|
|
172
178
|
return value
|
|
173
179
|
|
|
174
|
-
async def
|
|
180
|
+
async def validate(
|
|
175
181
|
self,
|
|
176
182
|
data: dict[str, Any],
|
|
177
183
|
operable: Operable,
|
|
178
184
|
capabilities: set[str] | None = None,
|
|
179
185
|
auto_fix: bool = True,
|
|
180
186
|
strict: bool = True,
|
|
187
|
+
structure: type[BaseModel] | None = None,
|
|
181
188
|
) -> dict[str, Any]:
|
|
189
|
+
if not_sentinel(capabilities, {"none"}) and not capabilities.issubset(
|
|
190
|
+
operable.allowed()
|
|
191
|
+
):
|
|
192
|
+
raise ValidationError("Capabilities exceed operable's allowed set")
|
|
193
|
+
|
|
182
194
|
capabilities = capabilities or operable.allowed()
|
|
183
195
|
validated: dict[str, Any] = {}
|
|
184
196
|
|
|
@@ -195,4 +207,7 @@ class Validator:
|
|
|
195
207
|
spec, value, auto_fix=auto_fix, strict=strict
|
|
196
208
|
)
|
|
197
209
|
|
|
210
|
+
if structure is not None:
|
|
211
|
+
validated = operable.validate_instance(structure, validated)
|
|
212
|
+
|
|
198
213
|
return validated
|
|
@@ -12,9 +12,9 @@ from typing import Any
|
|
|
12
12
|
|
|
13
13
|
from pydantic import Field, PrivateAttr
|
|
14
14
|
|
|
15
|
-
from
|
|
15
|
+
from krons.resource import ResourceBackend, ResourceConfig
|
|
16
|
+
from krons.work.operations.context import RequestContext
|
|
16
17
|
|
|
17
|
-
from .context import RequestContext
|
|
18
18
|
from .policy import EnforcementLevel, PolicyEngine, PolicyResolver
|
|
19
19
|
|
|
20
20
|
logger = logging.getLogger(__name__)
|
|
@@ -28,7 +28,7 @@ __all__ = (
|
|
|
28
28
|
)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
class KronConfig(
|
|
31
|
+
class KronConfig(ResourceConfig):
|
|
32
32
|
"""Configuration for KronService.
|
|
33
33
|
|
|
34
34
|
Attributes:
|
|
@@ -135,7 +135,7 @@ def get_action_meta(handler: Callable) -> ActionMeta | None:
|
|
|
135
135
|
# =============================================================================
|
|
136
136
|
|
|
137
137
|
|
|
138
|
-
class KronService(
|
|
138
|
+
class KronService(ResourceBackend):
|
|
139
139
|
"""Service backend with typed actions.
|
|
140
140
|
|
|
141
141
|
Subclasses implement action handlers with @action decorator.
|
|
@@ -168,7 +168,9 @@ class KronService(ServiceBackend):
|
|
|
168
168
|
config: KronConfig = Field(default_factory=KronConfig)
|
|
169
169
|
_policy_engine: PolicyEngine | None = PrivateAttr(default=None)
|
|
170
170
|
_policy_resolver: PolicyResolver | None = PrivateAttr(default=None)
|
|
171
|
-
_action_registry: dict[str, tuple[Callable, ActionMeta]] = PrivateAttr(
|
|
171
|
+
_action_registry: dict[str, tuple[Callable, ActionMeta]] = PrivateAttr(
|
|
172
|
+
default_factory=dict
|
|
173
|
+
)
|
|
172
174
|
|
|
173
175
|
def __init__(
|
|
174
176
|
self,
|
|
@@ -193,6 +195,9 @@ class KronService(ServiceBackend):
|
|
|
193
195
|
def _register_actions(self) -> None:
|
|
194
196
|
"""Scan for @action decorated methods and register them."""
|
|
195
197
|
for name in dir(self):
|
|
198
|
+
# Skip dunder attributes to avoid Pydantic deprecation warnings
|
|
199
|
+
if name.startswith("__"):
|
|
200
|
+
continue
|
|
196
201
|
if name.startswith("_"):
|
|
197
202
|
method = getattr(self, name, None)
|
|
198
203
|
if method and callable(method):
|
|
@@ -287,7 +292,9 @@ class KronService(ServiceBackend):
|
|
|
287
292
|
|
|
288
293
|
# Validate options if we have typed options_type
|
|
289
294
|
if meta._options_type and self.config.operable:
|
|
290
|
-
options = self.config.operable.validate_instance(
|
|
295
|
+
options = self.config.operable.validate_instance(
|
|
296
|
+
meta._options_type, options
|
|
297
|
+
)
|
|
291
298
|
|
|
292
299
|
# Execute handler
|
|
293
300
|
result = await handler(options, ctx)
|
|
@@ -349,7 +356,9 @@ class KronService(ServiceBackend):
|
|
|
349
356
|
|
|
350
357
|
for result in results:
|
|
351
358
|
if EnforcementLevel.is_blocking(result):
|
|
352
|
-
raise PermissionError(
|
|
359
|
+
raise PermissionError(
|
|
360
|
+
f"Policy {result.policy_id} blocked: {result.message}"
|
|
361
|
+
)
|
|
353
362
|
|
|
354
363
|
except PermissionError:
|
|
355
364
|
raise
|