krons 0.1.1__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.
Files changed (142) hide show
  1. krons/__init__.py +49 -0
  2. krons/agent/__init__.py +144 -0
  3. krons/agent/mcps/__init__.py +14 -0
  4. krons/agent/mcps/loader.py +287 -0
  5. krons/agent/mcps/wrapper.py +799 -0
  6. krons/agent/message/__init__.py +20 -0
  7. krons/agent/message/action.py +69 -0
  8. krons/agent/message/assistant.py +52 -0
  9. krons/agent/message/common.py +49 -0
  10. krons/agent/message/instruction.py +130 -0
  11. krons/agent/message/prepare_msg.py +187 -0
  12. krons/agent/message/role.py +53 -0
  13. krons/agent/message/system.py +53 -0
  14. krons/agent/operations/__init__.py +82 -0
  15. krons/agent/operations/act.py +100 -0
  16. krons/agent/operations/generate.py +145 -0
  17. krons/agent/operations/llm_reparse.py +89 -0
  18. krons/agent/operations/operate.py +247 -0
  19. krons/agent/operations/parse.py +243 -0
  20. krons/agent/operations/react.py +286 -0
  21. krons/agent/operations/specs.py +235 -0
  22. krons/agent/operations/structure.py +151 -0
  23. krons/agent/operations/utils.py +79 -0
  24. krons/agent/providers/__init__.py +17 -0
  25. krons/agent/providers/anthropic_messages.py +146 -0
  26. krons/agent/providers/claude_code.py +276 -0
  27. krons/agent/providers/gemini.py +268 -0
  28. krons/agent/providers/match.py +75 -0
  29. krons/agent/providers/oai_chat.py +174 -0
  30. krons/agent/third_party/__init__.py +2 -0
  31. krons/agent/third_party/anthropic_models.py +154 -0
  32. krons/agent/third_party/claude_code.py +682 -0
  33. krons/agent/third_party/gemini_models.py +508 -0
  34. krons/agent/third_party/openai_models.py +295 -0
  35. krons/agent/tool.py +291 -0
  36. krons/core/__init__.py +56 -74
  37. krons/core/base/__init__.py +121 -0
  38. krons/core/{broadcaster.py → base/broadcaster.py} +7 -3
  39. krons/core/{element.py → base/element.py} +13 -5
  40. krons/core/{event.py → base/event.py} +39 -6
  41. krons/core/{eventbus.py → base/eventbus.py} +3 -1
  42. krons/core/{flow.py → base/flow.py} +11 -4
  43. krons/core/{graph.py → base/graph.py} +24 -8
  44. krons/core/{node.py → base/node.py} +44 -19
  45. krons/core/{pile.py → base/pile.py} +22 -8
  46. krons/core/{processor.py → base/processor.py} +21 -7
  47. krons/core/{progression.py → base/progression.py} +3 -1
  48. krons/{specs → core/specs}/__init__.py +0 -5
  49. krons/{specs → core/specs}/adapters/dataclass_field.py +16 -8
  50. krons/{specs → core/specs}/adapters/pydantic_adapter.py +11 -5
  51. krons/{specs → core/specs}/adapters/sql_ddl.py +14 -8
  52. krons/{specs → core/specs}/catalog/__init__.py +2 -2
  53. krons/{specs → core/specs}/catalog/_audit.py +2 -2
  54. krons/{specs → core/specs}/catalog/_common.py +2 -2
  55. krons/{specs → core/specs}/catalog/_content.py +4 -4
  56. krons/{specs → core/specs}/catalog/_enforcement.py +3 -3
  57. krons/{specs → core/specs}/factory.py +5 -5
  58. krons/{specs → core/specs}/operable.py +8 -2
  59. krons/{specs → core/specs}/protocol.py +4 -2
  60. krons/{specs → core/specs}/spec.py +23 -11
  61. krons/{types → core/types}/base.py +4 -2
  62. krons/{types → core/types}/db_types.py +2 -2
  63. krons/errors.py +13 -13
  64. krons/protocols.py +9 -4
  65. krons/resource/__init__.py +89 -0
  66. krons/{services → resource}/backend.py +48 -22
  67. krons/{services → resource}/endpoint.py +28 -14
  68. krons/{services → resource}/hook.py +20 -7
  69. krons/{services → resource}/imodel.py +46 -28
  70. krons/{services → resource}/registry.py +26 -24
  71. krons/{services → resource}/utilities/rate_limited_executor.py +7 -3
  72. krons/{services → resource}/utilities/rate_limiter.py +3 -1
  73. krons/{services → resource}/utilities/resilience.py +15 -5
  74. krons/resource/utilities/token_calculator.py +185 -0
  75. krons/session/__init__.py +12 -17
  76. krons/session/constraints.py +70 -0
  77. krons/session/exchange.py +11 -3
  78. krons/session/message.py +3 -1
  79. krons/session/registry.py +35 -0
  80. krons/session/session.py +165 -174
  81. krons/utils/__init__.py +45 -0
  82. krons/utils/_function_arg_parser.py +99 -0
  83. krons/utils/_pythonic_function_call.py +249 -0
  84. krons/utils/_to_list.py +9 -3
  85. krons/utils/_utils.py +6 -2
  86. krons/utils/concurrency/_async_call.py +4 -2
  87. krons/utils/concurrency/_errors.py +3 -1
  88. krons/utils/concurrency/_patterns.py +3 -1
  89. krons/utils/concurrency/_resource_tracker.py +6 -2
  90. krons/utils/display.py +257 -0
  91. krons/utils/fuzzy/__init__.py +6 -1
  92. krons/utils/fuzzy/_fuzzy_match.py +14 -8
  93. krons/utils/fuzzy/_string_similarity.py +3 -1
  94. krons/utils/fuzzy/_to_dict.py +3 -1
  95. krons/utils/schemas/__init__.py +26 -0
  96. krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
  97. krons/utils/schemas/_formatter.py +72 -0
  98. krons/utils/schemas/_minimal_yaml.py +151 -0
  99. krons/utils/schemas/_typescript.py +153 -0
  100. krons/utils/validators/__init__.py +3 -0
  101. krons/utils/validators/_validate_image_url.py +56 -0
  102. krons/work/__init__.py +126 -0
  103. krons/work/engine.py +333 -0
  104. krons/work/form.py +305 -0
  105. krons/{operations → work/operations}/__init__.py +7 -4
  106. krons/{operations → work/operations}/builder.py +1 -1
  107. krons/{enforcement → work/operations}/context.py +36 -5
  108. krons/{operations → work/operations}/flow.py +13 -5
  109. krons/{operations → work/operations}/node.py +45 -43
  110. krons/work/operations/registry.py +103 -0
  111. krons/{specs → work}/phrase.py +130 -13
  112. krons/{enforcement → work}/policy.py +3 -3
  113. krons/work/report.py +268 -0
  114. krons/work/rules/__init__.py +47 -0
  115. krons/{enforcement → work/rules}/common/boolean.py +3 -1
  116. krons/{enforcement → work/rules}/common/choice.py +9 -3
  117. krons/{enforcement → work/rules}/common/number.py +3 -1
  118. krons/{enforcement → work/rules}/common/string.py +9 -3
  119. krons/{enforcement → work/rules}/rule.py +1 -1
  120. krons/{enforcement → work/rules}/validator.py +20 -5
  121. krons/{enforcement → work}/service.py +16 -7
  122. krons/work/worker.py +266 -0
  123. {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/METADATA +15 -1
  124. krons-0.2.0.dist-info/RECORD +154 -0
  125. krons/enforcement/__init__.py +0 -57
  126. krons/operations/registry.py +0 -92
  127. krons/services/__init__.py +0 -81
  128. krons-0.1.1.dist-info/RECORD +0 -101
  129. /krons/{specs → core/specs}/adapters/__init__.py +0 -0
  130. /krons/{specs → core/specs}/adapters/_utils.py +0 -0
  131. /krons/{specs → core/specs}/adapters/factory.py +0 -0
  132. /krons/{types → core/types}/__init__.py +0 -0
  133. /krons/{types → core/types}/_sentinel.py +0 -0
  134. /krons/{types → core/types}/identity.py +0 -0
  135. /krons/{services → resource}/utilities/__init__.py +0 -0
  136. /krons/{services → resource}/utilities/header_factory.py +0 -0
  137. /krons/{enforcement → work/rules}/common/__init__.py +0 -0
  138. /krons/{enforcement → work/rules}/common/mapping.py +0 -0
  139. /krons/{enforcement → work/rules}/common/model.py +0 -0
  140. /krons/{enforcement → work/rules}/registry.py +0 -0
  141. {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/WHEEL +0 -0
  142. {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,103 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Per-session operation handler registry.
5
+
6
+ Maps operation names to async handlers. Instantiated per-Session
7
+ for isolation, testability, and per-session customization.
8
+
9
+ Handler signature: async handler(params, ctx: RequestContext) -> result
10
+ Operation._invoke() creates the RequestContext from bound session/branch.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Awaitable, Callable
16
+ from typing import Any
17
+
18
+ __all__ = ("OperationHandler", "OperationRegistry")
19
+
20
+ OperationHandler = Callable[..., Awaitable[Any]]
21
+ """Handler signature: async (params, ctx: RequestContext) -> result"""
22
+
23
+
24
+ class OperationRegistry:
25
+ """Map operation names to async handler functions.
26
+
27
+ Per-session registry (not global) for isolation and testability.
28
+
29
+ Example:
30
+ from krons.agent.operations import generate, structure, operate
31
+
32
+ registry = OperationRegistry()
33
+ registry.register("generate", generate)
34
+ registry.register("structure", structure)
35
+ registry.register("operate", operate)
36
+
37
+ # Called by Operation._invoke() — users call session.conduct()
38
+ handler = registry.get("generate")
39
+ result = await handler(params, ctx)
40
+ """
41
+
42
+ def __init__(self):
43
+ """Initialize empty registry."""
44
+ self._handlers: dict[str, OperationHandler] = {}
45
+
46
+ def register(
47
+ self,
48
+ operation_name: str,
49
+ handler: OperationHandler,
50
+ *,
51
+ override: bool = False,
52
+ ) -> None:
53
+ """Register handler for operation name.
54
+
55
+ Args:
56
+ operation_name: Lookup key (e.g. "generate", "operate").
57
+ handler: Async (params, ctx) -> result.
58
+ override: Allow replacing existing. Default False.
59
+
60
+ Raises:
61
+ ValueError: If name exists and override=False.
62
+ """
63
+ if operation_name in self._handlers and not override:
64
+ raise ValueError(
65
+ f"Operation '{operation_name}' already registered. "
66
+ "Use override=True to replace."
67
+ )
68
+ self._handlers[operation_name] = handler
69
+
70
+ def get(self, operation_name: str) -> OperationHandler:
71
+ """Get handler by name. Raises KeyError with available names if not found."""
72
+ if operation_name not in self._handlers:
73
+ raise KeyError(
74
+ f"Operation '{operation_name}' not registered. "
75
+ f"Available: {self.list_names()}"
76
+ )
77
+ return self._handlers[operation_name]
78
+
79
+ def has(self, operation_name: str) -> bool:
80
+ """Check if name is registered."""
81
+ return operation_name in self._handlers
82
+
83
+ def unregister(self, operation_name: str) -> bool:
84
+ """Remove registration. Returns True if existed."""
85
+ if operation_name in self._handlers:
86
+ del self._handlers[operation_name]
87
+ return True
88
+ return False
89
+
90
+ def list_names(self) -> list[str]:
91
+ """Return all registered operation names."""
92
+ return list(self._handlers.keys())
93
+
94
+ def __contains__(self, operation_name: str) -> bool:
95
+ """Support 'name in registry' syntax."""
96
+ return operation_name in self._handlers
97
+
98
+ def __len__(self) -> int:
99
+ """Count of registered operations."""
100
+ return len(self._handlers)
101
+
102
+ def __repr__(self) -> str:
103
+ return f"OperationRegistry(operations={self.list_names()})"
@@ -6,7 +6,7 @@ A Phrase wraps an async handler with:
6
6
  - Validation via Operable
7
7
 
8
8
  Usage with decorator (custom handler):
9
- from krons.specs import Operable, phrase
9
+ from krons.core.specs import Operable, phrase
10
10
 
11
11
  consent_operable = Operable([
12
12
  Spec("subject_id", UUID),
@@ -25,7 +25,7 @@ Usage with decorator (custom handler):
25
25
  result = await verify_consent({"subject_id": id, "scope": "background"}, ctx)
26
26
 
27
27
  Usage with CrudPattern (declarative):
28
- from krons.specs import Operable, phrase, CrudPattern
28
+ from krons.core.specs import Operable, phrase, CrudPattern
29
29
 
30
30
  def check_has_consent(row):
31
31
  return {"has_consent": row["status"] in {"active"} if row else False}
@@ -48,12 +48,14 @@ from collections.abc import Awaitable, Callable, Mapping
48
48
  from dataclasses import dataclass
49
49
  from enum import Enum
50
50
  from types import MappingProxyType
51
- from typing import Any
51
+ from typing import TYPE_CHECKING, Any
52
52
 
53
- from krons.types import Unset, is_unset
53
+ from krons.core.specs.operable import Operable
54
+ from krons.core.types import Unset, is_unset
54
55
  from krons.utils.sql import validate_identifier
55
56
 
56
- from .operable import Operable
57
+ if TYPE_CHECKING:
58
+ from krons.work.operations.node import Operation
57
59
 
58
60
  __all__ = ("CrudPattern", "CrudOperation", "Phrase", "phrase")
59
61
 
@@ -111,18 +113,31 @@ class CrudPattern:
111
113
  object.__setattr__(self, "lookup", frozenset(self.lookup))
112
114
  # Normalize None mappings to immutable empty maps; freeze mutable dicts
113
115
  object.__setattr__(
114
- self, "filters",
115
- _EMPTY_MAP if self.filters is None else MappingProxyType(dict(self.filters)),
116
+ self,
117
+ "filters",
118
+ (
119
+ _EMPTY_MAP
120
+ if self.filters is None
121
+ else MappingProxyType(dict(self.filters))
122
+ ),
116
123
  )
117
124
  object.__setattr__(
118
- self, "set_fields",
119
- _EMPTY_MAP if self.set_fields is None
120
- else MappingProxyType(dict(self.set_fields)),
125
+ self,
126
+ "set_fields",
127
+ (
128
+ _EMPTY_MAP
129
+ if self.set_fields is None
130
+ else MappingProxyType(dict(self.set_fields))
131
+ ),
121
132
  )
122
133
  object.__setattr__(
123
- self, "defaults",
124
- _EMPTY_MAP if self.defaults is None
125
- else MappingProxyType(dict(self.defaults)),
134
+ self,
135
+ "defaults",
136
+ (
137
+ _EMPTY_MAP
138
+ if self.defaults is None
139
+ else MappingProxyType(dict(self.defaults))
140
+ ),
126
141
  )
127
142
 
128
143
 
@@ -307,6 +322,108 @@ class Phrase:
307
322
  )
308
323
  return self._result_type
309
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
+
310
427
 
311
428
  def _to_pascal(snake_name: str) -> str:
312
429
  """Convert snake_case name to PascalCase.
@@ -13,9 +13,9 @@ from collections.abc import Sequence
13
13
  from dataclasses import dataclass, field
14
14
  from typing import Any, Protocol, runtime_checkable
15
15
 
16
- from krons.enforcement.context import RequestContext
17
- from krons.specs.catalog._enforcement import EnforcementLevel
18
- from krons.types.base import DataClass
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
19
 
20
20
  __all__ = (
21
21
  "EnforcementLevel",
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(f"Invalid boolean value: expected bool, got {type(v).__name__}")
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 = {str(c).lower(): c for c in self.choices if isinstance(c, str)}
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(f"Invalid choice: {v} not in {sorted(str(c) for c in self.choices)}")
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(f"Cannot fix choice: {v} not in {sorted(str(c) for c in self.choices)}")
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(f"Invalid number value: expected int or float, got {type(v).__name__}")
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}")