jayrun 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.
Files changed (160) hide show
  1. jayrun/__init__.py +32 -0
  2. jayrun/context.py +11 -0
  3. jayrun/core/artifact/base.py +33 -0
  4. jayrun/core/artifact/context.py +159 -0
  5. jayrun/core/artifact/field.py +90 -0
  6. jayrun/core/artifact/properties.py +111 -0
  7. jayrun/core/base/error.py +2 -0
  8. jayrun/core/config/context.py +248 -0
  9. jayrun/core/config/field.py +46 -0
  10. jayrun/core/context/base.py +47 -0
  11. jayrun/core/context/runtime_data.py +27 -0
  12. jayrun/core/declaration/field.py +52 -0
  13. jayrun/core/graph/artifact_flow.py +111 -0
  14. jayrun/core/graph/compiled_graph.py +53 -0
  15. jayrun/core/graph/definition/__init__.py +14 -0
  16. jayrun/core/graph/definition/artifact.py +21 -0
  17. jayrun/core/graph/definition/data.py +9 -0
  18. jayrun/core/graph/definition/field.py +30 -0
  19. jayrun/core/graph/definition/requirement.py +16 -0
  20. jayrun/core/graph/graph_component.py +30 -0
  21. jayrun/core/graph/graph_definition.py +661 -0
  22. jayrun/core/graph/graph_layout.py +54 -0
  23. jayrun/core/graph/graph_specification.py +332 -0
  24. jayrun/core/graph/graph_state.py +7 -0
  25. jayrun/core/graph/inspection/artifact.py +58 -0
  26. jayrun/core/graph/inspection/field.py +47 -0
  27. jayrun/core/graph/inspection/graph.py +74 -0
  28. jayrun/core/graph/inspection/requirement.py +57 -0
  29. jayrun/core/graph/operator_reference.py +13 -0
  30. jayrun/core/graph/registry/__init__.py +13 -0
  31. jayrun/core/graph/registry/artifact.py +7 -0
  32. jayrun/core/graph/registry/config.py +7 -0
  33. jayrun/core/graph/registry/data.py +65 -0
  34. jayrun/core/graph/registry/field.py +20 -0
  35. jayrun/core/graph/registry/resource.py +7 -0
  36. jayrun/core/graph/requirements.py +129 -0
  37. jayrun/core/operator/base.py +276 -0
  38. jayrun/core/resource/base.py +168 -0
  39. jayrun/core/resource/context.py +43 -0
  40. jayrun/core/resource/field.py +26 -0
  41. jayrun/core/validation/__init__.py +37 -0
  42. jayrun/core/validation/artifact.py +171 -0
  43. jayrun/core/validation/graph.py +199 -0
  44. jayrun/core/validation/plotting.py +222 -0
  45. jayrun/core/validation/reporting.py +57 -0
  46. jayrun/core/validation/validator.py +317 -0
  47. jayrun/engine/api.py +215 -0
  48. jayrun/engine/artifact/__init__.py +11 -0
  49. jayrun/engine/artifact/actor.py +7 -0
  50. jayrun/engine/artifact/result.py +28 -0
  51. jayrun/engine/artifact/state.py +7 -0
  52. jayrun/engine/artifact/store.py +230 -0
  53. jayrun/engine/base/runtime_module.py +21 -0
  54. jayrun/engine/context/context_manager.py +196 -0
  55. jayrun/engine/context/context_outcome.py +16 -0
  56. jayrun/engine/context/execution_context.py +945 -0
  57. jayrun/engine/context/execution_proxy.py +60 -0
  58. jayrun/engine/context/execution_result.py +6 -0
  59. jayrun/engine/context/execution_session.py +190 -0
  60. jayrun/engine/context/execution_state.py +10 -0
  61. jayrun/engine/context/execution_step.py +18 -0
  62. jayrun/engine/context/execution_tracker.py +38 -0
  63. jayrun/engine/context/resource_key.py +11 -0
  64. jayrun/engine/context/state.py +11 -0
  65. jayrun/engine/context/status.py +107 -0
  66. jayrun/engine/context/step_reference.py +9 -0
  67. jayrun/engine/coordinator/__init__.py +3 -0
  68. jayrun/engine/coordinator/batch_size_estimator.py +39 -0
  69. jayrun/engine/coordinator/coordinator.py +175 -0
  70. jayrun/engine/coordinator/runtime_loop.py +433 -0
  71. jayrun/engine/coordinator/state.py +8 -0
  72. jayrun/engine/coordinator/timeout_estimator.py +52 -0
  73. jayrun/engine/engine_state.py +24 -0
  74. jayrun/engine/execution/async_executor.py +129 -0
  75. jayrun/engine/execution/execution_mode.py +8 -0
  76. jayrun/engine/execution/executor_manager.py +184 -0
  77. jayrun/engine/execution/thread_executor.py +129 -0
  78. jayrun/engine/gateway/engine_gateway.py +85 -0
  79. jayrun/engine/interfaces/base.py +58 -0
  80. jayrun/engine/interfaces/context.py +77 -0
  81. jayrun/engine/interfaces/execution.py +69 -0
  82. jayrun/engine/interfaces/placement.py +251 -0
  83. jayrun/engine/interfaces/runtime.py +109 -0
  84. jayrun/engine/interfaces/services/accesses.py +30 -0
  85. jayrun/engine/interfaces/services/context.py +45 -0
  86. jayrun/engine/interfaces/services/runtime.py +58 -0
  87. jayrun/engine/interfaces/services/storage.py +29 -0
  88. jayrun/engine/interfaces/value_record.py +26 -0
  89. jayrun/engine/messages/__init__.py +1 -0
  90. jayrun/engine/messages/commands/__init__.py +1 -0
  91. jayrun/engine/messages/commands/abort_context.py +14 -0
  92. jayrun/engine/messages/commands/pause_context.py +27 -0
  93. jayrun/engine/messages/commands/reconcile_contexts.py +10 -0
  94. jayrun/engine/messages/commands/resume_context.py +14 -0
  95. jayrun/engine/messages/commands/retrieve_session.py +20 -0
  96. jayrun/engine/messages/commands/shutdown_runtime.py +15 -0
  97. jayrun/engine/messages/commands/start_context.py +18 -0
  98. jayrun/engine/messages/commands/stop_context.py +14 -0
  99. jayrun/engine/messages/events/__init__.py +1 -0
  100. jayrun/engine/messages/events/context_admitted.py +20 -0
  101. jayrun/engine/messages/events/context_registered.py +22 -0
  102. jayrun/engine/messages/events/context_terminated.py +25 -0
  103. jayrun/engine/messages/events/runtime_idle.py +14 -0
  104. jayrun/engine/messages/runtime_message.py +33 -0
  105. jayrun/engine/messages/runtime_messenger.py +58 -0
  106. jayrun/engine/recorders/__init__.py +15 -0
  107. jayrun/engine/recorders/artifact/__init__.py +13 -0
  108. jayrun/engine/recorders/artifact/artifact_state.py +8 -0
  109. jayrun/engine/recorders/artifact/debug_recorder.py +6 -0
  110. jayrun/engine/recorders/artifact/production_recorder.py +6 -0
  111. jayrun/engine/recorders/artifact/record.py +14 -0
  112. jayrun/engine/recorders/artifact/recorder.py +147 -0
  113. jayrun/engine/recorders/artifact/state.py +7 -0
  114. jayrun/engine/recorders/context/__init__.py +11 -0
  115. jayrun/engine/recorders/context/debug_recorder.py +11 -0
  116. jayrun/engine/recorders/context/production_recorder.py +11 -0
  117. jayrun/engine/recorders/context/recorder.py +78 -0
  118. jayrun/engine/recorders/context/report.py +14 -0
  119. jayrun/engine/recorders/context/state.py +6 -0
  120. jayrun/engine/recorders/execution/__init__.py +29 -0
  121. jayrun/engine/recorders/execution/debug_recorder.py +11 -0
  122. jayrun/engine/recorders/execution/production_recorder.py +11 -0
  123. jayrun/engine/recorders/execution/recorder.py +242 -0
  124. jayrun/engine/recorders/execution/records.py +83 -0
  125. jayrun/engine/recorders/execution/state.py +7 -0
  126. jayrun/engine/registry/__init__.py +4 -0
  127. jayrun/engine/registry/context_id_generator.py +9 -0
  128. jayrun/engine/registry/context_instance.py +601 -0
  129. jayrun/engine/registry/context_snapshot.py +108 -0
  130. jayrun/engine/registry/context_state.py +52 -0
  131. jayrun/engine/registry/context_status.py +124 -0
  132. jayrun/engine/registry/identities.py +34 -0
  133. jayrun/engine/registry/runtime_registry.py +585 -0
  134. jayrun/engine/resource/__init__.py +17 -0
  135. jayrun/engine/resource/cached_resource.py +172 -0
  136. jayrun/engine/resource/device_allocator.py +131 -0
  137. jayrun/engine/resource/placement.py +236 -0
  138. jayrun/engine/resource/placement_controller.py +733 -0
  139. jayrun/engine/resource/placement_request.py +199 -0
  140. jayrun/engine/resource/resource_manager.py +345 -0
  141. jayrun/engine/resource/resource_state.py +8 -0
  142. jayrun/engine/runtime.py +133 -0
  143. jayrun/engine/scheduler/context.py +194 -0
  144. jayrun/engine/scheduler/memory.py +169 -0
  145. jayrun/engine/settings/__init__.py +15 -0
  146. jayrun/engine/settings/combined_context.py +148 -0
  147. jayrun/engine/settings/context.py +116 -0
  148. jayrun/engine/settings/engine.py +209 -0
  149. jayrun/engine/supervisor.py +633 -0
  150. jayrun/placement.py +19 -0
  151. jayrun/properties.py +19 -0
  152. jayrun/py.typed +1 -0
  153. jayrun/settings.py +20 -0
  154. jayrun/validation.py +5 -0
  155. jayrun-0.1.0.dist-info/METADATA +181 -0
  156. jayrun-0.1.0.dist-info/RECORD +160 -0
  157. jayrun-0.1.0.dist-info/WHEEL +5 -0
  158. jayrun-0.1.0.dist-info/licenses/LICENSE +201 -0
  159. jayrun-0.1.0.dist-info/licenses/NOTICE +4 -0
  160. jayrun-0.1.0.dist-info/top_level.txt +1 -0
jayrun/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """Public graph construction, execution, and data-context API."""
2
+
3
+ from .core.artifact.base import Artifact
4
+ from .core.artifact.context import ArtifactContext
5
+ from .core.artifact.field import ArtifactField
6
+ from .core.config.context import ConfigContext
7
+ from .core.config.field import ConfigField
8
+ from .core.context.runtime_data import Data
9
+ from .core.graph.artifact_flow import ArtifactFlow
10
+ from .core.graph.graph_definition import GraphDefinition
11
+ from .core.operator.base import BaseOperator
12
+ from .core.resource.base import BaseResource
13
+ from .core.resource.field import ResourceField
14
+ from .engine.api import Engine
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ __all__ = (
19
+ "__version__",
20
+ "Artifact",
21
+ "ArtifactContext",
22
+ "ArtifactField",
23
+ "ArtifactFlow",
24
+ "BaseOperator",
25
+ "BaseResource",
26
+ "ConfigContext",
27
+ "ConfigField",
28
+ "Data",
29
+ "Engine",
30
+ "GraphDefinition",
31
+ "ResourceField",
32
+ )
jayrun/context.py ADDED
@@ -0,0 +1,11 @@
1
+ """Public execution-context states, snapshots, and retained artifact results."""
2
+
3
+ from .engine.artifact.result import ArtifactResult
4
+ from .engine.registry.context_snapshot import ContextSnapshot
5
+ from .engine.registry.context_state import ContextState
6
+
7
+ __all__ = (
8
+ "ArtifactResult",
9
+ "ContextSnapshot",
10
+ "ContextState",
11
+ )
@@ -0,0 +1,33 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True, slots=True, kw_only=True, eq=False)
5
+ class Artifact:
6
+ """Declare a named value that moves through a graph.
7
+
8
+ An artifact describes identity and intent; its runtime value is supplied through
9
+ an :class:`~jayrun.ArtifactContext` or produced by an operator. Declarations use
10
+ identity semantics, so two artifacts with the same name remain distinct.
11
+
12
+ Args:
13
+ name: Optional human-readable name used in reports and graph plots.
14
+ description: Optional explanation of the value represented by the artifact.
15
+ """
16
+
17
+ name: str | None = None
18
+ description: str | None = None
19
+
20
+ def __post_init__(self) -> None:
21
+ if self.name is not None and not isinstance(self.name, str):
22
+ raise TypeError(
23
+ f"{type(self).__name__} 'name' must be str or None, got {type(self.name).__name__!r}"
24
+ )
25
+
26
+ if self.description is not None and not isinstance(self.description, str):
27
+ raise TypeError(
28
+ f"{type(self).__name__} 'description' must be str or None, got {type(self.description).__name__!r}"
29
+ )
30
+
31
+ def __repr__(self) -> str:
32
+ name = self.name if self.name is not None else "<unnamed>"
33
+ return f"{type(self).__name__}(name={name!r})"
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+
5
+ from ..context.base import DataContext
6
+ from ..context.runtime_data import Data
7
+ from ..graph.definition.artifact import ArtifactDefinition, ArtifactRole
8
+ from ..graph.graph_definition import GraphDefinition
9
+ from .base import Artifact
10
+
11
+
12
+ class ArtifactContext(DataContext[Artifact, Data]):
13
+ """Hold the entry-artifact values for one graph submission.
14
+
15
+ The graph must be confirmed before the context is created. Values are wrapped in
16
+ :class:`~jayrun.Data`; callers may address artifacts by object, inspected
17
+ definition, or graph-local integer ID.
18
+
19
+ Args:
20
+ graph: Confirmed graph whose artifacts the context accepts.
21
+ name: Optional name for diagnostics.
22
+ description: Optional description for diagnostics.
23
+
24
+ Raises:
25
+ TypeError: If ``graph`` is not a :class:`~jayrun.GraphDefinition`.
26
+ RuntimeError: If the graph has not been confirmed.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ graph: GraphDefinition,
33
+ name: str | None = None,
34
+ description: str | None = None,
35
+ ) -> None:
36
+ super().__init__(name=name, description=description)
37
+
38
+ if not isinstance(graph, GraphDefinition):
39
+ raise TypeError("graph must be a GraphDefinition instance")
40
+ if not graph.confirmed:
41
+ raise RuntimeError("The graph must be confirmed.")
42
+
43
+ self._graph = graph
44
+ self._registry = graph._specification.artifacts
45
+ self._definitions_by_id = {
46
+ definition.artifact_id: definition
47
+ for definition in self._registry.definitions
48
+ }
49
+ self._release_target: ArtifactContext | None = None
50
+
51
+ def set(
52
+ self,
53
+ artifacts: Mapping[int | Artifact | ArtifactDefinition, object],
54
+ ) -> None:
55
+ """Set or replace artifact values.
56
+
57
+ Args:
58
+ artifacts: Mapping from artifact IDs, artifacts, or inspected artifact
59
+ definitions to raw values.
60
+
61
+ Raises:
62
+ KeyError: If a key does not belong to this graph.
63
+ TypeError: If ``artifacts`` is not a mapping or a key is unsupported.
64
+ ValueError: If multiple keys resolve to the same artifact.
65
+ """
66
+ if not isinstance(artifacts, Mapping):
67
+ raise TypeError(
68
+ "Expected a mapping of artifact IDs, Artifact, or "
69
+ "ArtifactDefinition to values."
70
+ )
71
+
72
+ instances: dict[Artifact, Data] = {}
73
+
74
+ for key, value in artifacts.items():
75
+ artifact = self._resolve_artifact(key)
76
+
77
+ if artifact in instances:
78
+ raise ValueError("Multiple artifact keys resolve to the same Artifact.")
79
+
80
+ instances[artifact] = Data(value=value)
81
+
82
+ self._update_instances(instances)
83
+
84
+ def get(
85
+ self,
86
+ artifact: int | Artifact | ArtifactDefinition,
87
+ ) -> Data | None:
88
+ """Return an artifact's wrapped value, or ``None`` if it is unset."""
89
+ return self._instances.get(self._resolve_artifact(artifact))
90
+
91
+ def clear(self) -> None:
92
+ """Remove every value from this context."""
93
+ self._instances.clear()
94
+
95
+ def clear_entries(self) -> None:
96
+ """Remove graph-entry values from this context and its release target."""
97
+ self._clear_entries()
98
+ if self._release_target is not None:
99
+ self._release_target._clear_entries()
100
+
101
+ def _clear_entries(self) -> None:
102
+ for definition in self._registry.definitions:
103
+ if definition.role is ArtifactRole.ENTRY:
104
+ self._instances.pop(
105
+ self._registry.source_for(definition),
106
+ None,
107
+ )
108
+
109
+ def _fork(self) -> ArtifactContext:
110
+ context = ArtifactContext(
111
+ graph=self.graph,
112
+ name=self.name,
113
+ description=self.description,
114
+ )
115
+ context._instances = dict(self._instances)
116
+ context._release_target = self
117
+ return context
118
+
119
+ def validate(self) -> bool:
120
+ """Return whether every graph entry artifact has a value."""
121
+ return all(
122
+ self._registry.source_for(definition) in self._instances
123
+ for definition in self._registry.definitions
124
+ if definition.role is ArtifactRole.ENTRY
125
+ )
126
+
127
+ def _resolve_artifact(
128
+ self,
129
+ artifact: int | Artifact | ArtifactDefinition,
130
+ ) -> Artifact:
131
+ if type(artifact) is int:
132
+ try:
133
+ definition = self._definitions_by_id[artifact]
134
+ except KeyError:
135
+ raise KeyError(f"Unknown artifact ID: {artifact!r}.") from None
136
+
137
+ return self._registry.source_for(definition)
138
+
139
+ if isinstance(artifact, ArtifactDefinition):
140
+ if artifact not in self._registry.definitions:
141
+ raise KeyError("The ArtifactDefinition does not belong to this graph.")
142
+
143
+ return self._registry.source_for(artifact)
144
+
145
+ if isinstance(artifact, Artifact):
146
+ if artifact not in self._registry.sources:
147
+ raise KeyError("The Artifact does not belong to this graph.")
148
+
149
+ return artifact
150
+
151
+ raise TypeError(
152
+ "Expected int, Artifact, or ArtifactDefinition, "
153
+ f"got {type(artifact).__name__!r}."
154
+ )
155
+
156
+ @property
157
+ def graph(self) -> GraphDefinition:
158
+ """The confirmed graph associated with this context."""
159
+ return self._graph
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from ..declaration.field import DeclarativeField
6
+ from .base import Artifact
7
+ from .properties import ArtifactProperty
8
+
9
+
10
+ @dataclass(slots=True, frozen=True, kw_only=True, eq=False)
11
+ class ArtifactField(DeclarativeField):
12
+ """Declare an operator input or output artifact contract.
13
+
14
+ Args:
15
+ name: Optional display name.
16
+ description: Optional explanation of the artifact contract.
17
+ required: Whether the field must be connected. Input fields enforce this
18
+ during operator construction; output fields may still be bound to
19
+ ``None`` to disable a route.
20
+ properties: Optional tuple of artifact properties used by graph validation.
21
+ """
22
+
23
+ properties: tuple[ArtifactProperty, ...] | None = None
24
+ artifact: Artifact | None = field(init=False, default=None, repr=False)
25
+ is_bound: bool = field(init=False, default=False, repr=False)
26
+
27
+ def __post_init__(self) -> None:
28
+ DeclarativeField.__post_init__(self)
29
+
30
+ if self.properties is None:
31
+ object.__setattr__(self, "properties", ())
32
+ return
33
+
34
+ if not isinstance(self.properties, tuple):
35
+ raise TypeError(
36
+ f"'properties' must be a tuple of ArtifactProperty instances or None, "
37
+ f"got {type(self.properties).__name__!r}"
38
+ )
39
+
40
+ properties_by_type = list()
41
+
42
+ for artifact_property in self.properties:
43
+ if not isinstance(artifact_property, ArtifactProperty):
44
+ raise TypeError(
45
+ f"'properties' must contain only ArtifactProperty instances, "
46
+ f"got {type(artifact_property).__name__!r}"
47
+ )
48
+ property_type = type(artifact_property)
49
+
50
+ if property_type in properties_by_type:
51
+ raise ValueError(
52
+ f"Duplicate artifact property: {property_type.__name__}."
53
+ )
54
+ properties_by_type.append(property_type)
55
+
56
+ def bind(self, artifact: Artifact | None) -> None:
57
+ """Bind this input field to an artifact exactly once.
58
+
59
+ Args:
60
+ artifact: Artifact to consume, or ``None`` for an optional field.
61
+
62
+ Raises:
63
+ RuntimeError: If the field is already bound.
64
+ TypeError: If ``artifact`` has an unsupported type.
65
+ ValueError: If a required field is bound to ``None``.
66
+ """
67
+ self._bind(artifact, enforce_required=True)
68
+
69
+ def _bind(
70
+ self,
71
+ artifact: Artifact | None,
72
+ *,
73
+ enforce_required: bool,
74
+ ) -> None:
75
+ if self.is_bound:
76
+ raise RuntimeError(f"{type(self).__name__} is already bound")
77
+
78
+ if artifact is not None and not isinstance(artifact, Artifact):
79
+ raise TypeError(
80
+ f"'artifact' must be an Artifact or None, "
81
+ f"got {type(artifact).__name__!r}"
82
+ )
83
+
84
+ if enforce_required and artifact is None and self.required:
85
+ raise ValueError(
86
+ f"Required artifact field {self.display_name!r} cannot be bound to None"
87
+ )
88
+
89
+ object.__setattr__(self, "artifact", artifact)
90
+ object.__setattr__(self, "is_bound", True)
@@ -0,0 +1,111 @@
1
+ from abc import ABC, abstractmethod
2
+ from collections.abc import Iterable
3
+ from typing import Generic, Self, TypeVar
4
+
5
+ T = TypeVar("T")
6
+
7
+
8
+ class ArtifactProperty(ABC, Generic[T]):
9
+ """Base class for one statically validated artifact characteristic.
10
+
11
+ Args:
12
+ value: Declared property value.
13
+ """
14
+
15
+ def __init__(self, value: T) -> None:
16
+ self._validate_value(value)
17
+ self._value = value
18
+
19
+ @property
20
+ def value(self) -> T:
21
+ """Normalized declared value."""
22
+ return self._value
23
+
24
+ def _validate_value(self, value: T) -> None:
25
+ pass
26
+
27
+ @abstractmethod
28
+ def accepts(self, output: Self) -> bool:
29
+ """Return whether a producer property satisfies this consumer property.
30
+
31
+ Args:
32
+ output: Property declared by the producing output field.
33
+ """
34
+ pass
35
+
36
+
37
+ class TypeProperty(ArtifactProperty[type]):
38
+ """Require an exact Python value type."""
39
+
40
+ def _validate_value(self, value: type) -> None:
41
+ if not isinstance(value, type):
42
+ raise TypeError("Expected a Python type.")
43
+
44
+ def accepts(self, output: Self) -> bool:
45
+ return self.value is output.value
46
+
47
+
48
+ class DTypeProperty(ArtifactProperty[tuple[object, ...]]):
49
+ """Require at least one shared dtype from a set of accepted values.
50
+
51
+ Args:
52
+ value: One dtype or an iterable of acceptable dtypes.
53
+ """
54
+
55
+ def __init__(self, value: object | Iterable[object]) -> None:
56
+ if isinstance(value, Iterable) and not isinstance(value, (str, bytes)):
57
+ values = tuple(value)
58
+ else:
59
+ values = (value,)
60
+
61
+ super().__init__(values)
62
+
63
+ def _validate_value(self, value: tuple[object, ...]) -> None:
64
+ if len(value) == 0:
65
+ raise ValueError("Expected at least one dtype.")
66
+
67
+ def accepts(self, output: Self) -> bool:
68
+ return not set(self.value).isdisjoint(output.value)
69
+
70
+
71
+ class ShapeProperty(ArtifactProperty[tuple[int | None, ...]]):
72
+ """Require a tensor-like shape, using ``None`` as a consumer wildcard."""
73
+
74
+ def _validate_value(self, value: tuple[int | None, ...]) -> None:
75
+ for dimension in value:
76
+ if dimension is not None and not isinstance(dimension, int):
77
+ raise TypeError("Shape dimensions must be integers or None.")
78
+
79
+ def accepts(self, output: Self) -> bool:
80
+ if len(self.value) != len(output.value):
81
+ return False
82
+
83
+ for input_artifact, output_artifact in zip(self.value, output.value):
84
+ if input_artifact is None and output_artifact is not None:
85
+ continue
86
+ if input_artifact != output_artifact:
87
+ return False
88
+
89
+ return True
90
+
91
+
92
+ class DeviceProperty(ArtifactProperty[str]):
93
+ """Require an exact device name such as ``\"cpu\"`` or ``\"cuda\"``."""
94
+
95
+ def _validate_value(self, value: str) -> None:
96
+ if not isinstance(value, str):
97
+ raise TypeError("Expected a device name.")
98
+
99
+ def accepts(self, output: Self) -> bool:
100
+ return self.value == output.value
101
+
102
+
103
+ class BackendProperty(ArtifactProperty[str]):
104
+ """Require an exact data backend name such as ``\"torch\"``."""
105
+
106
+ def _validate_value(self, value: str) -> None:
107
+ if not isinstance(value, str):
108
+ raise TypeError("Expected a backend name.")
109
+
110
+ def accepts(self, output: Self) -> bool:
111
+ return self.value == output.value
@@ -0,0 +1,2 @@
1
+ class ConfigError(Exception):
2
+ pass
@@ -0,0 +1,248 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+
5
+ from ..context.base import DataContext
6
+ from ..context.runtime_data import Data
7
+ from ..graph.definition.field import ConfigDefinition
8
+ from ..graph.graph_definition import GraphDefinition
9
+ from .field import ConfigField
10
+
11
+
12
+ class ConfigContext(DataContext[ConfigField, Data]):
13
+ """Hold configuration values for one graph submission.
14
+
15
+ Args:
16
+ graph: Confirmed graph whose configuration fields the context accepts.
17
+ name: Optional name for diagnostics.
18
+ description: Optional description for diagnostics.
19
+
20
+ Raises:
21
+ TypeError: If ``graph`` is not a :class:`~jayrun.GraphDefinition`.
22
+ RuntimeError: If the graph has not been confirmed.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ *,
28
+ graph: GraphDefinition,
29
+ name: str | None = None,
30
+ description: str | None = None,
31
+ ) -> None:
32
+ super().__init__(name=name, description=description)
33
+
34
+ if not isinstance(graph, GraphDefinition):
35
+ raise TypeError("graph must be a GraphDefinition instance")
36
+ if not graph.confirmed:
37
+ raise RuntimeError("The graph must be confirmed.")
38
+ self._graph = graph
39
+ self._registry = graph._specification.configs
40
+ self._definitions_by_id = {
41
+ definition.config_id: definition
42
+ for definition in self._registry.definitions
43
+ }
44
+
45
+ def set(
46
+ self,
47
+ configs: Mapping[int | ConfigField | ConfigDefinition, object],
48
+ ) -> None:
49
+ """Set or replace configuration values.
50
+
51
+ Args:
52
+ configs: Mapping from config IDs, fields, or inspected definitions to
53
+ values.
54
+
55
+ Raises:
56
+ KeyError: If a key does not belong to this graph.
57
+ TypeError: If a value does not match its field type.
58
+ ValueError: If a required value is ``None`` or keys are ambiguous.
59
+ """
60
+ if not isinstance(configs, Mapping):
61
+ raise TypeError(
62
+ "Expected a mapping of config IDs, ConfigField, or "
63
+ "ConfigDefinition to values."
64
+ )
65
+
66
+ instances: dict[ConfigField, Data] = {}
67
+
68
+ for key, value in configs.items():
69
+ field = self._resolve_field(key)
70
+
71
+ if field in instances:
72
+ raise ValueError(
73
+ "Multiple config keys resolve to the same ConfigField."
74
+ )
75
+
76
+ self._validate_value(field, value)
77
+ instances[field] = Data(value=value)
78
+
79
+ self._update_instances(instances)
80
+
81
+ def get(
82
+ self,
83
+ config: int | ConfigField | ConfigDefinition,
84
+ ) -> Data | None:
85
+ """Return a configured or default value wrapped in :class:`~jayrun.Data`."""
86
+ field = self._resolve_field(config)
87
+
88
+ if field in self._instances:
89
+ return self._instances[field]
90
+
91
+ if field.default is None:
92
+ return None
93
+
94
+ return Data(value=field.default)
95
+
96
+ def validate(self) -> bool:
97
+ """Return whether every required configuration field has a value."""
98
+ return all(
99
+ self.get(definition) is not None
100
+ for definition in self._registry.definitions
101
+ if definition.required
102
+ )
103
+
104
+ def _fork(self) -> ConfigContext:
105
+ context = ConfigContext(
106
+ graph=self.graph,
107
+ name=self.name,
108
+ description=self.description,
109
+ )
110
+ context._instances = dict(self._instances)
111
+ return context
112
+
113
+ def to_yaml(self) -> str:
114
+ """Serialize graph configuration metadata and current values as YAML."""
115
+ import yaml
116
+
117
+ configs = {
118
+ definition.config_id: self._yaml_entry(definition)
119
+ for definition in sorted(
120
+ self._registry.definitions,
121
+ key=lambda definition: definition.config_id,
122
+ )
123
+ }
124
+
125
+ return yaml.safe_dump(
126
+ {"configs": configs},
127
+ sort_keys=False,
128
+ allow_unicode=True,
129
+ )
130
+
131
+ def load_yaml(self, content: str) -> None:
132
+ """Load values from YAML produced by :meth:`to_yaml`.
133
+
134
+ Existing values not present in ``content`` are preserved.
135
+
136
+ Args:
137
+ content: YAML document containing a top-level ``configs`` mapping.
138
+ """
139
+ import yaml
140
+
141
+ if not isinstance(content, str):
142
+ raise TypeError("content must be str")
143
+
144
+ document = yaml.safe_load(content)
145
+
146
+ if document is None:
147
+ return
148
+
149
+ if not isinstance(document, Mapping):
150
+ raise TypeError("YAML root must be a mapping")
151
+
152
+ configs = document.get("configs")
153
+
154
+ if not isinstance(configs, Mapping):
155
+ raise TypeError("'configs' must be a mapping")
156
+
157
+ values: dict[int, object] = {}
158
+
159
+ for config_id, config in configs.items():
160
+ if type(config_id) is not int:
161
+ raise TypeError("Config IDs in YAML must be integers")
162
+
163
+ if not isinstance(config, Mapping):
164
+ raise TypeError(f"Config {config_id!r} must be a mapping")
165
+
166
+ if "value" not in config:
167
+ raise ValueError(f"Config {config_id!r} is missing 'value'")
168
+
169
+ values[config_id] = config["value"]
170
+
171
+ self.set(values)
172
+
173
+ def _yaml_entry(
174
+ self,
175
+ definition: ConfigDefinition,
176
+ ) -> dict[str, object]:
177
+ field = self._registry.source_for(definition)
178
+ instance = self._instances.get(field)
179
+
180
+ return {
181
+ "name": definition.name,
182
+ "description": definition.description,
183
+ "owner": definition.owner,
184
+ "required": definition.required,
185
+ "layout_position": list(definition.layout_position),
186
+ "attribute_name": definition.attribute_name,
187
+ "value_type": self._type_name(definition.value_type),
188
+ "default": definition.default,
189
+ "value": instance.value if instance is not None else definition.default,
190
+ }
191
+
192
+ def _resolve_field(
193
+ self,
194
+ config: int | ConfigField | ConfigDefinition,
195
+ ) -> ConfigField:
196
+ if type(config) is int:
197
+ try:
198
+ definition = self._definitions_by_id[config]
199
+ except KeyError:
200
+ raise KeyError(f"Unknown config ID: {config!r}.") from None
201
+
202
+ return self._registry.source_for(definition)
203
+
204
+ if isinstance(config, ConfigDefinition):
205
+ if config not in self._registry.definitions:
206
+ raise KeyError("The ConfigDefinition does not belong to this graph.")
207
+
208
+ return self._registry.source_for(config)
209
+
210
+ if isinstance(config, ConfigField):
211
+ if config not in self._registry.sources:
212
+ raise KeyError("The ConfigField does not belong to this graph.")
213
+
214
+ return config
215
+
216
+ raise TypeError(
217
+ "Expected int, ConfigField, or ConfigDefinition, "
218
+ f"got {type(config).__name__!r}."
219
+ )
220
+
221
+ @staticmethod
222
+ def _validate_value(
223
+ field: ConfigField,
224
+ value: object,
225
+ ) -> None:
226
+ if value is None:
227
+ if field.required:
228
+ raise ValueError("A required config cannot be None.")
229
+ return
230
+
231
+ if not isinstance(value, field.value_type):
232
+ raise TypeError(
233
+ f"Expected {field.value_type.__name__!r}, got {type(value).__name__!r}."
234
+ )
235
+
236
+ hash(value)
237
+
238
+ @staticmethod
239
+ def _type_name(value_type: type) -> str:
240
+ if value_type.__module__ == "builtins":
241
+ return value_type.__qualname__
242
+
243
+ return f"{value_type.__module__}.{value_type.__qualname__}"
244
+
245
+ @property
246
+ def graph(self) -> GraphDefinition:
247
+ """The confirmed graph associated with this context."""
248
+ return self._graph