Coverage for python / lsst / pipe / base / graph / graph.py: 15%

586 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-17 16:30 -0800

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/>. 

27from __future__ import annotations 

28 

29__all__ = ("IncompatibleGraphError", "QuantumGraph") 

30 

31import datetime 

32import getpass 

33import io 

34import json 

35import lzma 

36import os 

37import struct 

38import sys 

39import time 

40import uuid 

41from collections import defaultdict, deque 

42from collections.abc import Generator, Iterable, Iterator, Mapping, MutableMapping 

43from itertools import chain 

44from types import MappingProxyType 

45from typing import Any, BinaryIO, TypeVar 

46 

47import networkx as nx 

48from networkx.drawing.nx_agraph import write_dot 

49 

50import lsst.utils.logging 

51from lsst.daf.butler import ( 

52 Config, 

53 DatasetId, 

54 DatasetRef, 

55 DatasetType, 

56 DimensionRecordsAccumulator, 

57 DimensionUniverse, 

58 LimitedButler, 

59 Quantum, 

60 QuantumBackedButler, 

61) 

62from lsst.daf.butler._rubin import generate_uuidv7 

63from lsst.daf.butler.datastore.record_data import DatastoreRecordData 

64from lsst.daf.butler.persistence_context import PersistenceContextVars 

65from lsst.daf.butler.registry import ConflictingDefinitionError 

66from lsst.resources import ResourcePath, ResourcePathExpression 

67from lsst.utils.introspection import get_full_type_name 

68from lsst.utils.packages import Packages 

69 

70from ..config import PipelineTaskConfig 

71from ..connections import iterConnections 

72from ..pipeline import TaskDef 

73from ..pipeline_graph import PipelineGraph, compare_packages, log_config_mismatch 

74from ._implDetails import DatasetTypeName, _DatasetTracker 

75from ._loadHelpers import LoadHelper 

76from ._versionDeserializers import DESERIALIZER_MAP 

77from .graphSummary import QgraphSummary, QgraphTaskSummary 

78from .quantumNode import BuildId, QuantumNode 

79 

80_T = TypeVar("_T", bound="QuantumGraph") 

81_LOG = lsst.utils.logging.getLogger(__name__) 

82 

83# modify this constant any time the on disk representation of the save file 

84# changes, and update the load helpers to behave properly for each version. 

85SAVE_VERSION = 3 

86 

87# Strings used to describe the format for the preamble bytes in a file save 

88# The base is a big endian encoded unsigned short that is used to hold the 

89# file format version. This allows reading version bytes and determine which 

90# loading code should be used for the rest of the file 

91STRUCT_FMT_BASE = ">H" 

92# 

93# Version 1 

94# This marks a big endian encoded format with an unsigned short, an unsigned 

95# long long, and an unsigned long long in the byte stream 

96# Version 2 

97# A big endian encoded format with an unsigned long long byte stream used to 

98# indicate the total length of the entire header. 

99STRUCT_FMT_STRING = {1: ">QQ", 2: ">Q"} 

100 

101# magic bytes that help determine this is a graph save 

102MAGIC_BYTES = b"qgraph4\xf6\xe8\xa9" 

103 

104 

105class IncompatibleGraphError(Exception): 

106 """Exception class to indicate that a lookup by NodeId is impossible due 

107 to incompatibilities. 

108 """ 

109 

110 pass 

111 

112 

113class QuantumGraph: 

114 """QuantumGraph is a directed acyclic graph of `QuantumNode` objects. 

115 

116 This data structure represents a concrete workflow generated from a 

117 `Pipeline`. 

118 

119 Parameters 

120 ---------- 

121 quanta : `~collections.abc.Mapping` [ `TaskDef`, \ 

122 `set` [ `~lsst.daf.butler.Quantum` ] ] 

123 This maps tasks (and their configs) to the sets of data they are to 

124 process. 

125 metadata : Optional `~collections.abc.Mapping` of `str` to primitives 

126 This is an optional parameter of extra data to carry with the graph. 

127 Entries in this mapping should be able to be serialized in JSON. 

128 universe : `~lsst.daf.butler.DimensionUniverse`, optional 

129 The dimensions in which quanta can be defined. Need only be provided if 

130 no quanta have data IDs. 

131 initInputs : `~collections.abc.Mapping`, optional 

132 Maps tasks to their InitInput dataset refs. Dataset refs can be either 

133 resolved or non-resolved. Presently the same dataset refs are included 

134 in each `~lsst.daf.butler.Quantum` for the same task. 

135 initOutputs : `~collections.abc.Mapping`, optional 

136 Maps tasks to their InitOutput dataset refs. Dataset refs can be either 

137 resolved or non-resolved. For intermediate resolved refs their dataset 

138 ID must match ``initInputs`` and Quantum ``initInputs``. 

139 globalInitOutputs : iterable [ `~lsst.daf.butler.DatasetRef` ], optional 

140 Dataset refs for some global objects produced by pipeline. These 

141 objects include task configurations and package versions. Typically 

142 they have an empty DataId, but there is no real restriction on what 

143 can appear here. 

144 registryDatasetTypes : iterable [ `~lsst.daf.butler.DatasetType` ], \ 

145 optional 

146 Dataset types which are used by this graph, their definitions must 

147 match registry. If registry does not define dataset type yet, then 

148 it should match one that will be created later. 

149 

150 Raises 

151 ------ 

152 ValueError 

153 Raised if the graph is pruned such that some tasks no longer have nodes 

154 associated with them. 

155 """ 

156 

157 def __init__( 

158 self, 

159 quanta: Mapping[TaskDef, set[Quantum]], 

160 metadata: Mapping[str, Any] | None = None, 

161 universe: DimensionUniverse | None = None, 

162 initInputs: Mapping[TaskDef, Iterable[DatasetRef]] | None = None, 

163 initOutputs: Mapping[TaskDef, Iterable[DatasetRef]] | None = None, 

164 globalInitOutputs: Iterable[DatasetRef] | None = None, 

165 registryDatasetTypes: Iterable[DatasetType] | None = None, 

166 ): 

167 self._buildGraphs( 

168 quanta, 

169 metadata=metadata, 

170 universe=universe, 

171 initInputs=initInputs, 

172 initOutputs=initOutputs, 

173 globalInitOutputs=globalInitOutputs, 

174 registryDatasetTypes=registryDatasetTypes, 

175 ) 

176 

177 def _buildGraphs( 

178 self, 

179 quanta: Mapping[TaskDef, set[Quantum]], 

180 *, 

181 _quantumToNodeId: Mapping[Quantum, uuid.UUID] | None = None, 

182 _buildId: BuildId | None = None, 

183 metadata: Mapping[str, Any] | None = None, 

184 universe: DimensionUniverse | None = None, 

185 initInputs: Mapping[TaskDef, Iterable[DatasetRef]] | None = None, 

186 initOutputs: Mapping[TaskDef, Iterable[DatasetRef]] | None = None, 

187 globalInitOutputs: Iterable[DatasetRef] | None = None, 

188 registryDatasetTypes: Iterable[DatasetType] | None = None, 

189 ) -> None: 

190 """Build the graph that is used to store the relation between tasks, 

191 and the graph that holds the relations between quanta 

192 """ 

193 # Save packages to metadata 

194 self._metadata = dict(metadata) if metadata is not None else {} 

195 self._metadata.setdefault("packages", Packages.fromSystem()) 

196 self._metadata.setdefault("user", getpass.getuser()) 

197 self._metadata.setdefault("time", f"{datetime.datetime.now()}") 

198 self._metadata.setdefault("full_command", " ".join(sys.argv)) 

199 

200 self._buildId = _buildId if _buildId is not None else BuildId(f"{time.time()}-{os.getpid()}") 

201 # Data structure used to identify relations between 

202 # DatasetTypeName -> TaskDef. 

203 self._datasetDict = _DatasetTracker(createInverse=True) 

204 

205 # Temporary graph that will have dataset UUIDs (as raw bytes) and 

206 # QuantumNode objects as nodes; will be collapsed down to just quanta 

207 # later. 

208 bipartite_graph = nx.DiGraph() 

209 

210 self._nodeIdMap: dict[uuid.UUID, QuantumNode] = {} 

211 self._taskToQuantumNode: MutableMapping[TaskDef, set[QuantumNode]] = defaultdict(set) 

212 for taskDef, quantumSet in quanta.items(): 

213 connections = taskDef.connections 

214 

215 # For each type of connection in the task, add a key to the 

216 # `_DatasetTracker` for the connections name, with a value of 

217 # the TaskDef in the appropriate field 

218 for inpt in iterConnections(connections, ("inputs", "prerequisiteInputs", "initInputs")): 

219 # Have to handle components in inputs. 

220 dataset_name, _, _ = inpt.name.partition(".") 

221 self._datasetDict.addConsumer(DatasetTypeName(dataset_name), taskDef) 

222 

223 for output in iterConnections(connections, ("outputs",)): 

224 # Have to handle possible components in outputs. 

225 dataset_name, _, _ = output.name.partition(".") 

226 self._datasetDict.addProducer(DatasetTypeName(dataset_name), taskDef) 

227 

228 # For each `Quantum` in the set of all `Quantum` for this task, 

229 # add a key to the `_DatasetTracker` that is a `DatasetRef` for one 

230 # of the individual datasets inside the `Quantum`, with a value of 

231 # a newly created QuantumNode to the appropriate input/output 

232 # field. 

233 for quantum in quantumSet: 

234 if quantum.dataId is not None: 

235 if universe is None: 

236 universe = quantum.dataId.universe 

237 elif universe != quantum.dataId.universe: 

238 raise RuntimeError( 

239 "Mismatched dimension universes in QuantumGraph construction: " 

240 f"{universe} != {quantum.dataId.universe}. " 

241 ) 

242 

243 if _quantumToNodeId: 

244 if (nodeId := _quantumToNodeId.get(quantum)) is None: 

245 raise ValueError( 

246 "If _quantuMToNodeNumber is not None, all quanta must have an " 

247 "associated value in the mapping" 

248 ) 

249 else: 

250 nodeId = generate_uuidv7() 

251 

252 inits = quantum.initInputs.values() 

253 inputs = quantum.inputs.values() 

254 value = QuantumNode(quantum, taskDef, nodeId) 

255 self._taskToQuantumNode[taskDef].add(value) 

256 self._nodeIdMap[nodeId] = value 

257 

258 bipartite_graph.add_node(value, bipartite=0) 

259 for dsRef in chain(inits, inputs): 

260 # unfortunately, `Quantum` allows inits to be individual 

261 # `DatasetRef`s or an Iterable of such, so there must 

262 # be an instance check here 

263 if isinstance(dsRef, Iterable): 

264 for sub in dsRef: 

265 bipartite_graph.add_node(sub.id.bytes, bipartite=1) 

266 bipartite_graph.add_edge(sub.id.bytes, value) 

267 else: 

268 assert isinstance(dsRef, DatasetRef) 

269 if dsRef.isComponent(): 

270 dsRef = dsRef.makeCompositeRef() 

271 bipartite_graph.add_node(dsRef.id.bytes, bipartite=1) 

272 bipartite_graph.add_edge(dsRef.id.bytes, value) 

273 for dsRef in chain.from_iterable(quantum.outputs.values()): 

274 bipartite_graph.add_node(dsRef.id.bytes, bipartite=1) 

275 bipartite_graph.add_edge(value, dsRef.id.bytes) 

276 

277 # Dimension universe 

278 if universe is None: 

279 raise RuntimeError( 

280 "Dimension universe or at least one quantum with a data ID " 

281 "must be provided when constructing a QuantumGraph." 

282 ) 

283 self._universe = universe 

284 

285 # Make graph of quanta relations, by projecting out the dataset nodes 

286 # in the bipartite_graph, leaving just the quanta. 

287 self._connectedQuanta = nx.algorithms.bipartite.projected_graph( 

288 bipartite_graph, self._nodeIdMap.values() 

289 ) 

290 self._count = len(self._connectedQuanta) 

291 

292 # Graph of task relations, used in various methods 

293 self._taskGraph = self._datasetDict.makeNetworkXGraph() 

294 

295 # convert default dict into a regular to prevent accidental key 

296 # insertion 

297 self._taskToQuantumNode = dict(self._taskToQuantumNode.items()) 

298 

299 self._initInputRefs: dict[str, list[DatasetRef]] = {} 

300 self._initOutputRefs: dict[str, list[DatasetRef]] = {} 

301 self._globalInitOutputRefs: list[DatasetRef] = [] 

302 self._registryDatasetTypes: list[DatasetType] = [] 

303 if initInputs is not None: 

304 self._initInputRefs = {taskDef.label: list(refs) for taskDef, refs in initInputs.items()} 

305 if initOutputs is not None: 

306 self._initOutputRefs = {taskDef.label: list(refs) for taskDef, refs in initOutputs.items()} 

307 if globalInitOutputs is not None: 

308 self._globalInitOutputRefs = list(globalInitOutputs) 

309 if registryDatasetTypes is not None: 

310 self._registryDatasetTypes = list(registryDatasetTypes) 

311 

312 # PipelineGraph is current constructed on first use. 

313 # TODO DM-40442: use PipelineGraph instead of TaskDef 

314 # collections. 

315 self._pipeline_graph: PipelineGraph | None = None 

316 

317 @property 

318 def pipeline_graph(self) -> PipelineGraph: 

319 """A graph representation of the tasks and dataset types in the quantum 

320 graph. 

321 """ 

322 if self._pipeline_graph is None: 

323 # Construct into a temporary for strong exception safety. 

324 pipeline_graph = PipelineGraph() 

325 for task_def in self._taskToQuantumNode.keys(): 

326 pipeline_graph.add_task( 

327 task_def.label, task_def.taskClass, task_def.config, connections=task_def.connections 

328 ) 

329 dataset_types = {dataset_type.name: dataset_type for dataset_type in self._registryDatasetTypes} 

330 pipeline_graph.resolve(dimensions=self._universe, dataset_types=dataset_types) 

331 self._pipeline_graph = pipeline_graph 

332 return self._pipeline_graph 

333 

334 def get_task_quanta(self, label: str) -> Mapping[uuid.UUID, Quantum]: 

335 """Return the quanta associated with the given task label. 

336 

337 Parameters 

338 ---------- 

339 label : `str` 

340 Task label. 

341 

342 Returns 

343 ------- 

344 quanta : `~collections.abc.Mapping` [ uuid.UUID, `Quantum` ] 

345 Mapping from quantum ID to quantum. Empty if ``label`` does not 

346 correspond to a task in this graph. 

347 """ 

348 task_def = self.findTaskDefByLabel(label) 

349 if not task_def: 

350 return {} 

351 return {node.nodeId: node.quantum for node in self.getNodesForTask(task_def)} 

352 

353 @property 

354 def taskGraph(self) -> nx.DiGraph: 

355 """A graph representing the relations between the tasks inside 

356 the quantum graph (`networkx.DiGraph`). 

357 """ 

358 return self._taskGraph 

359 

360 @property 

361 def graph(self) -> nx.DiGraph: 

362 """A graph representing the relations between all the `QuantumNode` 

363 objects (`networkx.DiGraph`). 

364 

365 The graph should usually be iterated over, or passed to methods of this 

366 class, but sometimes direct access to the ``networkx`` object may be 

367 helpful. 

368 """ 

369 return self._connectedQuanta 

370 

371 @property 

372 def inputQuanta(self) -> Iterable[QuantumNode]: 

373 """The nodes that are inputs to the graph (iterable [`QuantumNode`]). 

374 

375 These are the nodes that do not depend on any other nodes in the 

376 graph. 

377 """ 

378 return (q for q, n in self._connectedQuanta.in_degree if n == 0) 

379 

380 @property 

381 def outputQuanta(self) -> Iterable[QuantumNode]: 

382 """The nodes that are outputs of the graph (iterable [`QuantumNode`]). 

383 

384 These are the nodes that have no nodes that depend on them in the 

385 graph. 

386 """ 

387 return [q for q, n in self._connectedQuanta.out_degree if n == 0] 

388 

389 @property 

390 def allDatasetTypes(self) -> tuple[DatasetTypeName, ...]: 

391 """All the data set type names that are present in the graph 

392 (`tuple` [`str`]). 

393 

394 These types do not include global init-outputs. 

395 """ 

396 return tuple(self._datasetDict.keys()) 

397 

398 @property 

399 def isConnected(self) -> bool: 

400 """Whether all of the nodes in the graph are connected, ignoring 

401 directionality of connections (`bool`). 

402 """ 

403 return nx.is_weakly_connected(self._connectedQuanta) 

404 

405 def getQuantumNodeByNodeId(self, nodeId: uuid.UUID) -> QuantumNode: 

406 """Lookup a `QuantumNode` from an id associated with the node. 

407 

408 Parameters 

409 ---------- 

410 nodeId : `NodeId` 

411 The number associated with a node. 

412 

413 Returns 

414 ------- 

415 node : `QuantumNode` 

416 The node corresponding with input number. 

417 

418 Raises 

419 ------ 

420 KeyError 

421 Raised if the requested nodeId is not in the graph. 

422 """ 

423 return self._nodeIdMap[nodeId] 

424 

425 def getQuantaForTask(self, taskDef: TaskDef) -> frozenset[Quantum]: 

426 """Return all the `~lsst.daf.butler.Quantum` associated with a 

427 `TaskDef`. 

428 

429 Parameters 

430 ---------- 

431 taskDef : `TaskDef` 

432 The `TaskDef` for which `~lsst.daf.butler.Quantum` are to be 

433 queried. 

434 

435 Returns 

436 ------- 

437 quanta : `frozenset` of `~lsst.daf.butler.Quantum` 

438 The `set` of `~lsst.daf.butler.Quantum` that is associated with the 

439 specified `TaskDef`. 

440 """ 

441 return frozenset(node.quantum for node in self._taskToQuantumNode.get(taskDef, ())) 

442 

443 def getNumberOfQuantaForTask(self, taskDef: TaskDef) -> int: 

444 """Return the number of `~lsst.daf.butler.Quantum` associated with 

445 a `TaskDef`. 

446 

447 Parameters 

448 ---------- 

449 taskDef : `TaskDef` 

450 The `TaskDef` for which `~lsst.daf.butler.Quantum` are to be 

451 queried. 

452 

453 Returns 

454 ------- 

455 count : `int` 

456 The number of `~lsst.daf.butler.Quantum` that are associated with 

457 the specified `TaskDef`. 

458 """ 

459 return len(self._taskToQuantumNode.get(taskDef, ())) 

460 

461 def getNodesForTask(self, taskDef: TaskDef) -> frozenset[QuantumNode]: 

462 r"""Return all the `QuantumNode`\s associated with a `TaskDef`. 

463 

464 Parameters 

465 ---------- 

466 taskDef : `TaskDef` 

467 The `TaskDef` for which `~lsst.daf.butler.Quantum` are to be 

468 queried. 

469 

470 Returns 

471 ------- 

472 nodes : `frozenset` [ `QuantumNode` ] 

473 A `frozenset` of `QuantumNode` that is associated with the 

474 specified `TaskDef`. 

475 """ 

476 return frozenset(self._taskToQuantumNode[taskDef]) 

477 

478 def findTasksWithInput(self, datasetTypeName: DatasetTypeName) -> Iterable[TaskDef]: 

479 """Find all tasks that have the specified dataset type name as an 

480 input. 

481 

482 Parameters 

483 ---------- 

484 datasetTypeName : `str` 

485 A string representing the name of a dataset type to be queried, 

486 can also accept a `DatasetTypeName` which is a `~typing.NewType` of 

487 `str` for type safety in static type checking. 

488 

489 Returns 

490 ------- 

491 tasks : iterable of `TaskDef` 

492 `TaskDef` objects that have the specified `DatasetTypeName` as an 

493 input, list will be empty if no tasks use specified 

494 `DatasetTypeName` as an input. 

495 

496 Raises 

497 ------ 

498 KeyError 

499 Raised if the `DatasetTypeName` is not part of the `QuantumGraph`. 

500 """ 

501 return (c for c in self._datasetDict.getConsumers(datasetTypeName)) 

502 

503 def findTaskWithOutput(self, datasetTypeName: DatasetTypeName) -> TaskDef | None: 

504 """Find all tasks that have the specified dataset type name as an 

505 output. 

506 

507 Parameters 

508 ---------- 

509 datasetTypeName : `str` 

510 A string representing the name of a dataset type to be queried, 

511 can also accept a `DatasetTypeName` which is a `~typing.NewType` of 

512 `str` for type safety in static type checking. 

513 

514 Returns 

515 ------- 

516 result : `TaskDef` or `None` 

517 `TaskDef` that outputs `DatasetTypeName` as an output or `None` if 

518 none of the tasks produce this `DatasetTypeName`. 

519 

520 Raises 

521 ------ 

522 KeyError 

523 Raised if the `DatasetTypeName` is not part of the `QuantumGraph`. 

524 """ 

525 return self._datasetDict.getProducer(datasetTypeName) 

526 

527 def tasksWithDSType(self, datasetTypeName: DatasetTypeName) -> Iterable[TaskDef]: 

528 """Find all tasks that are associated with the specified dataset type 

529 name. 

530 

531 Parameters 

532 ---------- 

533 datasetTypeName : `str` 

534 A string representing the name of a dataset type to be queried, 

535 can also accept a `DatasetTypeName` which is a `~typing.NewType` of 

536 `str` for type safety in static type checking. 

537 

538 Returns 

539 ------- 

540 result : iterable of `TaskDef` 

541 `TaskDef` objects that are associated with the specified 

542 `DatasetTypeName`. 

543 

544 Raises 

545 ------ 

546 KeyError 

547 Raised if the `DatasetTypeName` is not part of the `QuantumGraph`. 

548 """ 

549 return self._datasetDict.getAll(datasetTypeName) 

550 

551 def findTaskDefByName(self, taskName: str) -> list[TaskDef]: 

552 """Determine which `TaskDef` objects in this graph are associated 

553 with a `str` representing a task name (looks at the ``taskName`` 

554 property of `TaskDef` objects). 

555 

556 Returns a list of `TaskDef` objects as a `PipelineTask` may appear 

557 multiple times in a graph with different labels. 

558 

559 Parameters 

560 ---------- 

561 taskName : `str` 

562 Name of a task to search for. 

563 

564 Returns 

565 ------- 

566 result : `list` of `TaskDef` 

567 List of the `TaskDef` objects that have the name specified. 

568 Multiple values are returned in the case that a task is used 

569 multiple times with different labels. 

570 """ 

571 results = [] 

572 for task in self._taskToQuantumNode: 

573 split = task.taskName.split(".") 

574 if split[-1] == taskName: 

575 results.append(task) 

576 return results 

577 

578 def findTaskDefByLabel(self, label: str) -> TaskDef | None: 

579 """Determine which `TaskDef` objects in this graph are associated 

580 with a `str` representing a tasks label. 

581 

582 Parameters 

583 ---------- 

584 label : `str` 

585 Name of a task to search for. 

586 

587 Returns 

588 ------- 

589 result : `TaskDef` 

590 `TaskDef` objects that has the specified label. 

591 """ 

592 for task in self._taskToQuantumNode: 

593 if label == task.label: 

594 return task 

595 return None 

596 

597 def findQuantaWithDSType(self, datasetTypeName: DatasetTypeName) -> set[Quantum]: 

598 r"""Return all the `~lsst.daf.butler.Quantum` that contain a specified 

599 `DatasetTypeName`. 

600 

601 Parameters 

602 ---------- 

603 datasetTypeName : `str` 

604 The name of the dataset type to search for as a string, 

605 can also accept a `DatasetTypeName` which is a `~typing.NewType` of 

606 `str` for type safety in static type checking. 

607 

608 Returns 

609 ------- 

610 result : `set` of `QuantumNode` objects 

611 A `set` of `QuantumNode`\s that contain specified 

612 `DatasetTypeName`. 

613 

614 Raises 

615 ------ 

616 KeyError 

617 Raised if the `DatasetTypeName` is not part of the `QuantumGraph`. 

618 """ 

619 tasks = self._datasetDict.getAll(datasetTypeName) 

620 result: set[Quantum] = set() 

621 result = result.union(quantum for task in tasks for quantum in self.getQuantaForTask(task)) 

622 return result 

623 

624 def checkQuantumInGraph(self, quantum: Quantum) -> bool: 

625 """Check if specified quantum appears in the graph as part of a node. 

626 

627 Parameters 

628 ---------- 

629 quantum : `lsst.daf.butler.Quantum` 

630 The quantum to search for. 

631 

632 Returns 

633 ------- 

634 in_graph : `bool` 

635 The result of searching for the quantum. 

636 """ 

637 return any(quantum == node.quantum for node in self) 

638 

639 def writeDotGraph(self, output: str | io.BufferedIOBase) -> None: 

640 """Write out the graph as a dot graph. 

641 

642 Parameters 

643 ---------- 

644 output : `str` or `io.BufferedIOBase` 

645 Either a filesystem path to write to, or a file handle object. 

646 """ 

647 write_dot(self._connectedQuanta, output) 

648 

649 def subset(self: _T, nodes: QuantumNode | Iterable[QuantumNode]) -> _T: 

650 """Create a new graph object that contains the subset of the nodes 

651 specified as input. Node number is preserved. 

652 

653 Parameters 

654 ---------- 

655 nodes : `QuantumNode` or iterable of `QuantumNode` 

656 Nodes from which to create subset. 

657 

658 Returns 

659 ------- 

660 graph : instance of graph type 

661 An instance of the type from which the subset was created. 

662 """ 

663 if not isinstance(nodes, Iterable): 

664 nodes = (nodes,) 

665 quantumSubgraph = self._connectedQuanta.subgraph(nodes).nodes 

666 quantumMap = defaultdict(set) 

667 

668 dataset_type_names: set[str] = set() 

669 node: QuantumNode 

670 for node in quantumSubgraph: 

671 quantumMap[node.taskDef].add(node.quantum) 

672 dataset_type_names.update( 

673 dstype.name 

674 for dstype in chain( 

675 node.quantum.inputs.keys(), node.quantum.outputs.keys(), node.quantum.initInputs.keys() 

676 ) 

677 ) 

678 

679 # May need to trim dataset types from registryDatasetTypes. 

680 for taskDef in quantumMap: 

681 if refs := self.initOutputRefs(taskDef): 

682 dataset_type_names.update(ref.datasetType.name for ref in refs) 

683 dataset_type_names.update(ref.datasetType.name for ref in self._globalInitOutputRefs) 

684 registryDatasetTypes = [ 

685 dstype for dstype in self._registryDatasetTypes if dstype.name in dataset_type_names 

686 ] 

687 

688 # convert to standard dict to prevent accidental key insertion 

689 quantumDict: dict[TaskDef, set[Quantum]] = dict(quantumMap.items()) 

690 # Create an empty graph, and then populate it with custom mapping 

691 newInst = type(self)({}, universe=self._universe) 

692 # TODO: Do we need to copy initInputs/initOutputs? 

693 newInst._buildGraphs( 

694 quantumDict, 

695 _quantumToNodeId={n.quantum: n.nodeId for n in nodes}, 

696 _buildId=self._buildId, 

697 metadata=self._metadata, 

698 universe=self._universe, 

699 globalInitOutputs=self._globalInitOutputRefs, 

700 registryDatasetTypes=registryDatasetTypes, 

701 ) 

702 return newInst 

703 

704 def subsetToConnected(self: _T) -> tuple[_T, ...]: 

705 """Generate a list of subgraphs where each is connected. 

706 

707 Returns 

708 ------- 

709 result : `list` of `QuantumGraph` 

710 A list of graphs that are each connected. 

711 """ 

712 return tuple( 

713 self.subset(connectedSet) 

714 for connectedSet in nx.weakly_connected_components(self._connectedQuanta) 

715 ) 

716 

717 def determineInputsToQuantumNode(self, node: QuantumNode) -> set[QuantumNode]: 

718 """Return a set of `QuantumNode` that are direct inputs to a specified 

719 node. 

720 

721 Parameters 

722 ---------- 

723 node : `QuantumNode` 

724 The node of the graph for which inputs are to be determined. 

725 

726 Returns 

727 ------- 

728 inputs : `set` of `QuantumNode` 

729 All the nodes that are direct inputs to specified node. 

730 """ 

731 return set(self._connectedQuanta.predecessors(node)) 

732 

733 def determineOutputsOfQuantumNode(self, node: QuantumNode) -> set[QuantumNode]: 

734 """Return a set of `QuantumNode` that are direct outputs of a specified 

735 node. 

736 

737 Parameters 

738 ---------- 

739 node : `QuantumNode` 

740 The node of the graph for which outputs are to be determined. 

741 

742 Returns 

743 ------- 

744 outputs : `set` of `QuantumNode` 

745 All the nodes that are direct outputs to specified node. 

746 """ 

747 return set(self._connectedQuanta.successors(node)) 

748 

749 def determineConnectionsOfQuantumNode(self: _T, node: QuantumNode) -> _T: 

750 """Return a graph of `QuantumNode` that are direct inputs and outputs 

751 of a specified node. 

752 

753 Parameters 

754 ---------- 

755 node : `QuantumNode` 

756 The node of the graph for which connected nodes are to be 

757 determined. 

758 

759 Returns 

760 ------- 

761 graph : graph of `QuantumNode` 

762 All the nodes that are directly connected to specified node. 

763 """ 

764 nodes = self.determineInputsToQuantumNode(node).union(self.determineOutputsOfQuantumNode(node)) 

765 nodes.add(node) 

766 return self.subset(nodes) 

767 

768 def determineAncestorsOfQuantumNode(self: _T, node: QuantumNode) -> _T: 

769 """Return a graph of the specified node and all the ancestor nodes 

770 directly reachable by walking edges. 

771 

772 Parameters 

773 ---------- 

774 node : `QuantumNode` 

775 The node for which all ancestors are to be determined. 

776 

777 Returns 

778 ------- 

779 ancestors : graph of `QuantumNode` 

780 Graph of node and all of its ancestors. 

781 """ 

782 predecessorNodes = nx.ancestors(self._connectedQuanta, node) 

783 predecessorNodes.add(node) 

784 return self.subset(predecessorNodes) 

785 

786 def findCycle(self) -> list[tuple[QuantumNode, QuantumNode]]: 

787 """Check a graph for the presense of cycles and returns the edges of 

788 any cycles found, or an empty list if there is no cycle. 

789 

790 Returns 

791 ------- 

792 result : `list` of `tuple` of [ `QuantumNode`, `QuantumNode` ] 

793 A list of any graph edges that form a cycle, or an empty list if 

794 there is no cycle. Empty list to so support if graph.find_cycle() 

795 syntax as an empty list is falsy. 

796 """ 

797 try: 

798 return nx.find_cycle(self._connectedQuanta) 

799 except nx.NetworkXNoCycle: 

800 return [] 

801 

802 def saveUri(self, uri: ResourcePathExpression) -> None: 

803 """Save `QuantumGraph` to the specified URI. 

804 

805 Parameters 

806 ---------- 

807 uri : convertible to `~lsst.resources.ResourcePath` 

808 URI to where the graph should be saved. 

809 """ 

810 path = ResourcePath(uri) 

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}.") 

822 

823 @property 

824 def metadata(self) -> MappingProxyType[str, Any]: 

825 """Extra data carried with the graph (mapping [`str`] or `None`). 

826 

827 The mapping is a dynamic view of this object's metadata. Values should 

828 be able to be serialized in JSON. 

829 """ 

830 return MappingProxyType(self._metadata) 

831 

832 def get_init_input_refs(self, task_label: str) -> list[DatasetRef]: 

833 """Return the DatasetRefs for the given task's init inputs. 

834 

835 Parameters 

836 ---------- 

837 task_label : `str` 

838 Label of the task. 

839 

840 Returns 

841 ------- 

842 refs : `list` [ `lsst.daf.butler.DatasetRef` ] 

843 Dataset references. Guaranteed to be a new list, not internal 

844 state. 

845 """ 

846 return list(self._initInputRefs.get(task_label, ())) 

847 

848 def get_init_output_refs(self, task_label: str) -> list[DatasetRef]: 

849 """Return the DatasetRefs for the given task's init outputs. 

850 

851 Parameters 

852 ---------- 

853 task_label : `str` 

854 Label of the task. 

855 

856 Returns 

857 ------- 

858 refs : `list` [ `lsst.daf.butler.DatasetRef` ] 

859 Dataset references. Guaranteed to be a new list, not internal 

860 state. 

861 """ 

862 return list(self._initOutputRefs.get(task_label, ())) 

863 

864 def initInputRefs(self, taskDef: TaskDef) -> list[DatasetRef] | None: 

865 """Return DatasetRefs for a given task InitInputs. 

866 

867 Parameters 

868 ---------- 

869 taskDef : `TaskDef` 

870 Task definition structure. 

871 

872 Returns 

873 ------- 

874 refs : `list` [ `~lsst.daf.butler.DatasetRef` ] or `None` 

875 DatasetRef for the task InitInput, can be `None`. This can return 

876 either resolved or non-resolved reference. 

877 """ 

878 return self._initInputRefs.get(taskDef.label) 

879 

880 def initOutputRefs(self, taskDef: TaskDef) -> list[DatasetRef] | None: 

881 """Return DatasetRefs for a given task InitOutputs. 

882 

883 Parameters 

884 ---------- 

885 taskDef : `TaskDef` 

886 Task definition structure. 

887 

888 Returns 

889 ------- 

890 refs : `list` [ `~lsst.daf.butler.DatasetRef` ] or `None` 

891 DatasetRefs for the task InitOutput, can be `None`. This can return 

892 either resolved or non-resolved reference. Resolved reference will 

893 match Quantum's initInputs if this is an intermediate dataset type. 

894 """ 

895 return self._initOutputRefs.get(taskDef.label) 

896 

897 def globalInitOutputRefs(self) -> list[DatasetRef]: 

898 """Return DatasetRefs for global InitOutputs. 

899 

900 Returns 

901 ------- 

902 refs : `list` [ `~lsst.daf.butler.DatasetRef` ] 

903 DatasetRefs for global InitOutputs. 

904 """ 

905 return self._globalInitOutputRefs 

906 

907 def registryDatasetTypes(self) -> list[DatasetType]: 

908 """Return dataset types used by this graph, their definitions match 

909 dataset types from registry. 

910 

911 Returns 

912 ------- 

913 refs : `list` [ `~lsst.daf.butler.DatasetType` ] 

914 Dataset types for this graph. 

915 """ 

916 return self._registryDatasetTypes 

917 

918 @classmethod 

919 def loadUri( 

920 cls, 

921 uri: ResourcePathExpression, 

922 universe: DimensionUniverse | None = None, 

923 nodes: Iterable[uuid.UUID | str] | None = None, 

924 graphID: BuildId | None = None, 

925 minimumVersion: int = 3, 

926 ) -> QuantumGraph: 

927 """Read `QuantumGraph` from a URI. 

928 

929 Parameters 

930 ---------- 

931 uri : convertible to `~lsst.resources.ResourcePath` 

932 URI from where to load the graph. 

933 universe : `~lsst.daf.butler.DimensionUniverse`, optional 

934 If `None` it is loaded from the `QuantumGraph` 

935 saved structure. If supplied, the 

936 `~lsst.daf.butler.DimensionUniverse` from the loaded `QuantumGraph` 

937 will be validated against the supplied argument for compatibility. 

938 nodes : iterable of [ `uuid.UUID` | `str` ] or `None` 

939 UUIDs that correspond to nodes in the graph. If specified, only 

940 these nodes will be loaded. Defaults to None, in which case all 

941 nodes will be loaded. 

942 graphID : `str` or `None` 

943 If specified this ID is verified against the loaded graph prior to 

944 loading any Nodes. This defaults to None in which case no 

945 validation is done. 

946 minimumVersion : `int` 

947 Minimum version of a save file to load. Set to -1 to load all 

948 versions. Older versions may need to be loaded, and re-saved 

949 to upgrade them to the latest format before they can be used in 

950 production. 

951 

952 Returns 

953 ------- 

954 graph : `QuantumGraph` 

955 Resulting QuantumGraph instance. 

956 

957 Raises 

958 ------ 

959 TypeError 

960 Raised if file contains instance of a type other than 

961 `QuantumGraph`. 

962 ValueError 

963 Raised if one or more of the nodes requested is not in the 

964 `QuantumGraph` or if graphID parameter does not match the graph 

965 being loaded or if the supplied uri does not point at a valid 

966 `QuantumGraph` save file. 

967 RuntimeError 

968 Raise if Supplied `~lsst.daf.butler.DimensionUniverse` is not 

969 compatible with the `~lsst.daf.butler.DimensionUniverse` saved in 

970 the graph. 

971 """ 

972 uri = ResourcePath(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}") 

990 if not isinstance(qgraph, QuantumGraph): 

991 raise TypeError(f"QuantumGraph file {uri} contains unexpected object type: {type(qgraph)}") 

992 return qgraph 

993 

994 @classmethod 

995 def readHeader(cls, uri: ResourcePathExpression, minimumVersion: int = 3) -> str | None: 

996 """Read the header of a `QuantumGraph` pointed to by the uri parameter 

997 and return it as a string. 

998 

999 Parameters 

1000 ---------- 

1001 uri : convertible to `~lsst.resources.ResourcePath` 

1002 The location of the `QuantumGraph` to load. If the argument is a 

1003 string, it must correspond to a valid 

1004 `~lsst.resources.ResourcePath` path. 

1005 minimumVersion : `int` 

1006 Minimum version of a save file to load. Set to -1 to load all 

1007 versions. Older versions may need to be loaded, and re-saved 

1008 to upgrade them to the latest format before they can be used in 

1009 production. 

1010 

1011 Returns 

1012 ------- 

1013 header : `str` or `None` 

1014 The header associated with the specified `QuantumGraph` it there is 

1015 one, else `None`. 

1016 

1017 Raises 

1018 ------ 

1019 ValueError 

1020 Raised if the extension of the file specified by uri is not a 

1021 `QuantumGraph` extension. 

1022 """ 

1023 uri = ResourcePath(uri) 

1024 if uri.getExtension() in {".qgraph"}: 

1025 return LoadHelper(uri, minimumVersion).readHeader() 

1026 else: 

1027 raise ValueError("Only know how to handle files saved as `.qgraph`") 

1028 

1029 def buildAndPrintHeader(self) -> None: 

1030 """Create a header that would be used in a save of this object and 

1031 prints it out to standard out. 

1032 """ 

1033 _, header = self._buildSaveObject(returnHeader=True) 

1034 print(json.dumps(header)) 

1035 

1036 def save(self, file: BinaryIO) -> None: 

1037 """Save QuantumGraph to a file. 

1038 

1039 Parameters 

1040 ---------- 

1041 file : `io.BufferedIOBase` 

1042 File to write data open in binary mode. 

1043 """ 

1044 buffer = self._buildSaveObject() 

1045 file.write(buffer) # type: ignore # Ignore because bytearray is safe to use in place of bytes 

1046 

1047 def _buildSaveObject(self, returnHeader: bool = False) -> bytearray | tuple[bytearray, dict]: 

1048 thing = PersistenceContextVars() 

1049 result = thing.run(self._buildSaveObjectImpl, returnHeader) 

1050 return result 

1051 

1052 def _buildSaveObjectImpl(self, returnHeader: bool = False) -> bytearray | tuple[bytearray, dict]: 

1053 # make some containers 

1054 jsonData: deque[bytes] = deque() 

1055 # node map is a list because json does not accept mapping keys that 

1056 # are not strings, so we store a list of key, value pairs that will 

1057 # be converted to a mapping on load 

1058 nodeMap = [] 

1059 taskDefMap = {} 

1060 headerData: dict[str, Any] = {} 

1061 

1062 # Store the QuantumGraph BuildId, this will allow validating BuildIds 

1063 # at load time, prior to loading any QuantumNodes. Name chosen for 

1064 # unlikely conflicts. 

1065 headerData["GraphBuildID"] = self.graphID 

1066 headerData["Metadata"] = self._metadata 

1067 

1068 # Store the universe this graph was created with 

1069 universeConfig = self._universe.dimensionConfig 

1070 headerData["universe"] = universeConfig.toDict() 

1071 

1072 # counter for the number of bytes processed thus far 

1073 count = 0 

1074 # serialize out the task Defs recording the start and end bytes of each 

1075 # taskDef 

1076 inverseLookup = self._datasetDict.inverse 

1077 taskDef: TaskDef 

1078 # sort by task label to ensure serialization happens in the same order 

1079 for taskDef in self.taskGraph: 

1080 # compressing has very little impact on saving or load time, but 

1081 # a large impact on on disk size, so it is worth doing 

1082 taskDescription: dict[str, Any] = {} 

1083 # save the fully qualified name. 

1084 taskDescription["taskName"] = get_full_type_name(taskDef.taskClass) 

1085 # save the config as a text stream that will be un-persisted on the 

1086 # other end 

1087 stream = io.StringIO() 

1088 taskDef.config.saveToStream(stream) 

1089 taskDescription["config"] = stream.getvalue() 

1090 taskDescription["label"] = taskDef.label 

1091 if (refs := self._initInputRefs.get(taskDef.label)) is not None: 

1092 taskDescription["initInputRefs"] = [ref.to_json() for ref in refs] 

1093 if (refs := self._initOutputRefs.get(taskDef.label)) is not None: 

1094 taskDescription["initOutputRefs"] = [ref.to_json() for ref in refs] 

1095 

1096 inputs = [] 

1097 outputs = [] 

1098 

1099 # Determine the connection between all of tasks and save that in 

1100 # the header as a list of connections and edges in each task 

1101 # this will help in un-persisting, and possibly in a "quick view" 

1102 # method that does not require everything to be un-persisted 

1103 # 

1104 # Typing returns can't be parameter dependent 

1105 for connection in inverseLookup[taskDef]: # type: ignore 

1106 consumers = self._datasetDict.getConsumers(connection) 

1107 producer = self._datasetDict.getProducer(connection) 

1108 if taskDef in consumers: 

1109 # This checks if the task consumes the connection directly 

1110 # from the datastore or it is produced by another task 

1111 producerLabel = producer.label if producer is not None else "datastore" 

1112 inputs.append((producerLabel, connection)) 

1113 elif taskDef not in consumers and producer is taskDef: 

1114 # If there are no consumers for this tasks produced 

1115 # connection, the output will be said to be the datastore 

1116 # in which case the for loop will be a zero length loop 

1117 if not consumers: 

1118 outputs.append(("datastore", connection)) 

1119 for td in consumers: 

1120 outputs.append((td.label, connection)) 

1121 

1122 # dump to json string, and encode that string to bytes and then 

1123 # conpress those bytes 

1124 dump = lzma.compress(json.dumps(taskDescription).encode(), preset=2) 

1125 # record the sizing and relation information 

1126 taskDefMap[taskDef.label] = { 

1127 "bytes": (count, count + len(dump)), 

1128 "inputs": inputs, 

1129 "outputs": outputs, 

1130 } 

1131 count += len(dump) 

1132 jsonData.append(dump) 

1133 

1134 headerData["TaskDefs"] = taskDefMap 

1135 

1136 # serialize the nodes, recording the start and end bytes of each node 

1137 dimAccumulator = DimensionRecordsAccumulator() 

1138 for node in self: 

1139 # compressing has very little impact on saving or load time, but 

1140 # a large impact on on disk size, so it is worth doing 

1141 simpleNode = node.to_simple(accumulator=dimAccumulator) 

1142 

1143 dump = lzma.compress(simpleNode.model_dump_json().encode(), preset=2) 

1144 jsonData.append(dump) 

1145 nodeMap.append( 

1146 ( 

1147 str(node.nodeId), 

1148 { 

1149 "bytes": (count, count + len(dump)), 

1150 "inputs": [str(n.nodeId) for n in self.determineInputsToQuantumNode(node)], 

1151 "outputs": [str(n.nodeId) for n in self.determineOutputsOfQuantumNode(node)], 

1152 }, 

1153 ) 

1154 ) 

1155 count += len(dump) 

1156 

1157 headerData["DimensionRecords"] = { 

1158 key: value.model_dump() 

1159 for key, value in dimAccumulator.makeSerializedDimensionRecordMapping().items() 

1160 } 

1161 

1162 # need to serialize this as a series of key,value tuples because of 

1163 # a limitation on how json cant do anything but strings as keys 

1164 headerData["Nodes"] = nodeMap 

1165 

1166 if self._globalInitOutputRefs: 

1167 headerData["GlobalInitOutputRefs"] = [ref.to_json() for ref in self._globalInitOutputRefs] 

1168 

1169 if self._registryDatasetTypes: 

1170 headerData["RegistryDatasetTypes"] = [dstype.to_json() for dstype in self._registryDatasetTypes] 

1171 

1172 # dump the headerData to json 

1173 header_encode = lzma.compress(json.dumps(headerData).encode()) 

1174 

1175 # record the sizes as 2 unsigned long long numbers for a total of 16 

1176 # bytes 

1177 save_bytes = struct.pack(STRUCT_FMT_BASE, SAVE_VERSION) 

1178 

1179 fmt_string = DESERIALIZER_MAP[SAVE_VERSION].FMT_STRING() 

1180 map_lengths = struct.pack(fmt_string, len(header_encode)) 

1181 

1182 # write each component of the save out in a deterministic order 

1183 buffer = bytearray() 

1184 buffer.extend(MAGIC_BYTES) 

1185 buffer.extend(save_bytes) 

1186 buffer.extend(map_lengths) 

1187 buffer.extend(header_encode) 

1188 # Iterate over the length of jsonData, and for each element pop the 

1189 # leftmost element off the deque and write it out. This is to save 

1190 # memory, as the memory is added to the buffer object, it is removed 

1191 # from from the container. 

1192 # 

1193 # Only this section needs to worry about memory pressure because 

1194 # everything else written to the buffer prior to this data is 

1195 # only on the order of kilobytes to low numbers of megabytes. 

1196 while jsonData: 

1197 buffer.extend(jsonData.popleft()) 

1198 if returnHeader: 

1199 return buffer, headerData 

1200 else: 

1201 return buffer 

1202 

1203 @classmethod 

1204 def load( 

1205 cls, 

1206 file: BinaryIO, 

1207 universe: DimensionUniverse | None = None, 

1208 nodes: Iterable[uuid.UUID] | None = None, 

1209 graphID: BuildId | None = None, 

1210 minimumVersion: int = 3, 

1211 ) -> QuantumGraph: 

1212 """Read `QuantumGraph` from a file that was made by `save`. 

1213 

1214 Parameters 

1215 ---------- 

1216 file : `io.IO` of bytes 

1217 File with data open in binary mode. 

1218 universe : `~lsst.daf.butler.DimensionUniverse`, optional 

1219 If `None` it is loaded from the `QuantumGraph` 

1220 saved structure. If supplied, the 

1221 `~lsst.daf.butler.DimensionUniverse` from the loaded `QuantumGraph` 

1222 will be validated against the supplied argument for compatibility. 

1223 nodes : iterable of `uuid.UUID` or `None` 

1224 UUIDs that correspond to nodes in the graph. If specified, only 

1225 these nodes will be loaded. Defaults to None, in which case all 

1226 nodes will be loaded. 

1227 graphID : `str` or `None` 

1228 If specified this ID is verified against the loaded graph prior to 

1229 loading any Nodes. This defaults to None in which case no 

1230 validation is done. 

1231 minimumVersion : `int` 

1232 Minimum version of a save file to load. Set to -1 to load all 

1233 versions. Older versions may need to be loaded, and re-saved 

1234 to upgrade them to the latest format before they can be used in 

1235 production. 

1236 

1237 Returns 

1238 ------- 

1239 graph : `QuantumGraph` 

1240 Resulting QuantumGraph instance. 

1241 

1242 Raises 

1243 ------ 

1244 TypeError 

1245 Raised if data contains instance of a type other than 

1246 `QuantumGraph`. 

1247 ValueError 

1248 Raised if one or more of the nodes requested is not in the 

1249 `QuantumGraph` or if graphID parameter does not match the graph 

1250 being loaded or if the supplied uri does not point at a valid 

1251 `QuantumGraph` save file. 

1252 """ 

1253 with LoadHelper(file, minimumVersion, fullRead=(nodes is None)) as loader: 

1254 qgraph = loader.load(universe, nodes, graphID) 

1255 if not isinstance(qgraph, QuantumGraph): 

1256 raise TypeError(f"QuantumGraph file contains unexpected object type: {type(qgraph)}") 

1257 return qgraph 

1258 

1259 def iterTaskGraph(self) -> Generator[TaskDef, None, None]: 

1260 """Iterate over the `taskGraph` attribute in topological order. 

1261 

1262 Yields 

1263 ------ 

1264 taskDef : `TaskDef` 

1265 `TaskDef` objects in topological order. 

1266 """ 

1267 yield from nx.topological_sort(self.taskGraph) 

1268 

1269 def updateRun(self, run: str, *, metadata_key: str | None = None, update_graph_id: bool = False) -> None: 

1270 """Change output run and dataset ID for each output dataset. 

1271 

1272 Parameters 

1273 ---------- 

1274 run : `str` 

1275 New output run name. 

1276 metadata_key : `str` or `None` 

1277 Specifies matadata key corresponding to output run name to update 

1278 with new run name. If `None` or if metadata is missing it is not 

1279 updated. If metadata is present but key is missing, it will be 

1280 added. 

1281 update_graph_id : `bool`, optional 

1282 If `True` then also update graph ID with a new unique value. 

1283 """ 

1284 dataset_id_map: dict[DatasetId, DatasetId] = {} 

1285 

1286 def _update_output_refs( 

1287 refs: Iterable[DatasetRef], run: str, dataset_id_map: MutableMapping[DatasetId, DatasetId] 

1288 ) -> Iterator[DatasetRef]: 

1289 """Update a collection of `~lsst.daf.butler.DatasetRef` with new 

1290 run and dataset IDs. 

1291 """ 

1292 for ref in refs: 

1293 new_ref = ref.replace(run=run) 

1294 dataset_id_map[ref.id] = new_ref.id 

1295 yield new_ref 

1296 

1297 def _update_intermediate_refs( 

1298 refs: Iterable[DatasetRef], run: str, dataset_id_map: Mapping[DatasetId, DatasetId] 

1299 ) -> Iterator[DatasetRef]: 

1300 """Update intermediate references with new run and IDs. Only the 

1301 references that appear in ``dataset_id_map`` are updated, others 

1302 are returned unchanged. 

1303 """ 

1304 for ref in refs: 

1305 if dataset_id := dataset_id_map.get(ref.id): 

1306 ref = ref.replace(run=run, id=dataset_id) 

1307 yield ref 

1308 

1309 # Replace quantum output refs first. 

1310 for node in self._connectedQuanta: 

1311 quantum = node.quantum 

1312 outputs = { 

1313 dataset_type: tuple(_update_output_refs(refs, run, dataset_id_map)) 

1314 for dataset_type, refs in quantum.outputs.items() 

1315 } 

1316 updated_quantum = Quantum( 

1317 taskName=quantum.taskName, 

1318 dataId=quantum.dataId, 

1319 initInputs=quantum.initInputs, 

1320 inputs=quantum.inputs, 

1321 outputs=outputs, 

1322 datastore_records=quantum.datastore_records, 

1323 ) 

1324 node._replace_quantum(updated_quantum) 

1325 

1326 self._initOutputRefs = { 

1327 task_def: list(_update_output_refs(refs, run, dataset_id_map)) 

1328 for task_def, refs in self._initOutputRefs.items() 

1329 } 

1330 self._globalInitOutputRefs = list( 

1331 _update_output_refs(self._globalInitOutputRefs, run, dataset_id_map) 

1332 ) 

1333 

1334 # Update all intermediates from their matching outputs. 

1335 for node in self._connectedQuanta: 

1336 quantum = node.quantum 

1337 inputs = { 

1338 dataset_type: tuple(_update_intermediate_refs(refs, run, dataset_id_map)) 

1339 for dataset_type, refs in quantum.inputs.items() 

1340 } 

1341 initInputs = list(_update_intermediate_refs(quantum.initInputs.values(), run, dataset_id_map)) 

1342 

1343 updated_quantum = Quantum( 

1344 taskName=quantum.taskName, 

1345 dataId=quantum.dataId, 

1346 initInputs=initInputs, 

1347 inputs=inputs, 

1348 outputs=quantum.outputs, 

1349 datastore_records=quantum.datastore_records, 

1350 ) 

1351 node._replace_quantum(updated_quantum) 

1352 

1353 self._initInputRefs = { 

1354 task_def: list(_update_intermediate_refs(refs, run, dataset_id_map)) 

1355 for task_def, refs in self._initInputRefs.items() 

1356 } 

1357 

1358 if update_graph_id: 

1359 self._buildId = BuildId(f"{time.time()}-{os.getpid()}") 

1360 

1361 # Update run if given. 

1362 if metadata_key is not None: 

1363 self._metadata[metadata_key] = run 

1364 

1365 @property 

1366 def graphID(self) -> BuildId: 

1367 """The ID generated by the graph at construction time (`str`).""" 

1368 return self._buildId 

1369 

1370 @property 

1371 def universe(self) -> DimensionUniverse: 

1372 """Dimension universe associated with this graph 

1373 (`~lsst.daf.butler.DimensionUniverse`). 

1374 """ 

1375 return self._universe 

1376 

1377 def __iter__(self) -> Generator[QuantumNode, None, None]: 

1378 yield from nx.topological_sort(self._connectedQuanta) 

1379 

1380 def __len__(self) -> int: 

1381 return self._count 

1382 

1383 def __contains__(self, node: QuantumNode) -> bool: 

1384 return self._connectedQuanta.has_node(node) 

1385 

1386 def __getstate__(self) -> dict: 

1387 """Store a compact form of the graph as a list of graph nodes, and a 

1388 tuple of task labels and task configs. The full graph can be 

1389 reconstructed with this information, and it preserves the ordering of 

1390 the graph nodes. 

1391 """ 

1392 universe: DimensionUniverse | None = None 

1393 for node in self: 

1394 dId = node.quantum.dataId 

1395 if dId is None: 

1396 continue 

1397 universe = dId.universe 

1398 return {"reduced": self._buildSaveObject(), "graphId": self._buildId, "universe": universe} 

1399 

1400 def __setstate__(self, state: dict) -> None: 

1401 """Reconstructs the state of the graph from the information persisted 

1402 in getstate. 

1403 """ 

1404 buffer = io.BytesIO(state["reduced"]) 

1405 with LoadHelper(buffer, minimumVersion=3) as loader: 

1406 qgraph = loader.load(state["universe"], graphID=state["graphId"]) 

1407 

1408 self._metadata = qgraph._metadata 

1409 self._buildId = qgraph._buildId 

1410 self._datasetDict = qgraph._datasetDict 

1411 self._nodeIdMap = qgraph._nodeIdMap 

1412 self._count = len(qgraph) 

1413 self._taskToQuantumNode = qgraph._taskToQuantumNode 

1414 self._taskGraph = qgraph._taskGraph 

1415 self._connectedQuanta = qgraph._connectedQuanta 

1416 self._initInputRefs = qgraph._initInputRefs 

1417 self._initOutputRefs = qgraph._initOutputRefs 

1418 

1419 def __eq__(self, other: object) -> bool: 

1420 if not isinstance(other, QuantumGraph): 

1421 return False 

1422 if len(self) != len(other): 

1423 return False 

1424 for node in self: 

1425 if node not in other: 

1426 return False 

1427 if self.determineInputsToQuantumNode(node) != other.determineInputsToQuantumNode(node): 

1428 return False 

1429 if self.determineOutputsOfQuantumNode(node) != other.determineOutputsOfQuantumNode(node): 

1430 return False 

1431 if set(self.allDatasetTypes) != set(other.allDatasetTypes): 

1432 return False 

1433 return set(self.taskGraph) == set(other.taskGraph) 

1434 

1435 def getSummary(self) -> QgraphSummary: 

1436 """Create summary of graph. 

1437 

1438 Returns 

1439 ------- 

1440 summary : `QgraphSummary` 

1441 Summary of QuantumGraph. 

1442 """ 

1443 inCollection = self.metadata.get("input", None) 

1444 if isinstance(inCollection, str): 

1445 inCollection = [inCollection] 

1446 summary = QgraphSummary( 

1447 graphID=self.graphID, 

1448 cmdLine=self.metadata.get("full_command", None), 

1449 creationUTC=self.metadata.get("time", None), 

1450 inputCollection=inCollection, 

1451 outputCollection=self.metadata.get("output", None), 

1452 outputRun=self.metadata.get("output_run", None), 

1453 ) 

1454 for q in self: 

1455 qts = summary.qgraphTaskSummaries.setdefault( 

1456 q.taskDef.label, QgraphTaskSummary(taskLabel=q.taskDef.label) 

1457 ) 

1458 qts.numQuanta += 1 

1459 

1460 for k in q.quantum.inputs.keys(): 

1461 qts.numInputs[k.name] += 1 

1462 

1463 for k in q.quantum.outputs.keys(): 

1464 qts.numOutputs[k.name] += 1 

1465 

1466 return summary 

1467 

1468 def make_init_qbb( 

1469 self, 

1470 butler_config: Config | ResourcePathExpression, 

1471 *, 

1472 config_search_paths: Iterable[str] | None = None, 

1473 ) -> QuantumBackedButler: 

1474 """Construct an quantum-backed butler suitable for reading and writing 

1475 init input and init output datasets, respectively. 

1476 

1477 This requires the full graph to have been loaded. 

1478 

1479 Parameters 

1480 ---------- 

1481 butler_config : `~lsst.daf.butler.Config` or \ 

1482 `~lsst.resources.ResourcePathExpression` 

1483 A butler repository root, configuration filename, or configuration 

1484 instance. 

1485 config_search_paths : `~collections.abc.Iterable` [ `str` ], optional 

1486 Additional search paths for butler configuration. 

1487 

1488 Returns 

1489 ------- 

1490 qbb : `~lsst.daf.butler.QuantumBackedButler` 

1491 A limited butler that can ``get`` init-input datasets and ``put`` 

1492 init-output datasets. 

1493 """ 

1494 universe = self.universe 

1495 # Collect all init input/output dataset IDs. 

1496 predicted_inputs: set[DatasetId] = set() 

1497 predicted_outputs: set[DatasetId] = set() 

1498 pipeline_graph = self.pipeline_graph 

1499 for task_label in pipeline_graph.tasks: 

1500 predicted_inputs.update(ref.id for ref in self.get_init_input_refs(task_label)) 

1501 predicted_outputs.update(ref.id for ref in self.get_init_output_refs(task_label)) 

1502 predicted_outputs.update(ref.id for ref in self.globalInitOutputRefs()) 

1503 # remove intermediates from inputs 

1504 predicted_inputs -= predicted_outputs 

1505 # Very inefficient way to extract datastore records from quantum graph, 

1506 # we have to scan all quanta and look at their datastore records. 

1507 datastore_records: dict[str, DatastoreRecordData] = {} 

1508 for quantum_node in self: 

1509 for store_name, records in quantum_node.quantum.datastore_records.items(): 

1510 subset = records.subset(predicted_inputs) 

1511 if subset is not None: 

1512 datastore_records.setdefault(store_name, DatastoreRecordData()).update(subset) 

1513 

1514 dataset_types = {dstype.name: dstype for dstype in self.registryDatasetTypes()} 

1515 # Make butler from everything. 

1516 return QuantumBackedButler.from_predicted( 

1517 config=butler_config, 

1518 predicted_inputs=predicted_inputs, 

1519 predicted_outputs=predicted_outputs, 

1520 dimensions=universe, 

1521 datastore_records=datastore_records, 

1522 search_paths=list(config_search_paths) if config_search_paths is not None else None, 

1523 dataset_types=dataset_types, 

1524 ) 

1525 

1526 def write_init_outputs(self, butler: LimitedButler, skip_existing: bool = True) -> None: 

1527 """Write the init-output datasets for all tasks in the quantum graph. 

1528 

1529 Parameters 

1530 ---------- 

1531 butler : `lsst.daf.butler.LimitedButler` 

1532 A limited butler data repository client. 

1533 skip_existing : `bool`, optional 

1534 If `True` (default) ignore init-outputs that already exist. If 

1535 `False`, raise. 

1536 

1537 Raises 

1538 ------ 

1539 lsst.daf.butler.registry.ConflictingDefinitionError 

1540 Raised if an init-output dataset already exists and 

1541 ``skip_existing=False``. 

1542 """ 

1543 # Extract init-input and init-output refs from the QG. 

1544 input_refs: dict[str, DatasetRef] = {} 

1545 output_refs: dict[str, DatasetRef] = {} 

1546 for task_node in self.pipeline_graph.tasks.values(): 

1547 input_refs.update( 

1548 {ref.datasetType.name: ref for ref in self.get_init_input_refs(task_node.label)} 

1549 ) 

1550 output_refs.update( 

1551 { 

1552 ref.datasetType.name: ref 

1553 for ref in self.get_init_output_refs(task_node.label) 

1554 if ref.datasetType.name != task_node.init.config_output.dataset_type_name 

1555 } 

1556 ) 

1557 for ref, is_stored in butler.stored_many(output_refs.values()).items(): 

1558 if is_stored: 

1559 if not skip_existing: 

1560 raise ConflictingDefinitionError(f"Init-output dataset {ref} already exists.") 

1561 # We'll `put` whatever's left in output_refs at the end. 

1562 del output_refs[ref.datasetType.name] 

1563 # Instantiate tasks, reading overall init-inputs and gathering 

1564 # init-output in-memory objects. 

1565 init_outputs: list[tuple[Any, DatasetType]] = [] 

1566 self.pipeline_graph.instantiate_tasks( 

1567 get_init_input=lambda dataset_type: butler.get( 

1568 input_refs[dataset_type.name].overrideStorageClass(dataset_type.storageClass) 

1569 ), 

1570 init_outputs=init_outputs, 

1571 ) 

1572 # Write init-outputs that weren't already present. 

1573 for obj, dataset_type in init_outputs: 

1574 if new_ref := output_refs.get(dataset_type.name): 

1575 assert new_ref.datasetType.storageClass_name == dataset_type.storageClass_name, ( 

1576 "QG init refs should use task connection storage classes." 

1577 ) 

1578 butler.put(obj, new_ref) 

1579 

1580 def write_configs(self, butler: LimitedButler, compare_existing: bool = True) -> None: 

1581 """Write the config datasets for all tasks in the quantum graph. 

1582 

1583 Parameters 

1584 ---------- 

1585 butler : `lsst.daf.butler.LimitedButler` 

1586 A limited butler data repository client. 

1587 compare_existing : `bool`, optional 

1588 If `True` check configs that already exist for consistency. If 

1589 `False`, always raise if configs already exist. 

1590 

1591 Raises 

1592 ------ 

1593 lsst.daf.butler.registry.ConflictingDefinitionError 

1594 Raised if an config dataset already exists and 

1595 ``compare_existing=False``, or if the existing config is not 

1596 consistent with the config in the quantum graph. 

1597 """ 

1598 to_put: list[tuple[PipelineTaskConfig, DatasetRef]] = [] 

1599 for task_node in self.pipeline_graph.tasks.values(): 

1600 dataset_type_name = task_node.init.config_output.dataset_type_name 

1601 (ref,) = [ # noqa: UP027 

1602 ref 

1603 for ref in self.get_init_output_refs(task_node.label) 

1604 if ref.datasetType.name == dataset_type_name 

1605 ] 

1606 try: 

1607 old_config = butler.get(ref) 

1608 except (LookupError, FileNotFoundError): 

1609 old_config = None 

1610 if old_config is not None: 

1611 if not compare_existing: 

1612 raise ConflictingDefinitionError(f"Config dataset {ref} already exists.") 

1613 if not task_node.config.compare(old_config, shortcut=False, output=log_config_mismatch): 

1614 raise ConflictingDefinitionError( 

1615 f"Config does not match existing task config {dataset_type_name!r} in " 

1616 "butler; tasks configurations must be consistent within the same run collection." 

1617 ) 

1618 else: 

1619 to_put.append((task_node.config, ref)) 

1620 # We do writes at the end to minimize the mess we leave behind when we 

1621 # raise an exception. 

1622 for config, ref in to_put: 

1623 butler.put(config, ref) 

1624 

1625 def write_packages(self, butler: LimitedButler, compare_existing: bool = True) -> None: 

1626 """Write the 'packages' dataset for the currently-active software 

1627 versions. 

1628 

1629 Parameters 

1630 ---------- 

1631 butler : `lsst.daf.butler.LimitedButler` 

1632 A limited butler data repository client. 

1633 compare_existing : `bool`, optional 

1634 If `True` check packages that already exist for consistency. If 

1635 `False`, always raise if the packages dataset already exists. 

1636 

1637 Raises 

1638 ------ 

1639 lsst.daf.butler.registry.ConflictingDefinitionError 

1640 Raised if the packages dataset already exists and is not consistent 

1641 with the current packages. 

1642 """ 

1643 new_packages = Packages.fromSystem() 

1644 (ref,) = self.globalInitOutputRefs() 

1645 try: 

1646 packages = butler.get(ref) 

1647 except (LookupError, FileNotFoundError): 

1648 packages = None 

1649 if packages is not None: 

1650 if not compare_existing: 

1651 raise ConflictingDefinitionError(f"Packages dataset {ref} already exists.") 

1652 if compare_packages(packages, new_packages): 

1653 # have to remove existing dataset first; butler has no 

1654 # replace option. 

1655 butler.pruneDatasets([ref], unstore=True, purge=True) 

1656 butler.put(packages, ref) 

1657 else: 

1658 butler.put(new_packages, ref) 

1659 

1660 def init_output_run(self, butler: LimitedButler, existing: bool = True) -> None: 

1661 """Initialize a new output RUN collection by writing init-output 

1662 datasets (including configs and packages). 

1663 

1664 Parameters 

1665 ---------- 

1666 butler : `lsst.daf.butler.LimitedButler` 

1667 A limited butler data repository client. 

1668 existing : `bool`, optional 

1669 If `True` check or ignore outputs that already exist. If 

1670 `False`, always raise if an output dataset already exists. 

1671 

1672 Raises 

1673 ------ 

1674 lsst.daf.butler.registry.ConflictingDefinitionError 

1675 Raised if there are existing init output datasets, and either 

1676 ``existing=False`` or their contents are not compatible with this 

1677 graph. 

1678 """ 

1679 self.write_configs(butler, compare_existing=existing) 

1680 self.write_packages(butler, compare_existing=existing) 

1681 self.write_init_outputs(butler, skip_existing=existing) 

1682 

1683 def get_refs( 

1684 self, 

1685 *, 

1686 include_init_inputs: bool = False, 

1687 include_inputs: bool = False, 

1688 include_intermediates: bool | None = None, 

1689 include_init_outputs: bool = False, 

1690 include_outputs: bool = False, 

1691 conform_outputs: bool = True, 

1692 ) -> tuple[set[DatasetRef], dict[str, DatastoreRecordData]]: 

1693 """Get the requested dataset refs from the graph. 

1694 

1695 Parameters 

1696 ---------- 

1697 include_init_inputs : `bool`, optional 

1698 Include init inputs. 

1699 include_inputs : `bool`, optional 

1700 Include inputs. 

1701 include_intermediates : `bool` or `None`, optional 

1702 If `None`, no special handling for intermediates is performed. 

1703 If `True` intermediates are calculated even if other flags 

1704 do not request datasets. If `False` intermediates will be removed 

1705 from any results. 

1706 include_init_outputs : `bool`, optional 

1707 Include init outpus. 

1708 include_outputs : `bool`, optional 

1709 Include outputs. 

1710 conform_outputs : `bool`, optional 

1711 Whether any outputs found should have their dataset types conformed 

1712 with the registry dataset types. 

1713 

1714 Returns 

1715 ------- 

1716 refs : `set` [ `lsst.daf.butler.DatasetRef` ] 

1717 The requested dataset refs found in the graph. 

1718 datastore_records : `dict` [ `str`, \ 

1719 `lsst.daf.butler.datastore.record_data.DatastoreRecordData` ] 

1720 Any datastore records found. 

1721 

1722 Notes 

1723 ----- 

1724 Conforming and requesting inputs and outputs can result in the same 

1725 dataset appearing in the results twice with differing storage classes. 

1726 """ 

1727 datastore_records: dict[str, DatastoreRecordData] = {} 

1728 init_input_refs: set[DatasetRef] = set() 

1729 init_output_refs: set[DatasetRef] = set(self.globalInitOutputRefs()) 

1730 

1731 if include_intermediates is True: 

1732 # Need to enable inputs and outputs even if not explicitly 

1733 # requested. 

1734 request_include_init_inputs = True 

1735 request_include_inputs = True 

1736 request_include_init_outputs = True 

1737 request_include_outputs = True 

1738 else: 

1739 request_include_init_inputs = include_init_inputs 

1740 request_include_inputs = include_inputs 

1741 request_include_init_outputs = include_init_outputs 

1742 request_include_outputs = include_outputs 

1743 

1744 if request_include_init_inputs or request_include_init_outputs: 

1745 for task_def in self.iterTaskGraph(): 

1746 if request_include_init_inputs: 

1747 if in_refs := self.initInputRefs(task_def): 

1748 init_input_refs.update(in_refs) 

1749 if request_include_init_outputs: 

1750 if out_refs := self.initOutputRefs(task_def): 

1751 init_output_refs.update(out_refs) 

1752 

1753 input_refs: set[DatasetRef] = set() 

1754 output_refs: set[DatasetRef] = set() 

1755 

1756 for qnode in self: 

1757 if request_include_inputs: 

1758 for other_refs in qnode.quantum.inputs.values(): 

1759 input_refs.update(other_refs) 

1760 # Inputs can come with datastore records. 

1761 for store_name, records in qnode.quantum.datastore_records.items(): 

1762 datastore_records.setdefault(store_name, DatastoreRecordData()).update(records) 

1763 if request_include_outputs: 

1764 for other_refs in qnode.quantum.outputs.values(): 

1765 output_refs.update(other_refs) 

1766 

1767 # Intermediates are the intersection of inputs and outputs. Must do 

1768 # this analysis before conforming since dataset type changes will 

1769 # change set membership. 

1770 inter_msg = "" 

1771 intermediates = set() 

1772 if include_intermediates is not None: 

1773 intermediates = (input_refs | init_input_refs) & (output_refs | init_output_refs) 

1774 

1775 if include_intermediates is False: 

1776 # Remove intermediates from results. 

1777 init_input_refs -= intermediates 

1778 input_refs -= intermediates 

1779 init_output_refs -= intermediates 

1780 output_refs -= intermediates 

1781 inter_msg = f"; Intermediates removed: {len(intermediates)}" 

1782 intermediates = set() 

1783 elif include_intermediates is True: 

1784 # Do not mention intermediates if all the input/output flags 

1785 # would have resulted in them anyhow. 

1786 if ( 

1787 (request_include_init_inputs is not include_init_inputs) 

1788 or (request_include_inputs is not include_inputs) 

1789 or (request_include_init_outputs is not include_init_outputs) 

1790 or (request_include_outputs is not include_outputs) 

1791 ): 

1792 inter_msg = f"; including intermediates: {len(intermediates)}" 

1793 

1794 # Assign intermediates to the relevant category. 

1795 if not include_init_inputs: 

1796 init_input_refs &= intermediates 

1797 if not include_inputs: 

1798 input_refs &= intermediates 

1799 if not include_init_outputs: 

1800 init_output_refs &= intermediates 

1801 if not include_outputs: 

1802 output_refs &= intermediates 

1803 

1804 # Conforming can result in an input ref and an output ref appearing 

1805 # in the returned results that are identical apart from storage class. 

1806 if conform_outputs: 

1807 # Get data repository definitions from the QuantumGraph; these can 

1808 # have different storage classes than those in the quanta. 

1809 dataset_types = {dstype.name: dstype for dstype in self.registryDatasetTypes()} 

1810 

1811 def _update_ref(ref: DatasetRef) -> DatasetRef: 

1812 internal_dataset_type = dataset_types.get(ref.datasetType.name, ref.datasetType) 

1813 if internal_dataset_type.storageClass_name != ref.datasetType.storageClass_name: 

1814 ref = ref.replace(storage_class=internal_dataset_type.storageClass_name) 

1815 return ref 

1816 

1817 # Convert output_refs to the data repository storage classes, too. 

1818 output_refs = {_update_ref(ref) for ref in output_refs} 

1819 init_output_refs = {_update_ref(ref) for ref in init_output_refs} 

1820 

1821 _LOG.verbose( 

1822 "Found the following datasets. InitInputs: %d; Inputs: %d; InitOutputs: %s; Outputs: %d%s", 

1823 len(init_input_refs), 

1824 len(input_refs), 

1825 len(init_output_refs), 

1826 len(output_refs), 

1827 inter_msg, 

1828 ) 

1829 

1830 refs = input_refs | init_input_refs | init_output_refs | output_refs 

1831 return refs, datastore_records