lsst-pipe-base 29.2025.3900__py3-none-any.whl → 29.2025.4100__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 (40) hide show
  1. lsst/pipe/base/_task_metadata.py +15 -0
  2. lsst/pipe/base/dot_tools.py +14 -152
  3. lsst/pipe/base/exec_fixup_data_id.py +17 -44
  4. lsst/pipe/base/execution_graph_fixup.py +49 -18
  5. lsst/pipe/base/graph/_versionDeserializers.py +6 -5
  6. lsst/pipe/base/graph/graph.py +30 -10
  7. lsst/pipe/base/graph/graphSummary.py +30 -0
  8. lsst/pipe/base/graph_walker.py +119 -0
  9. lsst/pipe/base/log_capture.py +5 -2
  10. lsst/pipe/base/mermaid_tools.py +11 -64
  11. lsst/pipe/base/mp_graph_executor.py +298 -236
  12. lsst/pipe/base/pipeline_graph/io.py +1 -1
  13. lsst/pipe/base/quantum_graph/__init__.py +32 -0
  14. lsst/pipe/base/quantum_graph/_common.py +632 -0
  15. lsst/pipe/base/quantum_graph/_multiblock.py +808 -0
  16. lsst/pipe/base/quantum_graph/_predicted.py +1950 -0
  17. lsst/pipe/base/quantum_graph/visualization.py +302 -0
  18. lsst/pipe/base/quantum_graph_builder.py +292 -34
  19. lsst/pipe/base/quantum_graph_executor.py +2 -1
  20. lsst/pipe/base/quantum_provenance_graph.py +16 -7
  21. lsst/pipe/base/quantum_reports.py +45 -0
  22. lsst/pipe/base/separable_pipeline_executor.py +126 -15
  23. lsst/pipe/base/simple_pipeline_executor.py +44 -43
  24. lsst/pipe/base/single_quantum_executor.py +1 -40
  25. lsst/pipe/base/tests/mocks/__init__.py +1 -1
  26. lsst/pipe/base/tests/mocks/_pipeline_task.py +16 -1
  27. lsst/pipe/base/tests/mocks/{_in_memory_repo.py → _repo.py} +324 -45
  28. lsst/pipe/base/tests/mocks/_storage_class.py +51 -0
  29. lsst/pipe/base/tests/simpleQGraph.py +11 -5
  30. lsst/pipe/base/version.py +1 -1
  31. {lsst_pipe_base-29.2025.3900.dist-info → lsst_pipe_base-29.2025.4100.dist-info}/METADATA +2 -1
  32. {lsst_pipe_base-29.2025.3900.dist-info → lsst_pipe_base-29.2025.4100.dist-info}/RECORD +40 -34
  33. {lsst_pipe_base-29.2025.3900.dist-info → lsst_pipe_base-29.2025.4100.dist-info}/WHEEL +0 -0
  34. {lsst_pipe_base-29.2025.3900.dist-info → lsst_pipe_base-29.2025.4100.dist-info}/entry_points.txt +0 -0
  35. {lsst_pipe_base-29.2025.3900.dist-info → lsst_pipe_base-29.2025.4100.dist-info}/licenses/COPYRIGHT +0 -0
  36. {lsst_pipe_base-29.2025.3900.dist-info → lsst_pipe_base-29.2025.4100.dist-info}/licenses/LICENSE +0 -0
  37. {lsst_pipe_base-29.2025.3900.dist-info → lsst_pipe_base-29.2025.4100.dist-info}/licenses/bsd_license.txt +0 -0
  38. {lsst_pipe_base-29.2025.3900.dist-info → lsst_pipe_base-29.2025.4100.dist-info}/licenses/gpl-v3.0.txt +0 -0
  39. {lsst_pipe_base-29.2025.3900.dist-info → lsst_pipe_base-29.2025.4100.dist-info}/top_level.txt +0 -0
  40. {lsst_pipe_base-29.2025.3900.dist-info → lsst_pipe_base-29.2025.4100.dist-info}/zip-safe +0 -0
@@ -686,6 +686,21 @@ class TaskMetadata(BaseModel):
686
686
  """See `pydantic.BaseModel.model_json_schema`."""
687
687
  return super().model_json_schema(*args, **kwargs)
688
688
 
689
+ @classmethod
690
+ def model_validate(cls, *args: Any, **kwargs: Any) -> Any:
691
+ """See `pydantic.BaseModel.model_validate`."""
692
+ return super().model_validate(*args, **kwargs)
693
+
694
+ @classmethod
695
+ def model_validate_json(cls, *args: Any, **kwargs: Any) -> Any:
696
+ """See `pydantic.BaseModel.model_validate_json`."""
697
+ return super().model_validate_json(*args, **kwargs)
698
+
699
+ @classmethod
700
+ def model_validate_strings(cls, *args: Any, **kwargs: Any) -> Any:
701
+ """See `pydantic.BaseModel.model_validate_strings`."""
702
+ return super().model_validate_strings(*args, **kwargs)
703
+
689
704
 
690
705
  # Needed because a TaskMetadata can contain a TaskMetadata.
691
706
  TaskMetadata.model_rebuild()
@@ -33,147 +33,27 @@ from __future__ import annotations
33
33
 
34
34
  __all__ = ["graph2dot", "pipeline2dot"]
35
35
 
36
- # -------------------------------
37
- # Imports of standard modules --
38
- # -------------------------------
39
- import html
40
- import io
41
36
  from collections.abc import Iterable
42
37
  from typing import TYPE_CHECKING, Any
43
38
 
44
- # -----------------------------
45
- # Imports for other modules --
46
- # -----------------------------
47
39
  from .pipeline import Pipeline
48
40
 
49
41
  if TYPE_CHECKING:
50
- from lsst.daf.butler import DatasetRef
51
- from lsst.pipe.base import QuantumGraph, QuantumNode, TaskDef
42
+ from .graph import QuantumGraph
43
+ from .pipeline import TaskDef
44
+ from .quantum_graph import PredictedQuantumGraph
52
45
 
53
- # ----------------------------------
54
- # Local non-exported definitions --
55
- # ----------------------------------
56
46
 
57
- # Attributes applied to directed graph objects.
58
- _NODELABELPOINTSIZE = "18"
59
- _ATTRIBS = dict(
60
- defaultGraph=dict(splines="ortho", nodesep="0.5", ranksep="0.75", pad="0.5"),
61
- defaultNode=dict(shape="box", fontname="Monospace", fontsize="14", margin="0.2,0.1", penwidth="3"),
62
- defaultEdge=dict(color="black", arrowsize="1.5", penwidth="1.5"),
63
- task=dict(style="filled", color="black", fillcolor="#B1F2EF"),
64
- quantum=dict(style="filled", color="black", fillcolor="#B1F2EF"),
65
- dsType=dict(style="rounded,filled,bold", color="#00BABC", fillcolor="#F5F5F5"),
66
- dataset=dict(style="rounded,filled,bold", color="#00BABC", fillcolor="#F5F5F5"),
67
- )
68
-
69
-
70
- def _renderDefault(type: str, attribs: dict[str, str], file: io.TextIOBase) -> None:
71
- """Set default attributes for a given type."""
72
- default_attribs = ", ".join([f'{key}="{val}"' for key, val in attribs.items()])
73
- print(f"{type} [{default_attribs}];", file=file)
74
-
75
-
76
- def _renderNode(file: io.TextIOBase, nodeName: str, style: str, labels: list[str]) -> None:
77
- """Render GV node"""
78
- label = r"</TD></TR><TR><TD>".join(labels)
79
- attrib_dict = dict(_ATTRIBS[style], label=label)
80
- pre = '<<TABLE BORDER="0" CELLPADDING="5"><TR><TD>'
81
- post = "</TD></TR></TABLE>>"
82
- attrib = ", ".join(
83
- [
84
- f'{key}="{val}"' if key != "label" else f"{key}={pre}{val}{post}"
85
- for key, val in attrib_dict.items()
86
- ]
87
- )
88
- print(f'"{nodeName}" [{attrib}];', file=file)
89
-
90
-
91
- def _renderTaskNode(nodeName: str, taskDef: TaskDef, file: io.TextIOBase, idx: Any = None) -> None:
92
- """Render GV node for a task"""
93
- labels = [
94
- f'<B><FONT POINT-SIZE="{_NODELABELPOINTSIZE}">' + html.escape(taskDef.label) + "</FONT></B>",
95
- html.escape(taskDef.taskName),
96
- ]
97
- if idx is not None:
98
- labels.append(f"<I>index:</I>&nbsp;{idx}")
99
- if taskDef.connections:
100
- # don't print collection of str directly to avoid visually noisy quotes
101
- dimensions_str = ", ".join(sorted(taskDef.connections.dimensions))
102
- labels.append(f"<I>dimensions:</I>&nbsp;{html.escape(dimensions_str)}")
103
- _renderNode(file, nodeName, "task", labels)
104
-
105
-
106
- def _renderQuantumNode(
107
- nodeName: str, taskDef: TaskDef, quantumNode: QuantumNode, file: io.TextIOBase
108
- ) -> None:
109
- """Render GV node for a quantum"""
110
- labels = [f"{quantumNode.nodeId}", html.escape(taskDef.label)]
111
- dataId = quantumNode.quantum.dataId
112
- assert dataId is not None, "Quantum DataId cannot be None"
113
- labels.extend(f"{key} = {dataId[key]}" for key in sorted(dataId.required.keys()))
114
- _renderNode(file, nodeName, "quantum", labels)
115
-
116
-
117
- def _renderDSTypeNode(name: str, dimensions: list[str], file: io.TextIOBase) -> None:
118
- """Render GV node for a dataset type"""
119
- labels = [f'<B><FONT POINT-SIZE="{_NODELABELPOINTSIZE}">' + html.escape(name) + "</FONT></B>"]
120
- if dimensions:
121
- labels.append("<I>dimensions:</I>&nbsp;" + html.escape(", ".join(sorted(dimensions))))
122
- _renderNode(file, name, "dsType", labels)
123
-
124
-
125
- def _renderDSNode(nodeName: str, dsRef: DatasetRef, file: io.TextIOBase) -> None:
126
- """Render GV node for a dataset"""
127
- labels = [html.escape(dsRef.datasetType.name), f"run: {dsRef.run!r}"]
128
- labels.extend(f"{key} = {dsRef.dataId[key]}" for key in sorted(dsRef.dataId.required.keys()))
129
- _renderNode(file, nodeName, "dataset", labels)
130
-
131
-
132
- def _renderEdge(fromName: str, toName: str, file: io.TextIOBase, **kwargs: Any) -> None:
133
- """Render GV edge"""
134
- if kwargs:
135
- attrib = ", ".join([f'{key}="{val}"' for key, val in kwargs.items()])
136
- print(f'"{fromName}" -> "{toName}" [{attrib}];', file=file)
137
- else:
138
- print(f'"{fromName}" -> "{toName}";', file=file)
139
-
140
-
141
- def _datasetRefId(dsRef: DatasetRef) -> str:
142
- """Make an identifying string for given ref"""
143
- dsId = [dsRef.datasetType.name]
144
- dsId.extend(f"{key} = {dsRef.dataId[key]}" for key in sorted(dsRef.dataId.required.keys()))
145
- return ":".join(dsId)
146
-
147
-
148
- def _makeDSNode(dsRef: DatasetRef, allDatasetRefs: dict[str, str], file: io.TextIOBase) -> str:
149
- """Make new node for dataset if it does not exist.
150
-
151
- Returns node name.
152
- """
153
- dsRefId = _datasetRefId(dsRef)
154
- nodeName = allDatasetRefs.get(dsRefId)
155
- if nodeName is None:
156
- idx = len(allDatasetRefs)
157
- nodeName = f"dsref_{idx}"
158
- allDatasetRefs[dsRefId] = nodeName
159
- _renderDSNode(nodeName, dsRef, file)
160
- return nodeName
161
-
162
-
163
- # ------------------------
164
- # Exported definitions --
165
- # ------------------------
166
-
167
-
168
- def graph2dot(qgraph: QuantumGraph, file: Any) -> None:
47
+ def graph2dot(qgraph: QuantumGraph | PredictedQuantumGraph, file: Any) -> None:
169
48
  """Convert QuantumGraph into GraphViz digraph.
170
49
 
171
50
  This method is mostly for documentation/presentation purposes.
172
51
 
173
52
  Parameters
174
53
  ----------
175
- qgraph : `lsst.pipe.base.QuantumGraph`
176
- QuantumGraph instance.
54
+ qgraph : `lsst.pipe.base.QuantumGraph` or \
55
+ `lsst.pipe.base.quantum_graph.PredictedQuantumGraph`
56
+ Quantum graph object.
177
57
  file : `str` or file object
178
58
  File where GraphViz graph (DOT language) is written, can be a file name
179
59
  or file object.
@@ -185,38 +65,20 @@ def graph2dot(qgraph: QuantumGraph, file: Any) -> None:
185
65
  ImportError
186
66
  Raised if the task class cannot be imported.
187
67
  """
68
+ from .quantum_graph import PredictedQuantumGraph, visualization
69
+
70
+ if not isinstance(qgraph, PredictedQuantumGraph):
71
+ qgraph = PredictedQuantumGraph.from_old_quantum_graph(qgraph)
72
+
188
73
  # open a file if needed
189
74
  close = False
190
75
  if not hasattr(file, "write"):
191
76
  file = open(file, "w")
192
77
  close = True
193
78
 
194
- print("digraph QuantumGraph {", file=file)
195
- _renderDefault("graph", _ATTRIBS["defaultGraph"], file)
196
- _renderDefault("node", _ATTRIBS["defaultNode"], file)
197
- _renderDefault("edge", _ATTRIBS["defaultEdge"], file)
198
-
199
- allDatasetRefs: dict[str, str] = {}
200
- for taskId, taskDef in enumerate(qgraph.taskGraph):
201
- quanta = qgraph.getNodesForTask(taskDef)
202
- for qId, quantumNode in enumerate(quanta):
203
- # node for a task
204
- taskNodeName = f"task_{taskId}_{qId}"
205
- _renderQuantumNode(taskNodeName, taskDef, quantumNode, file)
206
-
207
- # quantum inputs
208
- for dsRefs in quantumNode.quantum.inputs.values():
209
- for dsRef in dsRefs:
210
- nodeName = _makeDSNode(dsRef, allDatasetRefs, file)
211
- _renderEdge(nodeName, taskNodeName, file)
212
-
213
- # quantum outputs
214
- for dsRefs in quantumNode.quantum.outputs.values():
215
- for dsRef in dsRefs:
216
- nodeName = _makeDSNode(dsRef, allDatasetRefs, file)
217
- _renderEdge(taskNodeName, nodeName, file)
79
+ v = visualization.QuantumGraphDotVisualizer()
80
+ v.write_bipartite(qgraph, file)
218
81
 
219
- print("}", file=file)
220
82
  if close:
221
83
  file.close()
222
84
 
@@ -27,16 +27,17 @@
27
27
 
28
28
  __all__ = ["ExecutionGraphFixup"]
29
29
 
30
- import contextlib
31
30
  import itertools
31
+ import uuid
32
32
  from collections import defaultdict
33
- from collections.abc import Sequence
34
- from typing import Any
33
+ from collections.abc import Mapping, Sequence
35
34
 
36
35
  import networkx as nx
37
36
 
37
+ from lsst.daf.butler import DataCoordinate, DataIdValue
38
+
38
39
  from .execution_graph_fixup import ExecutionGraphFixup
39
- from .graph import QuantumGraph, QuantumNode
40
+ from .graph import QuantumGraph
40
41
 
41
42
 
42
43
  class ExecFixupDataId(ExecutionGraphFixup):
@@ -88,44 +89,16 @@ class ExecFixupDataId(ExecutionGraphFixup):
88
89
  else:
89
90
  self.dimensions = tuple(self.dimensions)
90
91
 
91
- def _key(self, qnode: QuantumNode) -> tuple[Any, ...]:
92
- """Produce comparison key for quantum data.
93
-
94
- Parameters
95
- ----------
96
- qnode : `QuantumNode`
97
- An individual node in a `~lsst.pipe.base.QuantumGraph`
98
-
99
- Returns
100
- -------
101
- key : `tuple`
102
- """
103
- dataId = qnode.quantum.dataId
104
- assert dataId is not None, "Quantum DataId cannot be None"
105
- key = tuple(dataId[dim] for dim in self.dimensions)
106
- return key
107
-
108
92
  def fixupQuanta(self, graph: QuantumGraph) -> QuantumGraph:
109
- taskDef = graph.findTaskDefByLabel(self.taskLabel)
110
- if taskDef is None:
111
- raise ValueError(f"Cannot find task with label {self.taskLabel}")
112
- quanta = list(graph.getNodesForTask(taskDef))
113
- keyQuanta = defaultdict(list)
114
- for q in quanta:
115
- key = self._key(q)
116
- keyQuanta[key].append(q)
117
- keys = sorted(keyQuanta.keys(), reverse=self.reverse)
118
- networkGraph = graph.graph
119
-
120
- for prev_key, key in itertools.pairwise(keys):
121
- for prev_node in keyQuanta[prev_key]:
122
- for node in keyQuanta[key]:
123
- # remove any existing edges between the two nodes, but
124
- # don't fail if there are not any. Both directions need
125
- # tried because in a directed graph, order maters
126
- for edge in ((node, prev_node), (prev_node, node)):
127
- with contextlib.suppress(nx.NetworkXException):
128
- networkGraph.remove_edge(*edge)
129
-
130
- networkGraph.add_edge(prev_node, node)
131
- return graph
93
+ raise NotImplementedError()
94
+
95
+ def fixup_graph(
96
+ self, xgraph: nx.DiGraph, quanta_by_task: Mapping[str, Mapping[DataCoordinate, uuid.UUID]]
97
+ ) -> None:
98
+ quanta_by_sort_key: defaultdict[tuple[DataIdValue, ...], list[uuid.UUID]] = defaultdict(list)
99
+ for data_id, quantum_id in quanta_by_task[self.taskLabel].items():
100
+ key = tuple(data_id[dim] for dim in self.dimensions)
101
+ quanta_by_sort_key[key].append(quantum_id)
102
+ sorted_keys = sorted(quanta_by_sort_key.keys(), reverse=self.reverse)
103
+ for prev_key, key in itertools.pairwise(sorted_keys):
104
+ xgraph.add_edges_from(itertools.product(quanta_by_sort_key[prev_key], quanta_by_sort_key[key]))
@@ -27,7 +27,13 @@
27
27
 
28
28
  __all__ = ["ExecutionGraphFixup"]
29
29
 
30
- from abc import ABC, abstractmethod
30
+ import uuid
31
+ from abc import ABC
32
+ from collections.abc import Mapping
33
+
34
+ import networkx
35
+
36
+ from lsst.daf.butler import DataCoordinate
31
37
 
32
38
  from .graph import QuantumGraph
33
39
 
@@ -35,27 +41,26 @@ from .graph import QuantumGraph
35
41
  class ExecutionGraphFixup(ABC):
36
42
  """Interface for classes which update quantum graphs before execution.
37
43
 
38
- Primary goal of this class is to modify quanta dependencies which may not
39
- be possible to reflect in a quantum graph using standard tools. One known
40
- use case for that is to guarantee particular execution order of visits in
41
- CI jobs for cases when outcome depends on the processing order of visits
42
- (e.g. AP association pipeline).
43
-
44
- Instances of this class receive pre-ordered sequence of quanta
45
- (`.QuantumGraph` instances) and they are allowed to modify quanta data in
46
- place, for example update ``dependencies`` field to add additional
47
- dependencies. Returned list of quanta will be re-ordered once again by the
48
- graph executor to reflect new dependencies.
44
+ Notes
45
+ -----
46
+ The primary goal of this class is to modify quanta dependencies which may
47
+ not be possible to reflect in a quantum graph using standard tools. One
48
+ known use case for that is to guarantee particular execution order of
49
+ visits in CI jobs for cases when outcome depends on the processing order of
50
+ visits (e.g. AP association pipeline).
51
+
52
+ Instances of this class receive a preliminary graph and are allowed to
53
+ add edges, as long as those edges do not result in a cycle. Edges and
54
+ nodes may not be removed.
55
+
56
+ New subclasses should implement only `fixup_graph`, which will always be
57
+ called first. `fixupQuanta` is only called if `fixup_graph` raises
58
+ `NotImplementedError`.
49
59
  """
50
60
 
51
- @abstractmethod
52
61
  def fixupQuanta(self, graph: QuantumGraph) -> QuantumGraph:
53
62
  """Update quanta in a graph.
54
63
 
55
- Potentially anything in the graph could be changed if it does not
56
- break executor assumptions. If modifications result in a dependency
57
- cycle the executor will raise an exception.
58
-
59
64
  Parameters
60
65
  ----------
61
66
  graph : `.QuantumGraph`
@@ -65,5 +70,31 @@ class ExecutionGraphFixup(ABC):
65
70
  -------
66
71
  graph : `.QuantumGraph`
67
72
  Modified graph.
73
+
74
+ Notes
75
+ -----
76
+ This hook is provided for backwards compatibility only.
77
+ """
78
+ raise NotImplementedError()
79
+
80
+ def fixup_graph(
81
+ self, xgraph: networkx.DiGraph, quanta_by_task: Mapping[str, Mapping[DataCoordinate, uuid.UUID]]
82
+ ) -> None:
83
+ """Update a networkx graph of quanta in place by adding edges to
84
+ further constrain the ordering.
85
+
86
+ Parameters
87
+ ----------
88
+ xgraph : `networkx.DiGraph`
89
+ A directed acyclic graph of quanta to modify in place. Node keys
90
+ are quantum UUIDs, and attributes include ``task_label`` (`str`)
91
+ and ``data_id`` (a full `lsst.daf.butler.DataCoordinate`, without
92
+ dimension records attached). Edges may be added, but not removed.
93
+ Nodes may not be modified.
94
+ quanta_by_task : `~collections.abc.Mapping` [ `str`,\
95
+ `~collections.abc.Mapping` [ `lsst.daf.butler.DataCoordinate`,\
96
+ `uuid.UUID` ] ]
97
+ All quanta in the graph, grouped first by task label and then by
98
+ data ID.
68
99
  """
69
- raise NotImplementedError
100
+ raise NotImplementedError()
@@ -38,7 +38,7 @@ from collections import defaultdict
38
38
  from collections.abc import Callable
39
39
  from dataclasses import dataclass
40
40
  from types import SimpleNamespace
41
- from typing import TYPE_CHECKING, ClassVar, cast
41
+ from typing import TYPE_CHECKING, ClassVar
42
42
 
43
43
  import networkx as nx
44
44
 
@@ -50,6 +50,7 @@ from lsst.daf.butler import (
50
50
  Quantum,
51
51
  SerializedDimensionRecord,
52
52
  )
53
+ from lsst.daf.butler._rubin import generate_uuidv7
53
54
  from lsst.utils import doImportType
54
55
 
55
56
  from ..config import PipelineTaskConfig
@@ -242,7 +243,7 @@ class DeserializerV1(DeserializerBase):
242
243
 
243
244
  # reconstruct node
244
245
  qNode = pickle.loads(dump)
245
- object.__setattr__(qNode, "nodeId", uuid.uuid4())
246
+ object.__setattr__(qNode, "nodeId", generate_uuidv7())
246
247
 
247
248
  # read the saved node, name. If it has been loaded, attach it, if
248
249
  # not read in the taskDef first, and then load it
@@ -376,7 +377,7 @@ class DeserializerV2(DeserializerBase):
376
377
 
377
378
  # reconstruct node
378
379
  qNode = pickle.loads(dump)
379
- object.__setattr__(qNode, "nodeId", uuid.uuid4())
380
+ object.__setattr__(qNode, "nodeId", generate_uuidv7())
380
381
 
381
382
  # read the saved node, name. If it has been loaded, attach it, if
382
383
  # not read in the taskDef first, and then load it
@@ -599,11 +600,11 @@ class DeserializerV3(DeserializerBase):
599
600
  # initInputRefs and initOutputRefs are optional
600
601
  if (refs := taskDefDump.get("initInputRefs")) is not None:
601
602
  initInputRefs[recreatedTaskDef.label] = [
602
- cast(DatasetRef, DatasetRef.from_json(ref, universe=universe)) for ref in refs
603
+ DatasetRef.from_json(ref, universe=universe) for ref in refs
603
604
  ]
604
605
  if (refs := taskDefDump.get("initOutputRefs")) is not None:
605
606
  initOutputRefs[recreatedTaskDef.label] = [
606
- cast(DatasetRef, DatasetRef.from_json(ref, universe=universe)) for ref in refs
607
+ DatasetRef.from_json(ref, universe=universe) for ref in refs
607
608
  ]
608
609
 
609
610
  # rebuild the mappings that associate dataset type names with
@@ -59,6 +59,7 @@ from lsst.daf.butler import (
59
59
  Quantum,
60
60
  QuantumBackedButler,
61
61
  )
62
+ from lsst.daf.butler._rubin import generate_uuidv7
62
63
  from lsst.daf.butler.datastore.record_data import DatastoreRecordData
63
64
  from lsst.daf.butler.persistence_context import PersistenceContextVars
64
65
  from lsst.daf.butler.registry import ConflictingDefinitionError
@@ -246,7 +247,7 @@ class QuantumGraph:
246
247
  "associated value in the mapping"
247
248
  )
248
249
  else:
249
- nodeId = uuid.uuid4()
250
+ nodeId = generate_uuidv7()
250
251
 
251
252
  inits = quantum.initInputs.values()
252
253
  inputs = quantum.inputs.values()
@@ -806,11 +807,18 @@ class QuantumGraph:
806
807
  uri : convertible to `~lsst.resources.ResourcePath`
807
808
  URI to where the graph should be saved.
808
809
  """
809
- buffer = self._buildSaveObject()
810
810
  path = ResourcePath(uri)
811
- if path.getExtension() not in (".qgraph"):
812
- raise TypeError(f"Can currently only save a graph in qgraph format not {uri}")
813
- path.write(buffer) # type: ignore # Ignore because bytearray is safe to use in place of bytes
811
+ match path.getExtension():
812
+ case ".qgraph":
813
+ buffer = self._buildSaveObject()
814
+ path.write(buffer) # type: ignore # Ignore because bytearray is safe to use in place of bytes
815
+ case ".qg":
816
+ from ..quantum_graph import PredictedQuantumGraphComponents
817
+
818
+ pqg = PredictedQuantumGraphComponents.from_old_quantum_graph(self)
819
+ pqg.write(path)
820
+ case ext:
821
+ raise TypeError(f"Can currently only save a graph in .qgraph or .qg format, not {ext!r}.")
814
822
 
815
823
  @property
816
824
  def metadata(self) -> MappingProxyType[str, Any]:
@@ -962,11 +970,23 @@ class QuantumGraph:
962
970
  the graph.
963
971
  """
964
972
  uri = ResourcePath(uri)
965
- if uri.getExtension() in {".qgraph"}:
966
- with LoadHelper(uri, minimumVersion, fullRead=(nodes is None)) as loader:
967
- qgraph = loader.load(universe, nodes, graphID)
968
- else:
969
- raise ValueError(f"Only know how to handle files saved as `.qgraph`, not {uri}")
973
+ match uri.getExtension():
974
+ case ".qgraph":
975
+ with LoadHelper(uri, minimumVersion, fullRead=(nodes is None)) as loader:
976
+ qgraph = loader.load(universe, nodes, graphID)
977
+ case ".qg":
978
+ from ..quantum_graph import PredictedQuantumGraphReader
979
+
980
+ with PredictedQuantumGraphReader.open(uri, page_size=100000) as qgr:
981
+ quantum_ids = (
982
+ [uuid.UUID(q) if not isinstance(q, uuid.UUID) else q for q in nodes]
983
+ if nodes is not None
984
+ else None
985
+ )
986
+ qgr.read_execution_quanta(quantum_ids)
987
+ qgraph = qgr.finish().to_old_quantum_graph()
988
+ case _:
989
+ raise ValueError(f"Only know how to handle files saved as `.qgraph`, not {uri}")
970
990
  if not isinstance(qgraph, QuantumGraph):
971
991
  raise TypeError(f"QuantumGraph file {uri} contains unexpected object type: {type(qgraph)}")
972
992
  return qgraph
@@ -75,6 +75,21 @@ class QgraphTaskSummary(pydantic.BaseModel):
75
75
  """See `pydantic.BaseModel.model_json_schema`."""
76
76
  return super().model_json_schema(*args, **kwargs)
77
77
 
78
+ @classmethod
79
+ def model_validate(cls, *args: Any, **kwargs: Any) -> Any:
80
+ """See `pydantic.BaseModel.model_validate`."""
81
+ return super().model_validate(*args, **kwargs)
82
+
83
+ @classmethod
84
+ def model_validate_json(cls, *args: Any, **kwargs: Any) -> Any:
85
+ """See `pydantic.BaseModel.model_validate_json`."""
86
+ return super().model_validate_json(*args, **kwargs)
87
+
88
+ @classmethod
89
+ def model_validate_strings(cls, *args: Any, **kwargs: Any) -> Any:
90
+ """See `pydantic.BaseModel.model_validate_strings`."""
91
+ return super().model_validate_strings(*args, **kwargs)
92
+
78
93
 
79
94
  class QgraphSummary(pydantic.BaseModel):
80
95
  """Report for the QuantumGraph creation or reading."""
@@ -129,3 +144,18 @@ class QgraphSummary(pydantic.BaseModel):
129
144
  def model_json_schema(cls, *args: Any, **kwargs: Any) -> Any:
130
145
  """See `pydantic.BaseModel.model_json_schema`."""
131
146
  return super().model_json_schema(*args, **kwargs)
147
+
148
+ @classmethod
149
+ def model_validate(cls, *args: Any, **kwargs: Any) -> Any:
150
+ """See `pydantic.BaseModel.model_validate`."""
151
+ return super().model_validate(*args, **kwargs)
152
+
153
+ @classmethod
154
+ def model_validate_json(cls, *args: Any, **kwargs: Any) -> Any:
155
+ """See `pydantic.BaseModel.model_validate_json`."""
156
+ return super().model_validate_json(*args, **kwargs)
157
+
158
+ @classmethod
159
+ def model_validate_strings(cls, *args: Any, **kwargs: Any) -> Any:
160
+ """See `pydantic.BaseModel.model_validate_strings`."""
161
+ return super().model_validate_strings(*args, **kwargs)
@@ -0,0 +1,119 @@
1
+ # This file is part of pipe_base.
2
+ #
3
+ # Developed for the LSST Data Management System.
4
+ # This product includes software developed by the LSST Project
5
+ # (http://www.lsst.org).
6
+ # See the COPYRIGHT file at the top-level directory of this distribution
7
+ # for details of code ownership.
8
+ #
9
+ # This software is dual licensed under the GNU General Public License and also
10
+ # under a 3-clause BSD license. Recipients may choose which of these licenses
11
+ # to use; please see the files gpl-3.0.txt and/or bsd_license.txt,
12
+ # respectively. If you choose the GPL option then the following text applies
13
+ # (but note that there is still no warranty even if you opt for BSD instead):
14
+ #
15
+ # This program is free software: you can redistribute it and/or modify
16
+ # it under the terms of the GNU General Public License as published by
17
+ # the Free Software Foundation, either version 3 of the License, or
18
+ # (at your option) any later version.
19
+ #
20
+ # This program is distributed in the hope that it will be useful,
21
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
22
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23
+ # GNU General Public License for more details.
24
+ #
25
+ # You should have received a copy of the GNU General Public License
26
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
27
+
28
+ from __future__ import annotations
29
+
30
+ __all__ = ("GraphWalker",)
31
+
32
+ from typing import Generic, Self, TypeVar
33
+
34
+ import networkx
35
+
36
+ _T = TypeVar("_T")
37
+
38
+
39
+ class GraphWalker(Generic[_T]):
40
+ """A helper for traversing directed acyclic graphs.
41
+
42
+ Parameters
43
+ ----------
44
+ xgraph : `networkx.DiGraph` or `networkx.MultiDiGraph`
45
+ Networkx graph to process. Will be consumed during iteration, so this
46
+ should often be a copy.
47
+
48
+ Notes
49
+ -----
50
+ Each iteration yields a `frozenset` of nodes, which may be empty if there
51
+ are no nodes ready for processing. A node is only considered ready if all
52
+ of its predecessor nodes have been marked as complete with `finish`.
53
+ Iteration only completes when all nodes have been finished or failed.
54
+
55
+ `GraphWalker` is not thread-safe; calling one `GraphWalker` method while
56
+ another is in progress is undefined behavior. It is designed to be used
57
+ in the management thread or process in a parallel traversal.
58
+ """
59
+
60
+ def __init__(self, xgraph: networkx.DiGraph | networkx.MultiDiGraph):
61
+ self._xgraph = xgraph
62
+ self._ready: set[_T] = set(next(iter(networkx.dag.topological_generations(self._xgraph)), []))
63
+ self._active: set[_T] = set()
64
+ self._incomplete: set[_T] = set(self._xgraph)
65
+
66
+ def __iter__(self) -> Self:
67
+ return self
68
+
69
+ def __next__(self) -> frozenset[_T]:
70
+ if not self._incomplete:
71
+ raise StopIteration()
72
+ new_active = frozenset(self._ready)
73
+ self._active.update(new_active)
74
+ self._ready.clear()
75
+ return new_active
76
+
77
+ def finish(self, key: _T) -> None:
78
+ """Mark a node as successfully processed, unblocking (at least in part)
79
+ iteration over successor nodes.
80
+
81
+ Parameters
82
+ ----------
83
+ key : unspecified
84
+ NetworkX key of the node to mark finished.
85
+ """
86
+ self._active.remove(key)
87
+ self._incomplete.remove(key)
88
+ successors = list(self._xgraph.successors(key))
89
+ for successor in successors:
90
+ assert successor not in self._active, (
91
+ "A node downstream of an active one should not have been yielded yet."
92
+ )
93
+ if all(
94
+ predecessor not in self._incomplete for predecessor in self._xgraph.predecessors(successor)
95
+ ):
96
+ self._ready.add(successor)
97
+
98
+ def fail(self, key: _T) -> list[_T]:
99
+ """Mark a node as unsuccessfully processed, permanently blocking all
100
+ recursive descendants.
101
+
102
+ Parameters
103
+ ----------
104
+ key : unspecified
105
+ NetworkX key of the node to mark as a failure.
106
+
107
+ Returns
108
+ -------
109
+ blocked : `list`
110
+ NetworkX keys of nodes that were recursive descendants of the
111
+ failed node, and will hence never be yielded by the iterator.
112
+ """
113
+ self._active.remove(key)
114
+ self._incomplete.remove(key)
115
+ descendants = list(networkx.dag.descendants(self._xgraph, key))
116
+ self._xgraph.remove_node(key)
117
+ self._xgraph.remove_nodes_from(descendants)
118
+ self._incomplete.difference_update(descendants)
119
+ return descendants