krons 0.1.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.
- kronos/__init__.py +0 -0
- kronos/core/__init__.py +145 -0
- kronos/core/broadcaster.py +116 -0
- kronos/core/element.py +225 -0
- kronos/core/event.py +316 -0
- kronos/core/eventbus.py +116 -0
- kronos/core/flow.py +356 -0
- kronos/core/graph.py +442 -0
- kronos/core/node.py +982 -0
- kronos/core/pile.py +575 -0
- kronos/core/processor.py +494 -0
- kronos/core/progression.py +296 -0
- kronos/enforcement/__init__.py +57 -0
- kronos/enforcement/common/__init__.py +34 -0
- kronos/enforcement/common/boolean.py +85 -0
- kronos/enforcement/common/choice.py +97 -0
- kronos/enforcement/common/mapping.py +118 -0
- kronos/enforcement/common/model.py +102 -0
- kronos/enforcement/common/number.py +98 -0
- kronos/enforcement/common/string.py +140 -0
- kronos/enforcement/context.py +129 -0
- kronos/enforcement/policy.py +80 -0
- kronos/enforcement/registry.py +153 -0
- kronos/enforcement/rule.py +312 -0
- kronos/enforcement/service.py +370 -0
- kronos/enforcement/validator.py +198 -0
- kronos/errors.py +146 -0
- kronos/operations/__init__.py +32 -0
- kronos/operations/builder.py +228 -0
- kronos/operations/flow.py +398 -0
- kronos/operations/node.py +101 -0
- kronos/operations/registry.py +92 -0
- kronos/protocols.py +414 -0
- kronos/py.typed +0 -0
- kronos/services/__init__.py +81 -0
- kronos/services/backend.py +286 -0
- kronos/services/endpoint.py +608 -0
- kronos/services/hook.py +471 -0
- kronos/services/imodel.py +465 -0
- kronos/services/registry.py +115 -0
- kronos/services/utilities/__init__.py +36 -0
- kronos/services/utilities/header_factory.py +87 -0
- kronos/services/utilities/rate_limited_executor.py +271 -0
- kronos/services/utilities/rate_limiter.py +180 -0
- kronos/services/utilities/resilience.py +414 -0
- kronos/session/__init__.py +41 -0
- kronos/session/exchange.py +258 -0
- kronos/session/message.py +60 -0
- kronos/session/session.py +411 -0
- kronos/specs/__init__.py +25 -0
- kronos/specs/adapters/__init__.py +0 -0
- kronos/specs/adapters/_utils.py +45 -0
- kronos/specs/adapters/dataclass_field.py +246 -0
- kronos/specs/adapters/factory.py +56 -0
- kronos/specs/adapters/pydantic_adapter.py +309 -0
- kronos/specs/adapters/sql_ddl.py +946 -0
- kronos/specs/catalog/__init__.py +36 -0
- kronos/specs/catalog/_audit.py +39 -0
- kronos/specs/catalog/_common.py +43 -0
- kronos/specs/catalog/_content.py +59 -0
- kronos/specs/catalog/_enforcement.py +70 -0
- kronos/specs/factory.py +120 -0
- kronos/specs/operable.py +314 -0
- kronos/specs/phrase.py +405 -0
- kronos/specs/protocol.py +140 -0
- kronos/specs/spec.py +506 -0
- kronos/types/__init__.py +60 -0
- kronos/types/_sentinel.py +311 -0
- kronos/types/base.py +369 -0
- kronos/types/db_types.py +260 -0
- kronos/types/identity.py +66 -0
- kronos/utils/__init__.py +40 -0
- kronos/utils/_hash.py +234 -0
- kronos/utils/_json_dump.py +392 -0
- kronos/utils/_lazy_init.py +63 -0
- kronos/utils/_to_list.py +165 -0
- kronos/utils/_to_num.py +85 -0
- kronos/utils/_utils.py +375 -0
- kronos/utils/concurrency/__init__.py +205 -0
- kronos/utils/concurrency/_async_call.py +333 -0
- kronos/utils/concurrency/_cancel.py +122 -0
- kronos/utils/concurrency/_errors.py +96 -0
- kronos/utils/concurrency/_patterns.py +363 -0
- kronos/utils/concurrency/_primitives.py +328 -0
- kronos/utils/concurrency/_priority_queue.py +135 -0
- kronos/utils/concurrency/_resource_tracker.py +110 -0
- kronos/utils/concurrency/_run_async.py +67 -0
- kronos/utils/concurrency/_task.py +95 -0
- kronos/utils/concurrency/_utils.py +79 -0
- kronos/utils/fuzzy/__init__.py +14 -0
- kronos/utils/fuzzy/_extract_json.py +90 -0
- kronos/utils/fuzzy/_fuzzy_json.py +288 -0
- kronos/utils/fuzzy/_fuzzy_match.py +149 -0
- kronos/utils/fuzzy/_string_similarity.py +187 -0
- kronos/utils/fuzzy/_to_dict.py +396 -0
- kronos/utils/sql/__init__.py +13 -0
- kronos/utils/sql/_sql_validation.py +142 -0
- krons-0.1.0.dist-info/METADATA +70 -0
- krons-0.1.0.dist-info/RECORD +101 -0
- krons-0.1.0.dist-info/WHEEL +4 -0
- krons-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections import deque
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
9
|
+
|
|
10
|
+
from kronos.types import is_sentinel
|
|
11
|
+
from kronos.utils.concurrency import is_coro_func
|
|
12
|
+
|
|
13
|
+
from .registry import RuleRegistry, get_default_registry
|
|
14
|
+
from .rule import Rule, ValidationError
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from kronos.specs import Operable, Spec
|
|
18
|
+
|
|
19
|
+
__all__ = ("Validator",)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Validator:
|
|
23
|
+
DEFAULT_MAX_LOG_ENTRIES: ClassVar[int] = 1000
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
registry: RuleRegistry | None = None,
|
|
28
|
+
max_log_entries: int | None = None,
|
|
29
|
+
):
|
|
30
|
+
self.registry = registry or get_default_registry()
|
|
31
|
+
max_entries = (
|
|
32
|
+
max_log_entries if max_log_entries is not None else self.DEFAULT_MAX_LOG_ENTRIES
|
|
33
|
+
)
|
|
34
|
+
self.validation_log: deque[dict[str, Any]] = deque(
|
|
35
|
+
maxlen=max_entries if max_entries > 0 else None
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def log_validation_error(self, field: str, value: Any, error: str) -> None:
|
|
39
|
+
"""Log a validation error with timestamp.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
field: Field name that failed validation
|
|
43
|
+
value: Value that failed validation
|
|
44
|
+
error: Error message
|
|
45
|
+
"""
|
|
46
|
+
log_entry = {
|
|
47
|
+
"field": field,
|
|
48
|
+
"value": value,
|
|
49
|
+
"error": error,
|
|
50
|
+
"timestamp": datetime.now().isoformat(),
|
|
51
|
+
}
|
|
52
|
+
self.validation_log.append(log_entry)
|
|
53
|
+
|
|
54
|
+
def get_validation_summary(self) -> dict[str, Any]:
|
|
55
|
+
"""Get summary of validation log.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Dict with total_errors, fields_with_errors, and error_entries
|
|
59
|
+
"""
|
|
60
|
+
fields_with_errors = set()
|
|
61
|
+
for entry in self.validation_log:
|
|
62
|
+
if "field" in entry:
|
|
63
|
+
fields_with_errors.add(entry["field"])
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
"total_errors": len(self.validation_log),
|
|
67
|
+
"fields_with_errors": sorted(list(fields_with_errors)),
|
|
68
|
+
"error_entries": list(self.validation_log),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
def clear_log(self) -> None:
|
|
72
|
+
"""Clear the validation log."""
|
|
73
|
+
self.validation_log.clear()
|
|
74
|
+
|
|
75
|
+
def get_rule_for_spec(self, spec: Spec) -> Rule | None:
|
|
76
|
+
override = spec.get("rule")
|
|
77
|
+
if override is not None and isinstance(override, Rule):
|
|
78
|
+
return override
|
|
79
|
+
|
|
80
|
+
return self.registry.get_rule(
|
|
81
|
+
base_type=spec.base_type,
|
|
82
|
+
field_name=spec.name if spec.name else None,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
async def validate_spec(
|
|
86
|
+
self,
|
|
87
|
+
spec: Spec,
|
|
88
|
+
value: Any,
|
|
89
|
+
auto_fix: bool = True,
|
|
90
|
+
strict: bool = True,
|
|
91
|
+
) -> Any:
|
|
92
|
+
field_name = spec.name or "<unnamed>"
|
|
93
|
+
|
|
94
|
+
if value is None:
|
|
95
|
+
if spec.is_nullable:
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
value = await spec.acreate_default_value()
|
|
99
|
+
except ValueError:
|
|
100
|
+
if strict:
|
|
101
|
+
error_msg = f"Field '{field_name}' is None but not nullable and has no default"
|
|
102
|
+
self.log_validation_error(field_name, value, error_msg)
|
|
103
|
+
raise ValidationError(error_msg)
|
|
104
|
+
return value
|
|
105
|
+
|
|
106
|
+
rule = self.get_rule_for_spec(spec)
|
|
107
|
+
|
|
108
|
+
if spec.is_listable:
|
|
109
|
+
if not isinstance(value, list):
|
|
110
|
+
if auto_fix:
|
|
111
|
+
value = [value]
|
|
112
|
+
else:
|
|
113
|
+
error_msg = f"Field '{field_name}' expected list, got {type(value).__name__}"
|
|
114
|
+
self.log_validation_error(field_name, value, error_msg)
|
|
115
|
+
raise ValidationError(error_msg)
|
|
116
|
+
|
|
117
|
+
validated_items = []
|
|
118
|
+
for i, item in enumerate(value):
|
|
119
|
+
item_name = f"{field_name}[{i}]"
|
|
120
|
+
if rule is not None:
|
|
121
|
+
try:
|
|
122
|
+
validated_item = await rule.invoke(
|
|
123
|
+
item_name, item, spec.base_type, auto_fix=auto_fix
|
|
124
|
+
)
|
|
125
|
+
except Exception as e:
|
|
126
|
+
self.log_validation_error(item_name, item, str(e))
|
|
127
|
+
raise
|
|
128
|
+
else:
|
|
129
|
+
validated_item = item
|
|
130
|
+
validated_items.append(validated_item)
|
|
131
|
+
|
|
132
|
+
value = validated_items
|
|
133
|
+
else:
|
|
134
|
+
if rule is None:
|
|
135
|
+
if strict:
|
|
136
|
+
error_msg = (
|
|
137
|
+
f"No rule found for field '{field_name}' with type {spec.base_type}. "
|
|
138
|
+
f"Register a rule or set strict=False."
|
|
139
|
+
)
|
|
140
|
+
self.log_validation_error(field_name, value, error_msg)
|
|
141
|
+
raise ValidationError(error_msg)
|
|
142
|
+
else:
|
|
143
|
+
try:
|
|
144
|
+
value = await rule.invoke(field_name, value, spec.base_type, auto_fix=auto_fix)
|
|
145
|
+
except Exception as e:
|
|
146
|
+
self.log_validation_error(field_name, value, str(e))
|
|
147
|
+
raise
|
|
148
|
+
|
|
149
|
+
custom_validators = spec.get("validator")
|
|
150
|
+
if is_sentinel(custom_validators) or custom_validators is None:
|
|
151
|
+
validators = []
|
|
152
|
+
elif callable(custom_validators):
|
|
153
|
+
validators = [custom_validators]
|
|
154
|
+
elif isinstance(custom_validators, list):
|
|
155
|
+
validators = custom_validators
|
|
156
|
+
else:
|
|
157
|
+
validators = []
|
|
158
|
+
|
|
159
|
+
for validator_fn in validators:
|
|
160
|
+
if not callable(validator_fn):
|
|
161
|
+
continue
|
|
162
|
+
try:
|
|
163
|
+
if is_coro_func(validator_fn):
|
|
164
|
+
value = await validator_fn(value)
|
|
165
|
+
else:
|
|
166
|
+
value = validator_fn(value)
|
|
167
|
+
except Exception as e:
|
|
168
|
+
error_msg = f"Custom validator failed for '{field_name}': {e}"
|
|
169
|
+
self.log_validation_error(field_name, value, error_msg)
|
|
170
|
+
raise ValidationError(error_msg) from e
|
|
171
|
+
|
|
172
|
+
return value
|
|
173
|
+
|
|
174
|
+
async def validate_operable(
|
|
175
|
+
self,
|
|
176
|
+
data: dict[str, Any],
|
|
177
|
+
operable: Operable,
|
|
178
|
+
capabilities: set[str] | None = None,
|
|
179
|
+
auto_fix: bool = True,
|
|
180
|
+
strict: bool = True,
|
|
181
|
+
) -> dict[str, Any]:
|
|
182
|
+
capabilities = capabilities or operable.allowed()
|
|
183
|
+
validated: dict[str, Any] = {}
|
|
184
|
+
|
|
185
|
+
for spec in operable.get_specs():
|
|
186
|
+
field_name = spec.name
|
|
187
|
+
if is_sentinel(field_name) or not isinstance(field_name, str):
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
if field_name not in capabilities:
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
value = data.get(field_name)
|
|
194
|
+
validated[field_name] = await self.validate_spec(
|
|
195
|
+
spec, value, auto_fix=auto_fix, strict=strict
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return validated
|
kronos/errors.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Kron exception hierarchy with structured details and retryability.
|
|
5
|
+
|
|
6
|
+
All exceptions inherit from KronError and include:
|
|
7
|
+
- message: Human-readable description
|
|
8
|
+
- details: Structured context dict
|
|
9
|
+
- retryable: Whether retry might succeed
|
|
10
|
+
|
|
11
|
+
Naming: KronConnectionError/KronTimeoutError avoid shadowing builtins.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .protocols import Serializable, implements
|
|
19
|
+
|
|
20
|
+
__all__ = (
|
|
21
|
+
"AccessError",
|
|
22
|
+
"ConfigurationError",
|
|
23
|
+
"ExecutionError",
|
|
24
|
+
"ExistsError",
|
|
25
|
+
"KronConnectionError",
|
|
26
|
+
"KronError",
|
|
27
|
+
"KronTimeoutError",
|
|
28
|
+
"NotFoundError",
|
|
29
|
+
"OperationError",
|
|
30
|
+
"QueueFullError",
|
|
31
|
+
"ValidationError",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@implements(Serializable)
|
|
36
|
+
class KronError(Exception):
|
|
37
|
+
"""Base exception for kron. Serializable with structured details.
|
|
38
|
+
|
|
39
|
+
Subclasses set default_message and default_retryable.
|
|
40
|
+
Use cause= to chain exceptions with preserved traceback.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
message: Human-readable description.
|
|
44
|
+
details: Structured context for debugging/logging.
|
|
45
|
+
retryable: Whether retry might succeed.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
default_message: str = "kron error"
|
|
49
|
+
default_retryable: bool = True
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
message: str | None = None,
|
|
54
|
+
*,
|
|
55
|
+
details: dict[str, Any] | None = None,
|
|
56
|
+
retryable: bool | None = None,
|
|
57
|
+
cause: Exception | None = None,
|
|
58
|
+
):
|
|
59
|
+
"""Initialize with optional message, details, retryability, and cause."""
|
|
60
|
+
self.message = message or self.default_message
|
|
61
|
+
self.details = details or {}
|
|
62
|
+
self.retryable = retryable if retryable is not None else self.default_retryable
|
|
63
|
+
|
|
64
|
+
if cause:
|
|
65
|
+
self.__cause__ = cause # Preserve traceback
|
|
66
|
+
|
|
67
|
+
super().__init__(self.message)
|
|
68
|
+
|
|
69
|
+
def to_dict(self, **kwargs: Any) -> dict[str, Any]:
|
|
70
|
+
"""Serialize to dict: {error, message, retryable, details?}."""
|
|
71
|
+
return {
|
|
72
|
+
"error": self.__class__.__name__,
|
|
73
|
+
"message": self.message,
|
|
74
|
+
"retryable": self.retryable,
|
|
75
|
+
**({"details": self.details} if self.details else {}),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ValidationError(KronError):
|
|
80
|
+
"""Data validation failure. Raise when input fails schema/constraint checks."""
|
|
81
|
+
|
|
82
|
+
default_message = "Validation failed"
|
|
83
|
+
default_retryable = False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AccessError(KronError):
|
|
87
|
+
"""Permission denied. Raise when capability/resource access is blocked."""
|
|
88
|
+
|
|
89
|
+
default_message = "Access denied"
|
|
90
|
+
default_retryable = False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ConfigurationError(KronError):
|
|
94
|
+
"""Invalid configuration. Raise when setup/config is incorrect."""
|
|
95
|
+
|
|
96
|
+
default_message = "Configuration error"
|
|
97
|
+
default_retryable = False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ExecutionError(KronError):
|
|
101
|
+
"""Execution failure. Raise when Event/Calling invoke fails (often transient)."""
|
|
102
|
+
|
|
103
|
+
default_message = "Execution failed"
|
|
104
|
+
default_retryable = True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class KronConnectionError(KronError):
|
|
108
|
+
"""Network/connection failure. Named to avoid shadowing builtins."""
|
|
109
|
+
|
|
110
|
+
default_message = "Connection error"
|
|
111
|
+
default_retryable = True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class KronTimeoutError(KronError):
|
|
115
|
+
"""Operation timeout. Named to avoid shadowing builtins."""
|
|
116
|
+
|
|
117
|
+
default_message = "Operation timed out"
|
|
118
|
+
default_retryable = True
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class NotFoundError(KronError):
|
|
122
|
+
"""Resource/item not found. Raise when lookup fails."""
|
|
123
|
+
|
|
124
|
+
default_message = "Item not found"
|
|
125
|
+
default_retryable = False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ExistsError(KronError):
|
|
129
|
+
"""Duplicate item. Raise when creating item that already exists."""
|
|
130
|
+
|
|
131
|
+
default_message = "Item already exists"
|
|
132
|
+
default_retryable = False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class QueueFullError(KronError):
|
|
136
|
+
"""Capacity exceeded. Raise when queue/buffer is full."""
|
|
137
|
+
|
|
138
|
+
default_message = "Queue is full"
|
|
139
|
+
default_retryable = True
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class OperationError(KronError):
|
|
143
|
+
"""Generic operation failure. Use for unclassified operation errors."""
|
|
144
|
+
|
|
145
|
+
default_message = "Operation failed"
|
|
146
|
+
default_retryable = False
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Operations: executable graph nodes with dependency-aware execution.
|
|
5
|
+
|
|
6
|
+
Core types:
|
|
7
|
+
Operation: Node + Event hybrid for graph-based execution.
|
|
8
|
+
OperationRegistry: Per-session factory mapping.
|
|
9
|
+
OperationGraphBuilder (Builder): Fluent DAG construction.
|
|
10
|
+
|
|
11
|
+
Execution:
|
|
12
|
+
flow(): Execute DAG, return results dict.
|
|
13
|
+
flow_stream(): Execute DAG, yield results incrementally.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from .builder import Builder, OperationGraphBuilder
|
|
19
|
+
from .flow import DependencyAwareExecutor, flow, flow_stream
|
|
20
|
+
from .node import Operation, create_operation
|
|
21
|
+
from .registry import OperationRegistry
|
|
22
|
+
|
|
23
|
+
__all__ = (
|
|
24
|
+
"Builder",
|
|
25
|
+
"DependencyAwareExecutor",
|
|
26
|
+
"Operation",
|
|
27
|
+
"OperationGraphBuilder",
|
|
28
|
+
"OperationRegistry",
|
|
29
|
+
"create_operation",
|
|
30
|
+
"flow",
|
|
31
|
+
"flow_stream",
|
|
32
|
+
)
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Fluent builder for operation DAGs.
|
|
5
|
+
|
|
6
|
+
Build graphs with chained .add() calls, automatic sequential linking,
|
|
7
|
+
and explicit dependency management. Alias: Builder = OperationGraphBuilder.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
from kronos.core import Edge, Graph
|
|
16
|
+
from kronos.types import Undefined, UndefinedType, is_sentinel, not_sentinel
|
|
17
|
+
from kronos.utils._utils import to_uuid
|
|
18
|
+
|
|
19
|
+
from .node import Operation
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from kronos.session import Branch
|
|
23
|
+
|
|
24
|
+
__all__ = ("Builder", "OperationGraphBuilder")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_branch_ref(branch: Any) -> UUID | str:
|
|
28
|
+
"""Convert branch reference to UUID or trimmed name string.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ValueError: If branch is not a valid UUID, Branch, or non-empty string.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
return to_uuid(branch)
|
|
35
|
+
except (ValueError, TypeError):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
if isinstance(branch, str) and branch.strip():
|
|
39
|
+
return branch.strip()
|
|
40
|
+
|
|
41
|
+
raise ValueError(f"Invalid branch reference: {branch}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class OperationGraphBuilder:
|
|
45
|
+
"""Fluent builder for operation DAGs with automatic linking.
|
|
46
|
+
|
|
47
|
+
Operations added sequentially auto-link unless depends_on is specified.
|
|
48
|
+
Use depends_on=[] for independent operations, depends_on=["op1"] for explicit.
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
graph = (Builder()
|
|
52
|
+
.add("fetch", "http_get", {"url": "..."})
|
|
53
|
+
.add("parse", "json_parse") # auto-links to fetch
|
|
54
|
+
.add("validate", "schema_check", depends_on=["parse"])
|
|
55
|
+
.build())
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
graph: Underlying Graph being built.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, graph: Graph | UndefinedType = Undefined):
|
|
62
|
+
"""Initialize builder, optionally with existing graph.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
graph: Pre-existing Graph to extend, or Undefined for new.
|
|
66
|
+
"""
|
|
67
|
+
self.graph = Graph() if is_sentinel(graph) else graph
|
|
68
|
+
self._nodes: dict[str, Operation] = {}
|
|
69
|
+
self._executed: set[UUID] = set()
|
|
70
|
+
self._current_heads: list[str] = []
|
|
71
|
+
|
|
72
|
+
def add(
|
|
73
|
+
self,
|
|
74
|
+
name: str,
|
|
75
|
+
operation: str,
|
|
76
|
+
parameters: dict[str, Any] | Any | UndefinedType = Undefined,
|
|
77
|
+
depends_on: list[str] | UndefinedType = Undefined,
|
|
78
|
+
branch: str | UUID | Branch | UndefinedType = Undefined,
|
|
79
|
+
inherit_context: bool = False,
|
|
80
|
+
metadata: dict[str, Any] | UndefinedType = Undefined,
|
|
81
|
+
) -> OperationGraphBuilder:
|
|
82
|
+
"""Add operation to graph.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
name: Unique operation name for reference.
|
|
86
|
+
operation: Operation type (registry key).
|
|
87
|
+
parameters: Factory arguments (dict or model).
|
|
88
|
+
depends_on: Explicit dependencies. Undefined=auto-link, []=independent.
|
|
89
|
+
branch: Target branch (Branch|UUID|str). Undefined=default.
|
|
90
|
+
inherit_context: Store primary_dependency for context inheritance.
|
|
91
|
+
metadata: Additional metadata dict.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Self for chaining.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
ValueError: If name exists or dependency not found.
|
|
98
|
+
"""
|
|
99
|
+
if name in self._nodes:
|
|
100
|
+
raise ValueError(f"Operation with name '{name}' already exists")
|
|
101
|
+
|
|
102
|
+
resolved_params = {} if is_sentinel(parameters) else parameters
|
|
103
|
+
resolved_metadata = {} if is_sentinel(metadata) else metadata
|
|
104
|
+
op = Operation(
|
|
105
|
+
operation_type=operation,
|
|
106
|
+
parameters=resolved_params,
|
|
107
|
+
metadata=resolved_metadata,
|
|
108
|
+
streaming=False,
|
|
109
|
+
)
|
|
110
|
+
op.metadata["name"] = name
|
|
111
|
+
|
|
112
|
+
if not_sentinel(branch):
|
|
113
|
+
op.metadata["branch"] = _resolve_branch_ref(branch)
|
|
114
|
+
|
|
115
|
+
if inherit_context and not_sentinel(depends_on) and depends_on:
|
|
116
|
+
op.metadata["inherit_context"] = True
|
|
117
|
+
op.metadata["primary_dependency"] = self._nodes[depends_on[0]].id
|
|
118
|
+
|
|
119
|
+
self.graph.add_node(op)
|
|
120
|
+
self._nodes[name] = op
|
|
121
|
+
|
|
122
|
+
if not_sentinel(depends_on):
|
|
123
|
+
for dep_name in depends_on:
|
|
124
|
+
if dep_name not in self._nodes:
|
|
125
|
+
raise ValueError(f"Dependency '{dep_name}' not found")
|
|
126
|
+
dep_node = self._nodes[dep_name]
|
|
127
|
+
edge = Edge(head=dep_node.id, tail=op.id, label=["depends_on"])
|
|
128
|
+
self.graph.add_edge(edge)
|
|
129
|
+
elif self._current_heads:
|
|
130
|
+
for head_name in self._current_heads:
|
|
131
|
+
if head_name in self._nodes:
|
|
132
|
+
head_node = self._nodes[head_name]
|
|
133
|
+
edge = Edge(head=head_node.id, tail=op.id, label=["sequential"])
|
|
134
|
+
self.graph.add_edge(edge)
|
|
135
|
+
|
|
136
|
+
self._current_heads = [name]
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def depends_on(
|
|
140
|
+
self,
|
|
141
|
+
target: str,
|
|
142
|
+
*dependencies: str,
|
|
143
|
+
label: list[str] | UndefinedType = Undefined,
|
|
144
|
+
) -> OperationGraphBuilder:
|
|
145
|
+
"""Add explicit dependency edges: dependencies -> target.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
target: Operation that depends on others.
|
|
149
|
+
*dependencies: Operations that target depends on.
|
|
150
|
+
label: Optional edge labels.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Self for chaining.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
ValueError: If target or any dependency not found.
|
|
157
|
+
"""
|
|
158
|
+
if target not in self._nodes:
|
|
159
|
+
raise ValueError(f"Target operation '{target}' not found")
|
|
160
|
+
|
|
161
|
+
target_node = self._nodes[target]
|
|
162
|
+
resolved_label = [] if is_sentinel(label, {"none", "empty"}) else label
|
|
163
|
+
|
|
164
|
+
for dep_name in dependencies:
|
|
165
|
+
if dep_name not in self._nodes:
|
|
166
|
+
raise ValueError(f"Dependency operation '{dep_name}' not found")
|
|
167
|
+
|
|
168
|
+
dep_node = self._nodes[dep_name]
|
|
169
|
+
|
|
170
|
+
# Create edge: dependency -> target
|
|
171
|
+
edge = Edge(
|
|
172
|
+
head=dep_node.id,
|
|
173
|
+
tail=target_node.id,
|
|
174
|
+
label=resolved_label,
|
|
175
|
+
)
|
|
176
|
+
self.graph.add_edge(edge)
|
|
177
|
+
|
|
178
|
+
return self
|
|
179
|
+
|
|
180
|
+
def get(self, name: str) -> Operation:
|
|
181
|
+
"""Get operation by name. Raises ValueError if not found."""
|
|
182
|
+
if name not in self._nodes:
|
|
183
|
+
raise ValueError(f"Operation '{name}' not found")
|
|
184
|
+
return self._nodes[name]
|
|
185
|
+
|
|
186
|
+
def get_by_id(self, operation_id: UUID) -> Operation | None:
|
|
187
|
+
"""Get operation by UUID, or None if not in graph."""
|
|
188
|
+
node = self.graph.nodes.get(operation_id, None)
|
|
189
|
+
if isinstance(node, Operation):
|
|
190
|
+
return node
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
def mark_executed(self, *names: str) -> OperationGraphBuilder:
|
|
194
|
+
"""Mark operations as executed for incremental workflows. Returns self."""
|
|
195
|
+
for name in names:
|
|
196
|
+
if name in self._nodes:
|
|
197
|
+
self._executed.add(self._nodes[name].id)
|
|
198
|
+
return self
|
|
199
|
+
|
|
200
|
+
def get_unexecuted_nodes(self) -> list[Operation]:
|
|
201
|
+
"""Return operations not marked as executed."""
|
|
202
|
+
return [op for op in self._nodes.values() if op.id not in self._executed]
|
|
203
|
+
|
|
204
|
+
def build(self) -> Graph:
|
|
205
|
+
"""Validate and return the graph. Raises ValueError if cyclic."""
|
|
206
|
+
if not self.graph.is_acyclic():
|
|
207
|
+
raise ValueError("Operation graph has cycles - must be a DAG")
|
|
208
|
+
return self.graph
|
|
209
|
+
|
|
210
|
+
def clear(self) -> OperationGraphBuilder:
|
|
211
|
+
"""Reset builder to empty state. Returns self."""
|
|
212
|
+
self.graph = Graph()
|
|
213
|
+
self._nodes = {}
|
|
214
|
+
self._executed = set()
|
|
215
|
+
self._current_heads = []
|
|
216
|
+
return self
|
|
217
|
+
|
|
218
|
+
def __repr__(self) -> str:
|
|
219
|
+
return (
|
|
220
|
+
f"OperationGraphBuilder("
|
|
221
|
+
f"operations={len(self._nodes)}, "
|
|
222
|
+
f"edges={len(self.graph.edges)}, "
|
|
223
|
+
f"executed={len(self._executed)})"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# Alias for convenience
|
|
228
|
+
Builder = OperationGraphBuilder
|