Coverage for python/lsst/pipe/base/pipeline_graph/_pipeline_graph.py: 12%

739 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-11 01:30 -0700

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__ = ("PipelineGraph", "compare_packages", "log_config_mismatch") 

30 

31import gzip 

32import itertools 

33import json 

34import logging 

35from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence, Set 

36from typing import TYPE_CHECKING, Any, BinaryIO, Literal, TypeVar, cast 

37 

38import networkx 

39import networkx.algorithms.bipartite 

40import networkx.algorithms.dag 

41 

42from lsst.daf.butler import ( 

43 Butler, 

44 DataCoordinate, 

45 DataId, 

46 DatasetRef, 

47 DatasetType, 

48 DimensionGroup, 

49 DimensionUniverse, 

50 MissingDatasetTypeError, 

51) 

52from lsst.daf.butler.registry import ConflictingDefinitionError, Registry 

53from lsst.resources import ResourcePath, ResourcePathExpression 

54from lsst.utils.packages import Packages 

55 

56from .._dataset_handle import InMemoryDatasetHandle 

57from ..automatic_connection_constants import PACKAGES_INIT_OUTPUT_NAME, PACKAGES_INIT_OUTPUT_STORAGE_CLASS 

58from . import expressions 

59from ._dataset_types import DatasetTypeNode 

60from ._edges import Edge, ReadEdge, WriteEdge 

61from ._exceptions import ( 

62 DuplicateOutputError, 

63 EdgesChangedError, 

64 InvalidExpressionError, 

65 InvalidStepsError, 

66 PipelineDataCycleError, 

67 PipelineGraphError, 

68 PipelineGraphExceptionSafetyError, 

69 UnresolvedGraphError, 

70) 

71from ._mapping_views import DatasetTypeMappingView, TaskMappingView 

72from ._nodes import NodeKey, NodeType 

73from ._task_subsets import StepDefinitions, TaskSubset 

74from ._tasks import TaskImportMode, TaskInitNode, TaskNode, _TaskNodeImportedData 

75 

76if TYPE_CHECKING: 

77 from ..config import PipelineTaskConfig 

78 from ..connections import PipelineTaskConnections 

79 from ..pipeline import TaskDef 

80 from ..pipelineTask import PipelineTask 

81 

82 

83_G = TypeVar("_G", bound=networkx.DiGraph | networkx.MultiDiGraph) 

84 

85_LOG = logging.getLogger("lsst.pipe.base.pipeline_graph") 

86 

87 

88class PipelineGraph: 

89 """A graph representation of fully-configured pipeline. 

90 

91 `PipelineGraph` instances are typically constructed by calling 

92 `.Pipeline.to_graph`, but in rare cases constructing and then populating an 

93 empty one may be preferable. 

94 

95 Parameters 

96 ---------- 

97 description : `str`, optional 

98 String description for this pipeline. 

99 universe : `lsst.daf.butler.DimensionUniverse`, optional 

100 Definitions for all butler dimensions. If not provided, some 

101 attributes will not be available until `resolve` is called. 

102 data_id : `lsst.daf.butler.DataCoordinate` or other data ID, optional 

103 Data ID that represents a constraint on all quanta generated by this 

104 pipeline. This typically just holds the instrument constraint included 

105 in the pipeline definition, if there was one. 

106 """ 

107 

108 ########################################################################### 

109 # 

110 # Simple Pipeline Graph Inspection Interface: 

111 # 

112 # - for inspecting graph structure, not modifying it (except to sort and] 

113 # resolve); 

114 # 

115 # - no NodeKey objects, just string dataset type name and task label keys; 

116 # 

117 # - graph structure is represented as a pair of mappings, with methods to 

118 # find neighbors and edges of nodes. 

119 # 

120 ########################################################################### 

121 

122 def __init__( 

123 self, 

124 *, 

125 description: str = "", 

126 universe: DimensionUniverse | None = None, 

127 data_id: DataId | None = None, 

128 ) -> None: 

129 self._init_from_args( 

130 xgraph=None, 

131 sorted_keys=None, 

132 task_subsets=None, 

133 description=description, 

134 universe=universe, 

135 data_id=data_id, 

136 step_definitions=StepDefinitions(universe), 

137 ) 

138 

139 def __repr__(self) -> str: 

140 return f"{type(self).__name__}({self.description!r}, tasks={self.tasks!s})" 

141 

142 @property 

143 def description(self) -> str: 

144 """String description for this pipeline.""" 

145 return self._description 

146 

147 @description.setter 

148 def description(self, value: str) -> None: 

149 # Docstring in setter. 

150 self._description = value 

151 

152 @property 

153 def universe(self) -> DimensionUniverse: 

154 """Definitions for all butler dimensions.""" 

155 if self._universe is None: 

156 raise UnresolvedGraphError("Pipeline graph is not resolved.") 

157 return self._universe 

158 

159 @property 

160 def data_id(self) -> DataCoordinate: 

161 """Data ID that represents a constraint on all quanta generated from 

162 this pipeline. 

163 

164 This is may not be available unless the graph is resolved. 

165 """ 

166 return DataCoordinate.standardize(self._raw_data_id, universe=self.universe) 

167 

168 @property 

169 def tasks(self) -> TaskMappingView: 

170 """A mapping view of the tasks in the graph. 

171 

172 This mapping has `str` task label keys and `TaskNode` values. Iteration 

173 is topologically and deterministically ordered if and only if `sort` 

174 has been called since the last modification to the graph. 

175 """ 

176 return self._tasks 

177 

178 @property 

179 def dataset_types(self) -> DatasetTypeMappingView: 

180 """A mapping view of the dataset types in the graph. 

181 

182 This mapping has `str` parent dataset type name keys, but only provides 

183 access to its `DatasetTypeNode` values if `resolve` has been called 

184 since the last modification involving a task that uses a dataset type. 

185 See `DatasetTypeMappingView` for details. 

186 """ 

187 return self._dataset_types 

188 

189 @property 

190 def task_subsets(self) -> Mapping[str, TaskSubset]: 

191 """A mapping of all labeled subsets of tasks. 

192 

193 Keys are subset labels, values are sets of task labels. See 

194 `TaskSubset` for more information. 

195 

196 Use `add_task_subset` to add a new subset. The subsets themselves may 

197 be modified in-place. 

198 """ 

199 return self._task_subsets 

200 

201 @property 

202 def steps(self) -> StepDefinitions: 

203 """An ordered iterable of the labels of task subsets that must be 

204 executed separately. 

205 

206 Steps are intended to be partition of the full pipeline graph - the 

207 steps together include all tasks, and each task belongs to exactly one 

208 step - but this is only checked when the graph is resolved. 

209 

210 The order of the steps is required to be consistent with the graph's 

211 topological ordering, and when sorting a graph with steps, that sort 

212 order is guaranteed to be consistent with the order of the steps. But 

213 there may be other orderings of the steps that are still valid for 

214 execution (e.g. two steps could be runnable in parallel). 

215 """ 

216 return self._step_definitions 

217 

218 @steps.setter 

219 def steps(self, labels: Iterable[str]) -> None: 

220 # Docstring on getter. 

221 self._step_definitions.assign(labels) 

222 

223 def get_task_step(self, task_label: str) -> str: 

224 """Return the step that the given task belongs to. 

225 

226 Parameters 

227 ---------- 

228 task_label : `str` 

229 Label of the task to look up. 

230 

231 Returns 

232 ------- 

233 step_label : `str` 

234 Step the task belongs to. 

235 

236 Raises 

237 ------ 

238 InvalidStepsError 

239 Raised if the pipeline has no steps defined. 

240 UnresolvedGraphError 

241 Raised if the pipeline has been modified since the steps were 

242 last verified. 

243 """ 

244 if not self._step_definitions: 

245 raise InvalidStepsError("No steps have been defined for this pipeline.") 

246 elif self._step_definitions.verified: 

247 return self._xgraph.nodes[NodeKey(NodeType.TASK, task_label)]["step"] 

248 raise UnresolvedGraphError("Steps have not been verified since the last modification.") 

249 

250 @property 

251 def is_fully_resolved(self) -> bool: 

252 """Whether all of this graph's nodes are resolved and any all have 

253 been checked for correctness. 

254 

255 A fully-resolved graph is always sorted as well, and a fully-resolved 

256 graph with sorted 

257 """ 

258 return ( 

259 self._universe is not None 

260 and self._step_definitions.verified 

261 and self._sorted_keys is not None 

262 and all(self.dataset_types.is_resolved(k) for k in self.dataset_types) 

263 ) 

264 

265 @property 

266 def has_been_sorted(self) -> bool: 

267 """Whether this graph's tasks and dataset types have been 

268 topologically sorted (with unspecified but deterministic tiebreakers) 

269 since the last modification to the graph. 

270 

271 If the pipeline graph has step definitions, the sort order is 

272 consistent with the step order. 

273 

274 This may return `False` if the graph *happens* to be sorted but `sort` 

275 was never called,. 

276 """ 

277 return self._sorted_keys is not None 

278 

279 def sort(self) -> None: 

280 """Sort this graph's nodes topologically with deterministic (but 

281 unspecified) tiebreakers. 

282 

283 This does nothing if the graph is already known to be sorted. 

284 """ 

285 if self._sorted_keys is None: 

286 try: 

287 sorted_keys: Sequence[NodeKey] = list(networkx.lexicographical_topological_sort(self._xgraph)) 

288 except networkx.NetworkXUnfeasible as err: # pragma: no cover 

289 # Should't be possible to get here, because we check for cycles 

290 # when adding tasks, but we guard against it anyway. 

291 cycle = networkx.find_cycle(self._xgraph) 

292 raise PipelineDataCycleError( 

293 f"Cycle detected while attempting to sort graph: {cycle}." 

294 ) from err 

295 self._reorder(sorted_keys) 

296 

297 def copy(self) -> PipelineGraph: 

298 """Return a copy of this graph that copies all mutable state.""" 

299 xgraph = self._xgraph.copy() 

300 step_definitions = self._step_definitions.copy() 

301 result = PipelineGraph.__new__(PipelineGraph) 

302 result._init_from_args( 

303 xgraph, 

304 self._sorted_keys, 

305 task_subsets={ 

306 k: TaskSubset(xgraph, v.label, set(v._members), v.description, step_definitions) 

307 for k, v in self._task_subsets.items() 

308 }, 

309 description=self._description, 

310 universe=self._universe, 

311 data_id=self._raw_data_id, 

312 step_definitions=step_definitions, 

313 ) 

314 return result 

315 

316 def __copy__(self) -> PipelineGraph: 

317 # Fully shallow copies are dangerous; we don't want shared mutable 

318 # state to lead to broken class invariants. 

319 return self.copy() 

320 

321 def __deepcopy__(self, memo: dict) -> PipelineGraph: 

322 # Genuine deep copies are unnecessary, since we should only ever care 

323 # that mutable state is copied. 

324 return self.copy() 

325 

326 def diff_tasks(self, other: PipelineGraph) -> list[str]: 

327 """Compare two pipeline graphs. 

328 

329 This only compares graph structure and task classes (including their 

330 edges). It does *not* compare full configuration (which is subject to 

331 spurious differences due to import-cache state), dataset type 

332 resolutions, or sort state. 

333 

334 Parameters 

335 ---------- 

336 other : `PipelineGraph` 

337 Graph to compare to. 

338 

339 Returns 

340 ------- 

341 differences : `list` [ `str` ] 

342 List of string messages describing differences between the 

343 pipelines. If empty, the graphs have the same tasks and 

344 connections. 

345 """ 

346 messages: list[str] = [] 

347 common_labels: Set[str] 

348 if self.tasks.keys() != other.tasks.keys(): 

349 common_labels = self.tasks.keys() & other.tasks.keys() 

350 messages.append( 

351 f"Pipelines have different tasks: A & ~B = {list(self.tasks.keys() - common_labels)}, " 

352 f"B & ~A = {list(other.tasks.keys() - common_labels)}." 

353 ) 

354 else: 

355 common_labels = self.tasks.keys() 

356 for label in common_labels: 

357 a = self.tasks[label] 

358 b = other.tasks[label] 

359 if a.task_class != b.task_class: 

360 messages.append( 

361 f"Task {label!r} has class {a.task_class_name} in A, but {b.task_class_name} in B." 

362 ) 

363 messages.extend(a.diff_edges(b)) 

364 return messages 

365 

366 def producing_edge_of(self, dataset_type_name: str) -> WriteEdge | None: 

367 """Return the `WriteEdge` that links the producing task to the named 

368 dataset type. 

369 

370 Parameters 

371 ---------- 

372 dataset_type_name : `str` 

373 Dataset type name. Must not be a component. 

374 

375 Returns 

376 ------- 

377 edge : `WriteEdge` or `None` 

378 Producing edge or `None` if there isn't one in this graph. 

379 

380 Raises 

381 ------ 

382 DuplicateOutputError 

383 Raised if there are multiple tasks defined to produce this dataset 

384 type. This is only possible if the graph's dataset types are not 

385 resolved. 

386 

387 Notes 

388 ----- 

389 On resolved graphs, it may be slightly more efficient to use:: 

390 

391 graph.dataset_types[dataset_type_name].producing_edge 

392 

393 but this method works on graphs with unresolved dataset types as well. 

394 """ 

395 producer: str | None = None 

396 producing_edge: WriteEdge | None = None 

397 for _, _, producing_edge in self._xgraph.in_edges( 

398 NodeKey(NodeType.DATASET_TYPE, dataset_type_name), data="instance" 

399 ): 

400 assert producing_edge is not None, "Should only be None if we never loop." 

401 if producer is not None: 

402 raise DuplicateOutputError( 

403 f"Dataset type {dataset_type_name!r} is produced by both {producing_edge.task_label!r} " 

404 f"and {producer!r}." 

405 ) 

406 return producing_edge 

407 

408 def consuming_edges_of(self, dataset_type_name: str) -> list[ReadEdge]: 

409 """Return the `ReadEdge` objects that link the named dataset type to 

410 the tasks that consume it. 

411 

412 Parameters 

413 ---------- 

414 dataset_type_name : `str` 

415 Dataset type name. Must not be a component. 

416 

417 Returns 

418 ------- 

419 edges : `list` [ `ReadEdge` ] 

420 Edges that connect this dataset type to the tasks that consume it. 

421 

422 Notes 

423 ----- 

424 On resolved graphs, it may be slightly more efficient to use:: 

425 

426 graph.dataset_types[dataset_type_name].producing_edges 

427 

428 but this method works on graphs with unresolved dataset types as well. 

429 """ 

430 return [ 

431 edge 

432 for _, _, edge in self._xgraph.out_edges( 

433 NodeKey(NodeType.DATASET_TYPE, dataset_type_name), data="instance" 

434 ) 

435 ] 

436 

437 def producer_of(self, dataset_type_name: str) -> TaskNode | TaskInitNode | None: 

438 """Return the `TaskNode` or `TaskInitNode` that writes the given 

439 dataset type. 

440 

441 Parameters 

442 ---------- 

443 dataset_type_name : `str` 

444 Dataset type name. Must not be a component. 

445 

446 Returns 

447 ------- 

448 edge : `TaskNode`, `TaskInitNode`, or `None` 

449 Producing node or `None` if there isn't one in this graph. 

450 

451 Raises 

452 ------ 

453 DuplicateOutputError 

454 Raised if there are multiple tasks defined to produce this dataset 

455 type. This is only possible if the graph's dataset types are not 

456 resolved. 

457 """ 

458 if (producing_edge := self.producing_edge_of(dataset_type_name)) is not None: 

459 return self._xgraph.nodes[producing_edge.task_key]["instance"] 

460 return None 

461 

462 def consumers_of(self, dataset_type_name: str) -> list[TaskNode | TaskInitNode]: 

463 """Return the `TaskNode` and/or `TaskInitNode` objects that read 

464 the given dataset type. 

465 

466 Parameters 

467 ---------- 

468 dataset_type_name : `str` 

469 Dataset type name. Must not be a component. 

470 

471 Returns 

472 ------- 

473 edges : `list` [ `ReadEdge` ] 

474 Edges that connect this dataset type to the tasks that consume it. 

475 

476 Notes 

477 ----- 

478 On resolved graphs, it may be slightly more efficient to use:: 

479 

480 graph.dataset_types[dataset_type_name].producing_edges 

481 

482 but this method works on graphs with unresolved dataset types as well. 

483 """ 

484 return [ 

485 self._xgraph.nodes[consuming_edge.task_key]["instance"] 

486 for consuming_edge in self.consuming_edges_of(dataset_type_name) 

487 ] 

488 

489 def inputs_of(self, task_label: str, init: bool = False) -> dict[str, DatasetTypeNode | None]: 

490 """Return the dataset types that are inputs to a task. 

491 

492 Parameters 

493 ---------- 

494 task_label : `str` 

495 Label for the task in the pipeline. 

496 init : `bool`, optional 

497 If `True`, return init-input dataset types instead of runtime 

498 (including prerequisite) inputs. 

499 

500 Returns 

501 ------- 

502 inputs : `dict` [ `str`, `DatasetTypeNode` or `None` ] 

503 Dictionary parent dataset type name keys and either 

504 `DatasetTypeNode` values (if the dataset type has been resolved) 

505 or `None` values. 

506 

507 Notes 

508 ----- 

509 To get the input edges of a task or task init node (which provide 

510 information about storage class overrides nd components) use:: 

511 

512 graph.tasks[task_label].iter_all_inputs() 

513 

514 or 

515 

516 graph.tasks[task_label].init.iter_all_inputs() 

517 

518 or the various mapping attributes of the `TaskNode` and `TaskInitNode` 

519 class. 

520 """ 

521 node: TaskNode | TaskInitNode = self.tasks[task_label] if not init else self.tasks[task_label].init 

522 return { 

523 edge.parent_dataset_type_name: self._xgraph.nodes[edge.dataset_type_key]["instance"] 

524 for edge in node.iter_all_inputs() 

525 } 

526 

527 def outputs_of( 

528 self, task_label: str, init: bool = False, include_automatic_connections: bool = True 

529 ) -> dict[str, DatasetTypeNode | None]: 

530 """Return the dataset types that are outputs of a task. 

531 

532 Parameters 

533 ---------- 

534 task_label : `str` 

535 Label for the task in the pipeline. 

536 init : `bool`, optional 

537 If `True`, return init-output dataset types instead of runtime 

538 outputs. 

539 include_automatic_connections : `bool`, optional 

540 Whether to include automatic connections such as configs, metadata, 

541 and logs. 

542 

543 Returns 

544 ------- 

545 outputs : `dict` [ `str`, `DatasetTypeNode` or `None` ] 

546 Dictionary parent dataset type name keys and either 

547 `DatasetTypeNode` values (if the dataset type has been resolved) 

548 or `None` values. 

549 

550 Notes 

551 ----- 

552 To get the input edges of a task or task init node (which provide 

553 information about storage class overrides nd components) use:: 

554 

555 graph.tasks[task_label].iter_all_outputs() 

556 

557 or 

558 

559 graph.tasks[task_label].init.iter_all_outputs() 

560 

561 or the various mapping attributes of the `TaskNode` and `TaskInitNode` 

562 class. 

563 """ 

564 node: TaskNode | TaskInitNode = self.tasks[task_label] if not init else self.tasks[task_label].init 

565 iterable = node.iter_all_outputs() if include_automatic_connections else node.outputs.values() 

566 return { 

567 edge.parent_dataset_type_name: self._xgraph.nodes[edge.dataset_type_key]["instance"] 

568 for edge in iterable 

569 } 

570 

571 def resolve( 

572 self, 

573 registry: Registry | None = None, 

574 dimensions: DimensionUniverse | None = None, 

575 dataset_types: Mapping[str, DatasetType] | None = None, 

576 visualization_only: bool = False, 

577 ) -> None: 

578 """Resolve all dimensions and dataset types and check them for 

579 consistency. 

580 

581 Resolving a graph also causes it to be sorted. 

582 

583 Parameters 

584 ---------- 

585 registry : `lsst.daf.butler.Registry`, optional 

586 Client for the data repository to resolve against. 

587 dimensions : `lsst.daf.butler.DimensionUniverse`, optional 

588 Definitions for all dimensions. Takes precedence over 

589 ``registry.dimensions`` if both are provided. If neither is 

590 provided, defaults to the default dimension universe 

591 (``lsst.daf.butler.DimensionUniverse()``). 

592 dataset_types : `~collection.abc.Mapping` [ `str`, \ 

593 `~lsst.daf.butler.DatasetType` ], optional 

594 Mapping of dataset types to consider registered. Takes precedence 

595 over ``registry.getDatasetType()`` if both are provided. 

596 visualization_only : `bool`, optional 

597 Resolve the graph as well as possible even when dimensions and 

598 storage classes cannot really be determined. This can include 

599 using the ``universe.commonSkyPix`` as the assumed dimensions of 

600 connections that use the "skypix" placeholder and using "<UNKNOWN>" 

601 as a storage class name (which will fail if the storage class 

602 itself is ever actually loaded). 

603 

604 Notes 

605 ----- 

606 The `universe` attribute is set to ``dimensions`` and used to set all 

607 `TaskNode.dimensions` attributes. Dataset type nodes are resolved by 

608 first looking for a registry definition, then using the producing 

609 task's definition, then looking for consistency between all consuming 

610 task definitions. 

611 

612 Raises 

613 ------ 

614 ConnectionTypeConsistencyError 

615 Raised if a prerequisite input for one task appears as a different 

616 kind of connection in any other task. 

617 DuplicateOutputError 

618 Raised if multiple tasks have the same dataset type as an output. 

619 IncompatibleDatasetTypeError 

620 Raised if different tasks have different definitions of a dataset 

621 type. Different but compatible storage classes are permitted. 

622 MissingDatasetTypeError 

623 Raised if a dataset type definition is required to exist in the 

624 data repository but none was found. This should only occur for 

625 dataset types that are not produced by a task in the pipeline and 

626 are consumed with different storage classes or as components by 

627 tasks in the pipeline. 

628 EdgesChangedError 

629 Raised if ``check_edges_unchanged=True`` and the edges of a task do 

630 change after import and reconfiguration. 

631 """ 

632 get_registered: Callable[[str], DatasetType | None] | None = None 

633 if dataset_types is not None: 

634 # Ruff seems confused about whether this is used below; it is! 

635 get_registered = dataset_types.get 

636 elif registry is not None: 

637 

638 def get_registered(name: str) -> DatasetType | None: 

639 try: 

640 return registry.getDatasetType(name) 

641 except MissingDatasetTypeError: 

642 return None 

643 

644 else: 

645 

646 def get_registered(name: str) -> None: 

647 return None 

648 

649 if dimensions is None: 

650 if registry is not None: 

651 dimensions = registry.dimensions 

652 else: 

653 dimensions = DimensionUniverse() 

654 

655 sort_keys_from_steps = self._resolve_step_flow() 

656 

657 node_key: NodeKey 

658 updates: dict[NodeKey, TaskNode | DatasetTypeNode] = {} 

659 for node_key, node_state in self._xgraph.nodes.items(): 

660 match node_key.node_type: 

661 case NodeType.TASK: 

662 task_node: TaskNode = node_state["instance"] 

663 new_task_node = task_node._resolved(dimensions) 

664 if new_task_node is not task_node: 

665 updates[node_key] = new_task_node 

666 case NodeType.DATASET_TYPE: 

667 dataset_type_node: DatasetTypeNode | None = node_state["instance"] 

668 new_dataset_type_node = DatasetTypeNode._from_edges( 

669 node_key, 

670 self._xgraph, 

671 get_registered, 

672 dimensions, 

673 previous=dataset_type_node, 

674 visualization_only=visualization_only, 

675 ) 

676 # Usage of `is`` here is intentional; `_from_edges` returns 

677 # `previous=dataset_type_node` if it can determine that it 

678 # doesn't need to change. 

679 if new_dataset_type_node is not dataset_type_node: 

680 updates[node_key] = new_dataset_type_node 

681 try: 

682 for node_key, node_value in updates.items(): 

683 self._xgraph.nodes[node_key]["instance"] = node_value 

684 except Exception as err: # pragma: no cover 

685 # There's no known way to get here, but we want to make it 

686 # clear it's a big problem if we do. 

687 raise PipelineGraphExceptionSafetyError( 

688 "Error during dataset type resolution has left the graph in an inconsistent state." 

689 ) from err 

690 

691 # If we get an error here, many graph nodes will have been resolved but 

692 # the steps will still be marked as unverified and unsorted. That's 

693 # still an acceptable state for the pipeline to be in. 

694 self._resolve_step_dimensions(dimensions) 

695 self._step_definitions._verified = True 

696 

697 if sort_keys_from_steps is not None: 

698 self._reorder(sort_keys_from_steps) 

699 else: 

700 self.sort() 

701 self._universe = dimensions 

702 

703 ########################################################################### 

704 # 

705 # Graph Modification Interface: 

706 # 

707 # - methods to add, remove, and replace tasks; 

708 # 

709 # - methods to add and remove task subsets. 

710 # 

711 # These are all things that are usually done in a Pipeline before making a 

712 # graph at all, but there may be cases where we want to modify the graph 

713 # instead. (These are also the methods used to make a graph from a 

714 # Pipeline, or make a graph from another graph.) 

715 # 

716 ########################################################################### 

717 

718 def add_task( 

719 self, 

720 label: str | None, 

721 task_class: type[PipelineTask], 

722 config: PipelineTaskConfig | None = None, 

723 connections: PipelineTaskConnections | None = None, 

724 ) -> TaskNode: 

725 """Add a new task to the graph. 

726 

727 Parameters 

728 ---------- 

729 label : `str` or `None` 

730 Label for the task in the pipeline. If `None`, `Task._DefaultName` 

731 is used. 

732 task_class : `type` [ `PipelineTask` ] 

733 Class object for the task. 

734 config : `PipelineTaskConfig`, optional 

735 Configuration for the task. If not provided, a default-constructed 

736 instance of ``task_class.ConfigClass`` is used. 

737 connections : `PipelineTaskConnections`, optional 

738 Object that describes the dataset types used by the task. If not 

739 provided, one will be constructed from the given configuration. If 

740 provided, it is assumed that ``config`` has already been validated 

741 and frozen. 

742 

743 Returns 

744 ------- 

745 node : `TaskNode` 

746 The new task node added to the graph. 

747 

748 Raises 

749 ------ 

750 ValueError 

751 Raised if configuration validation failed when constructing 

752 ``connections``. 

753 PipelineDataCycleError 

754 Raised if the graph is cyclic after this addition. 

755 RuntimeError 

756 Raised if an unexpected exception (which will be chained) occurred 

757 at a stage that may have left the graph in an inconsistent state. 

758 Other exceptions should leave the graph unchanged. 

759 

760 Notes 

761 ----- 

762 Checks for dataset type consistency and multiple producers do not occur 

763 until `resolve` is called, since the resolution depends on both the 

764 state of the data repository and all contributing tasks. 

765 

766 Adding new tasks removes any existing resolutions of all dataset types 

767 it references and marks the graph as unsorted. It is most efficient 

768 to add all tasks up front and only then resolve and/or sort the graph. 

769 """ 

770 if label is None: 

771 label = task_class._DefaultName 

772 if config is None: 

773 config = task_class.ConfigClass() 

774 _LOG.debug("Adding task %s %s to the pipeline graph", label, task_class) 

775 task_node = TaskNode._from_imported_data( 

776 key=NodeKey(NodeType.TASK, label), 

777 init_key=NodeKey(NodeType.TASK_INIT, label), 

778 data=_TaskNodeImportedData.configure(label, task_class, config, connections), 

779 universe=self._universe, 

780 ) 

781 self.add_task_nodes([task_node]) 

782 return task_node 

783 

784 def add_task_nodes(self, nodes: Iterable[TaskNode], parent: PipelineGraph | None = None) -> None: 

785 """Add one or more existing task nodes to the graph. 

786 

787 Parameters 

788 ---------- 

789 nodes : `~collections.abc.Iterable` [ `TaskNode` ] 

790 Iterable of task nodes to add. If any tasks have resolved 

791 dimensions, they must have the same dimension universe as the rest 

792 of the graph. 

793 parent : `PipelineGraph`, optional 

794 If provided, another `PipelineGraph` from which these nodes were 

795 obtained. Any dataset type nodes already present in ``parent`` 

796 that are referenced by the given tasks will be used in this graph 

797 if they are not already present, preserving any dataset type 

798 resolutions present in the parent graph. Adding nodes from a 

799 parent graph after the graph has its own nodes (e.g. from 

800 `add_task`) or nodes from a third graph may result in invalid 

801 dataset type resolutions. It is safest to only use this argument 

802 when populating an empty graph for the first time. 

803 

804 Raises 

805 ------ 

806 PipelineDataCycleError 

807 Raised if the graph is cyclic after this addition. 

808 

809 Notes 

810 ----- 

811 Checks for dataset type consistency and multiple producers do not occur 

812 until `resolve` is called, since the resolution depends on both the 

813 state of the data repository and all contributing tasks. 

814 

815 Adding new tasks removes any existing resolutions of all dataset types 

816 it references (unless ``parent is not None`` and marks the graph as 

817 unsorted. It is most efficient to add all tasks up front and only then 

818 resolve and/or sort the graph. 

819 """ 

820 node_data: list[tuple[NodeKey, dict[str, Any]]] = [] 

821 edge_data: list[tuple[NodeKey, NodeKey, str, dict[str, Any]]] = [] 

822 for task_node in nodes: 

823 task_node = task_node._resolved(self._universe) 

824 node_data.append( 

825 (task_node.key, {"instance": task_node, "bipartite": task_node.key.node_type.bipartite}) 

826 ) 

827 node_data.append( 

828 ( 

829 task_node.init.key, 

830 {"instance": task_node.init, "bipartite": task_node.init.key.node_type.bipartite}, 

831 ) 

832 ) 

833 # Convert the edge objects attached to the task node to networkx. 

834 for read_edge in task_node.init.iter_all_inputs(): 

835 self._append_graph_data_from_edge(node_data, edge_data, read_edge, parent=parent) 

836 for write_edge in task_node.init.iter_all_outputs(): 

837 self._append_graph_data_from_edge(node_data, edge_data, write_edge, parent=parent) 

838 for read_edge in task_node.iter_all_inputs(): 

839 self._append_graph_data_from_edge(node_data, edge_data, read_edge, parent=parent) 

840 for write_edge in task_node.iter_all_outputs(): 

841 self._append_graph_data_from_edge(node_data, edge_data, write_edge, parent=parent) 

842 # Add a special edge (with no Edge instance) that connects the 

843 # TaskInitNode to the runtime TaskNode. 

844 edge_data.append((task_node.init.key, task_node.key, Edge.INIT_TO_TASK_NAME, {"instance": None})) 

845 if not node_data and not edge_data: 

846 return 

847 # Checks and preparation complete; time to start the actual 

848 # modification, during which it's hard to provide strong exception 

849 # safety. Start by resetting the sort ordering, if there is one. 

850 self._reset() 

851 try: 

852 self._xgraph.add_nodes_from(node_data) 

853 self._xgraph.add_edges_from(edge_data) 

854 if not networkx.algorithms.dag.is_directed_acyclic_graph(self._xgraph): 

855 cycle = networkx.find_cycle(self._xgraph) 

856 raise PipelineDataCycleError(f"Cycle detected while adding tasks: {cycle}.") 

857 except Exception: 

858 # First try to roll back our changes. 

859 try: 

860 self._xgraph.remove_edges_from(edge_data) 

861 self._xgraph.remove_nodes_from(key for key, _ in node_data) 

862 except Exception as err: # pragma: no cover 

863 # There's no known way to get here, but we want to make it 

864 # clear it's a big problem if we do. 

865 raise PipelineGraphExceptionSafetyError( 

866 "Error while attempting to revert PipelineGraph modification has left the graph in " 

867 "an inconsistent state." 

868 ) from err 

869 # Successfully rolled back; raise the original exception. 

870 raise 

871 

872 def reconfigure_tasks( 

873 self, 

874 *args: tuple[str, PipelineTaskConfig], 

875 check_edges_unchanged: bool = False, 

876 assume_edges_unchanged: bool = False, 

877 **kwargs: PipelineTaskConfig, 

878 ) -> None: 

879 """Update the configuration for one or more tasks. 

880 

881 Parameters 

882 ---------- 

883 *args : `tuple` [ `str`, `.PipelineTaskConfig` ] 

884 Positional arguments are each a 2-tuple of task label and new 

885 config object. Note that the same arguments may also be passed as 

886 ``**kwargs``, which is usually more readable, but task labels in 

887 ``*args`` are not required to be valid Python identifiers. 

888 check_edges_unchanged : `bool`, optional 

889 If `True`, require the edges (connections) of the modified tasks to 

890 remain unchanged after the configuration updates, and verify that 

891 this is the case. 

892 assume_edges_unchanged : `bool`, optional 

893 If `True`, the caller declares that the edges (connections) of the 

894 modified tasks will remain unchanged after the configuration 

895 updates, and that it is unnecessary to check this. 

896 **kwargs : `.PipelineTaskConfig` 

897 New config objects or overrides to apply to copies of the current 

898 config objects, with task labels as the keywords. 

899 

900 Raises 

901 ------ 

902 ValueError 

903 Raised if ``assume_edges_unchanged`` and ``check_edges_unchanged`` 

904 are both `True`, or if the same task appears twice. 

905 EdgesChangedError 

906 Raised if ``check_edges_unchanged=True`` and the edges of a task do 

907 change. 

908 

909 Notes 

910 ----- 

911 If reconfiguring a task causes its edges to change, any dataset type 

912 nodes connected to that task (not just those whose edges have changed!) 

913 will be unresolved. 

914 """ 

915 new_configs: dict[str, PipelineTaskConfig] = {} 

916 for task_label, config_update in itertools.chain(args, kwargs.items()): 

917 if new_configs.setdefault(task_label, config_update) is not config_update: 

918 raise ValueError(f"Config for {task_label!r} provided more than once.") 

919 updates = { 

920 task_label: self.tasks[task_label]._reconfigured(config, rebuild=not assume_edges_unchanged) 

921 for task_label, config in new_configs.items() 

922 } 

923 self._replace_task_nodes( 

924 updates, 

925 check_edges_unchanged=check_edges_unchanged, 

926 assume_edges_unchanged=assume_edges_unchanged, 

927 message_header=( 

928 "Unexpected change in edges for task {task_label!r} from original config (A) to " 

929 "new configs (B):" 

930 ), 

931 ) 

932 

933 def remove_tasks( 

934 self, labels: Iterable[str], drop_from_subsets: bool = True 

935 ) -> list[tuple[TaskNode, set[str]]]: 

936 """Remove one or more tasks from the graph. 

937 

938 Parameters 

939 ---------- 

940 labels : `~collections.abc.Iterable` [ `str` ] 

941 Iterable of the labels of the tasks to remove. 

942 drop_from_subsets : `bool`, optional 

943 If `True`, drop each removed task from any subset in which it 

944 currently appears. If `False`, raise `PipelineGraphError` if any 

945 such subsets exist. 

946 

947 Returns 

948 ------- 

949 nodes_and_subsets : `list` [ `tuple` [ `TaskNode`, `set` [ `str` ] ] ] 

950 List of nodes removed and the labels of task subsets that 

951 referenced them. 

952 

953 Raises 

954 ------ 

955 PipelineGraphError 

956 Raised if ``drop_from_subsets`` is `False` and the task is still 

957 part of one or more subsets. 

958 

959 Notes 

960 ----- 

961 Removing a task will cause dataset nodes with no other referencing 

962 tasks to be removed. Any other dataset type nodes referenced by a 

963 removed task will be reset to an "unresolved" state. 

964 """ 

965 task_nodes_and_subsets = [] 

966 dataset_types: set[NodeKey] = set() 

967 nodes_to_remove = set() 

968 for label in labels: 

969 task_node: TaskNode = self._xgraph.nodes[NodeKey(NodeType.TASK, label)]["instance"] 

970 # Find task subsets that reference this task. 

971 referencing_subsets = { 

972 subset_label 

973 for subset_label, task_subset in self.task_subsets.items() 

974 if label in task_subset 

975 } 

976 if not drop_from_subsets and referencing_subsets: 

977 raise PipelineGraphError( 

978 f"Task {label!r} is still referenced by subset(s) {referencing_subsets}." 

979 ) 

980 task_nodes_and_subsets.append((task_node, referencing_subsets)) 

981 # Find dataset types referenced by this task. 

982 dataset_types.update(self._xgraph.predecessors(task_node.key)) 

983 dataset_types.update(self._xgraph.successors(task_node.key)) 

984 dataset_types.update(self._xgraph.predecessors(task_node.init.key)) 

985 dataset_types.update(self._xgraph.successors(task_node.init.key)) 

986 # Since there's an edge between the task and its init node, we'll 

987 # have added those two nodes here, too, and we don't want that. 

988 dataset_types.remove(task_node.init.key) 

989 dataset_types.remove(task_node.key) 

990 # Mark the task node and its init node for removal from the graph. 

991 nodes_to_remove.add(task_node.key) 

992 nodes_to_remove.add(task_node.init.key) 

993 # Process the referenced datasets to see which ones are orphaned and 

994 # need to be removed vs. just unresolved. 

995 nodes_to_unresolve = [] 

996 for dataset_type_key in dataset_types: 

997 related_tasks = set() 

998 related_tasks.update(self._xgraph.predecessors(dataset_type_key)) 

999 related_tasks.update(self._xgraph.successors(dataset_type_key)) 

1000 related_tasks.difference_update(nodes_to_remove) 

1001 if not related_tasks: 

1002 nodes_to_remove.add(dataset_type_key) 

1003 else: 

1004 nodes_to_unresolve.append(dataset_type_key) 

1005 # Checks and preparation complete; time to start the actual 

1006 # modification, during which it's hard to provide strong exception 

1007 # safety. Start by resetting the sort ordering. 

1008 self._reset() 

1009 try: 

1010 for dataset_type_key in nodes_to_unresolve: 

1011 self._xgraph.nodes[dataset_type_key]["instance"] = None 

1012 for task_node, referencing_subsets in task_nodes_and_subsets: 

1013 for subset_label in referencing_subsets: 

1014 self._task_subsets[subset_label].remove(task_node.label) 

1015 self._xgraph.remove_nodes_from(nodes_to_remove) 

1016 except Exception as err: # pragma: no cover 

1017 # There's no known way to get here, but we want to make it 

1018 # clear it's a big problem if we do. 

1019 raise PipelineGraphExceptionSafetyError( 

1020 "Error during task removal has left the graph in an inconsistent state." 

1021 ) from err 

1022 return task_nodes_and_subsets 

1023 

1024 def add_task_subset(self, subset_label: str, task_labels: Iterable[str], description: str = "") -> None: 

1025 """Add a label for a set of tasks that are already in the pipeline. 

1026 

1027 Parameters 

1028 ---------- 

1029 subset_label : `str` 

1030 Label for this set of tasks. 

1031 task_labels : `~collections.abc.Iterable` [ `str` ] 

1032 Labels of the tasks to include in the set. All must already be 

1033 included in the graph. 

1034 description : `str`, optional 

1035 String description to associate with this label. 

1036 """ 

1037 subset = TaskSubset(self._xgraph, subset_label, set(task_labels), description, self._step_definitions) 

1038 self._task_subsets[subset_label] = subset 

1039 

1040 def remove_task_subset(self, subset_label: str) -> None: 

1041 """Remove a labeled set of tasks. 

1042 

1043 Parameters 

1044 ---------- 

1045 subset_label : `str` 

1046 Label for this set of tasks. 

1047 

1048 Notes 

1049 ----- 

1050 If this subset is a step, it is also removed from the step definitions. 

1051 """ 

1052 try: 

1053 self._step_definitions.remove(subset_label) 

1054 except KeyError: 

1055 pass 

1056 del self._task_subsets[subset_label] 

1057 

1058 ########################################################################### 

1059 # 

1060 # NetworkX Export Interface: 

1061 # 

1062 # - methods to export the PipelineGraph's content (or various subsets 

1063 # thereof) as NetworkX objects. 

1064 # 

1065 # These are particularly useful when writing tools to visualize the graph, 

1066 # while providing options for which aspects of the graph (tasks, dataset 

1067 # types, or both) to include, since all exported graphs have similar 

1068 # attributes regardless of their structure. 

1069 # 

1070 ########################################################################### 

1071 

1072 def make_xgraph(self) -> networkx.MultiDiGraph: 

1073 """Export a networkx representation of the full pipeline graph, 

1074 including both init and runtime edges. 

1075 

1076 Returns 

1077 ------- 

1078 xgraph : `networkx.MultiDiGraph` 

1079 Directed acyclic graph with parallel edges. 

1080 

1081 Notes 

1082 ----- 

1083 The returned graph uses `NodeKey` instances for nodes. Parallel edges 

1084 represent the same dataset type appearing in multiple connections for 

1085 the same task, and are hence rare. The connection name is used as the 

1086 edge key to disambiguate those parallel edges. 

1087 

1088 Almost all edges connect dataset type nodes to task or task init nodes 

1089 or vice versa, but there is also a special edge that connects each task 

1090 init node to its runtime node. The existence of these edges makes the 

1091 graph not quite bipartite, though its init-only and runtime-only 

1092 subgraphs are bipartite. 

1093 

1094 See `TaskNode`, `TaskInitNode`, `DatasetTypeNode`, `ReadEdge`, and 

1095 `WriteEdge` for the descriptive node and edge attributes added. 

1096 """ 

1097 return self._transform_xgraph_state(self._xgraph.copy(), skip_edges=False) 

1098 

1099 def make_bipartite_xgraph(self, init: bool = False) -> networkx.MultiDiGraph: 

1100 """Return a bipartite networkx representation of just the runtime or 

1101 init-time pipeline graph. 

1102 

1103 Parameters 

1104 ---------- 

1105 init : `bool`, optional 

1106 If `True` (`False` is default) return the graph of task 

1107 initialization nodes and init input/output dataset types, instead 

1108 of the graph of runtime task nodes and regular 

1109 input/output/prerequisite dataset types. 

1110 

1111 Returns 

1112 ------- 

1113 xgraph : `networkx.MultiDiGraph` 

1114 Directed acyclic graph with parallel edges. 

1115 

1116 Notes 

1117 ----- 

1118 The returned graph uses `NodeKey` instances for nodes. Parallel edges 

1119 represent the same dataset type appearing in multiple connections for 

1120 the same task, and are hence rare. The connection name is used as the 

1121 edge key to disambiguate those parallel edges. 

1122 

1123 This graph is bipartite because each dataset type node only has edges 

1124 that connect it to a task [init] node, and vice versa. 

1125 

1126 See `TaskNode`, `TaskInitNode`, `DatasetTypeNode`, `ReadEdge`, and 

1127 `WriteEdge` for the descriptive node and edge attributes added. 

1128 """ 

1129 return self._transform_xgraph_state( 

1130 self._make_bipartite_xgraph_internal(init).copy(), skip_edges=False 

1131 ) 

1132 

1133 def make_task_xgraph(self, init: bool = False) -> networkx.DiGraph: 

1134 """Return a networkx representation of just the tasks in the pipeline. 

1135 

1136 Parameters 

1137 ---------- 

1138 init : `bool`, optional 

1139 If `True` (`False` is default) return the graph of task 

1140 initialization nodes, instead of the graph of runtime task nodes. 

1141 

1142 Returns 

1143 ------- 

1144 xgraph : `networkx.DiGraph` 

1145 Directed acyclic graph with no parallel edges. 

1146 

1147 Notes 

1148 ----- 

1149 The returned graph uses `NodeKey` instances for nodes. The dataset 

1150 types that link these tasks are not represented at all; edges have no 

1151 attributes, and there are no parallel edges. 

1152 

1153 See `TaskNode` and `TaskInitNode` for the descriptive node and 

1154 attributes added. 

1155 """ 

1156 return self._transform_xgraph_state(self._make_task_xgraph_internal(init), skip_edges=True) 

1157 

1158 def make_dataset_type_xgraph(self, init: bool = False) -> networkx.DiGraph: 

1159 """Return a networkx representation of just the dataset types in the 

1160 pipeline. 

1161 

1162 Parameters 

1163 ---------- 

1164 init : `bool`, optional 

1165 If `True` (`False` is default) return the graph of init input and 

1166 output dataset types, instead of the graph of runtime (input, 

1167 output, prerequisite input) dataset types. 

1168 

1169 Returns 

1170 ------- 

1171 xgraph : `networkx.DiGraph` 

1172 Directed acyclic graph with no parallel edges. 

1173 

1174 Notes 

1175 ----- 

1176 The returned graph uses `NodeKey` instances for nodes. The tasks that 

1177 link these tasks are not represented at all; edges have no attributes, 

1178 and there are no parallel edges. 

1179 

1180 See `DatasetTypeNode` for the descriptive node and attributes added. 

1181 """ 

1182 bipartite_xgraph = self._make_bipartite_xgraph_internal(init) 

1183 dataset_type_keys = [ 

1184 key 

1185 for key, bipartite in bipartite_xgraph.nodes(data="bipartite") 

1186 if bipartite == NodeType.DATASET_TYPE.bipartite 

1187 ] 

1188 return self._transform_xgraph_state( 

1189 networkx.algorithms.bipartite.projected_graph( 

1190 networkx.DiGraph(bipartite_xgraph), dataset_type_keys 

1191 ), 

1192 skip_edges=True, 

1193 ) 

1194 

1195 ########################################################################### 

1196 # 

1197 # Expression-based Selection Interface. 

1198 # 

1199 ########################################################################### 

1200 

1201 def select_tasks(self, expression: str) -> set[str]: 

1202 """Return the tasks that match an expression. 

1203 

1204 Parameters 

1205 ---------- 

1206 expression : `str` 

1207 String expression to evaluate. See 

1208 :ref:`pipeline-graph-subset-expressions`. 

1209 

1210 Returns 

1211 ------- 

1212 task_labels : `set` [ `str` ] 

1213 Set of matching task labels. 

1214 """ 

1215 task_xgraph = self._make_task_xgraph_internal(init=False) 

1216 expr_tree = expressions.parse(expression) 

1217 matching_task_keys = self._select_expression(expr_tree, task_xgraph) 

1218 return {key.name for key in matching_task_keys} 

1219 

1220 def select(self, expression: str) -> PipelineGraph: 

1221 """Return a new pipeline graph with the tasks that match an expression. 

1222 

1223 Parameters 

1224 ---------- 

1225 expression : `str` 

1226 String expression to evaluate. See 

1227 :ref:`pipeline-graph-subset-expressions`. 

1228 

1229 Returns 

1230 ------- 

1231 new_graph : `PipelineGraph` 

1232 New pipeline graph with just the matching tasks. 

1233 

1234 Notes 

1235 ----- 

1236 All resolved dataset type nodes will be preserved. 

1237 

1238 If `has_been_sorted`, the new graph will be sorted as well. 

1239 

1240 Task subsets will not be included in the returned graph. 

1241 """ 

1242 selected_tasks = self.select_tasks(expression) 

1243 new_pipeline_graph = PipelineGraph(universe=self._universe, data_id=self._raw_data_id) 

1244 new_pipeline_graph.add_task_nodes( 

1245 [self.tasks[task_label] for task_label in selected_tasks], parent=self 

1246 ) 

1247 if self.has_been_sorted: 

1248 new_pipeline_graph.sort() 

1249 return new_pipeline_graph 

1250 

1251 ########################################################################### 

1252 # 

1253 # Serialization Interface. 

1254 # 

1255 # Serialization of PipelineGraphs is currently experimental and may not be 

1256 # retained in the future. All serialization methods are 

1257 # underscore-prefixed to ensure nobody mistakes them for a stable interface 

1258 # (let a lone a stable file format). 

1259 # 

1260 ########################################################################### 

1261 

1262 @classmethod 

1263 def _read_stream( 

1264 cls, stream: BinaryIO, import_mode: TaskImportMode = TaskImportMode.REQUIRE_CONSISTENT_EDGES 

1265 ) -> PipelineGraph: 

1266 """Read a serialized `PipelineGraph` from a file-like object. 

1267 

1268 Parameters 

1269 ---------- 

1270 stream : `BinaryIO` 

1271 File-like object opened for binary reading, containing 

1272 gzip-compressed JSON. 

1273 import_mode : `TaskImportMode`, optional 

1274 Whether to import tasks, and how to reconcile any differences 

1275 between the imported task's connections and the those that were 

1276 persisted with the graph. Default is to check that they are the 

1277 same. 

1278 

1279 Returns 

1280 ------- 

1281 graph : `PipelineGraph` 

1282 Deserialized pipeline graph. 

1283 

1284 Raises 

1285 ------ 

1286 PipelineGraphReadError 

1287 Raised if the serialized `PipelineGraph` is not self-consistent. 

1288 EdgesChangedError 

1289 Raised if ``import_mode`` is 

1290 `TaskImportMode.REQUIRED_CONSISTENT_EDGES` and the edges of a task 

1291 did change after import and reconfiguration. 

1292 

1293 Notes 

1294 ----- 

1295 `PipelineGraph` serialization is currently experimental and may be 

1296 removed or significantly changed in the future, with no deprecation 

1297 period. 

1298 """ 

1299 from .io import SerializedPipelineGraph 

1300 

1301 with gzip.open(stream, "rb") as uncompressed_stream: 

1302 data = json.load(uncompressed_stream) 

1303 serialized_graph = SerializedPipelineGraph.model_validate(data) 

1304 return serialized_graph.deserialize(import_mode) 

1305 

1306 @classmethod 

1307 def _read_uri( 

1308 cls, 

1309 uri: ResourcePathExpression, 

1310 import_mode: TaskImportMode = TaskImportMode.REQUIRE_CONSISTENT_EDGES, 

1311 ) -> PipelineGraph: 

1312 """Read a serialized `PipelineGraph` from a file at a URI. 

1313 

1314 Parameters 

1315 ---------- 

1316 uri : convertible to `lsst.resources.ResourcePath` 

1317 URI to a gzip-compressed JSON file containing a serialized pipeline 

1318 graph. 

1319 import_mode : `TaskImportMode`, optional 

1320 Whether to import tasks, and how to reconcile any differences 

1321 between the imported task's connections and the those that were 

1322 persisted with the graph. Default is to check that they are the 

1323 same. 

1324 

1325 Returns 

1326 ------- 

1327 graph : `PipelineGraph` 

1328 Deserialized pipeline graph. 

1329 

1330 Raises 

1331 ------ 

1332 PipelineGraphReadError 

1333 Raised if the serialized `PipelineGraph` is not self-consistent. 

1334 EdgesChangedError 

1335 Raised if ``import_mode`` is 

1336 `TaskImportMode.REQUIRED_CONSISTENT_EDGES` and the edges of a task 

1337 did change after import and reconfiguration. 

1338 

1339 Notes 

1340 ----- 

1341 `PipelineGraph` serialization is currently experimental and may be 

1342 removed or significantly changed in the future, with no deprecation 

1343 period. 

1344 """ 

1345 uri = ResourcePath(uri) 

1346 with uri.open("rb") as stream: 

1347 return cls._read_stream(cast(BinaryIO, stream), import_mode=import_mode) 

1348 

1349 def _write_stream(self, stream: BinaryIO) -> None: 

1350 """Write the pipeline to a file-like object. 

1351 

1352 Parameters 

1353 ---------- 

1354 stream 

1355 File-like object opened for binary writing. 

1356 

1357 Notes 

1358 ----- 

1359 `PipelineGraph` serialization is currently experimental and may be 

1360 removed or significantly changed in the future, with no deprecation 

1361 period. 

1362 

1363 The file format is gzipped JSON, and is intended to be human-readable, 

1364 but it should not be considered a stable public interface for outside 

1365 code, which should always use `PipelineGraph` methods (or at least the 

1366 `io.SerializedPipelineGraph` class) to read these files. 

1367 """ 

1368 from .io import SerializedPipelineGraph 

1369 

1370 with gzip.open(stream, mode="wb") as compressed_stream: 

1371 compressed_stream.write( 

1372 SerializedPipelineGraph.serialize(self).model_dump_json(exclude_defaults=True).encode("utf-8") 

1373 ) 

1374 

1375 def _write_uri(self, uri: ResourcePathExpression) -> None: 

1376 """Write the pipeline to a file given a URI. 

1377 

1378 Parameters 

1379 ---------- 

1380 uri : convertible to `lsst.resources.ResourcePath` 

1381 URI to write to . May have ``.json.gz`` or no extension (which 

1382 will cause a ``.json.gz`` extension to be added). 

1383 

1384 Notes 

1385 ----- 

1386 `PipelineGraph` serialization is currently experimental and may be 

1387 removed or significantly changed in the future, with no deprecation 

1388 period. 

1389 

1390 The file format is gzipped JSON, and is intended to be human-readable, 

1391 but it should not be considered a stable public interface for outside 

1392 code, which should always use `PipelineGraph` methods (or at least the 

1393 `io.SerializedPipelineGraph` class) to read these files. 

1394 """ 

1395 uri = ResourcePath(uri) 

1396 extension = uri.getExtension() 

1397 if not extension: 

1398 uri = uri.updatedExtension(".json.gz") 

1399 elif extension != ".json.gz": 

1400 raise ValueError("Expanded pipeline files should always have a .json.gz extension.") 

1401 with uri.open(mode="wb") as stream: 

1402 self._write_stream(cast(BinaryIO, stream)) 

1403 

1404 def _import_and_configure( 

1405 self, import_mode: TaskImportMode = TaskImportMode.REQUIRE_CONSISTENT_EDGES 

1406 ) -> None: 

1407 """Import the `PipelineTask` classes referenced by all task nodes and 

1408 update those nodes accordingly. 

1409 

1410 Parameters 

1411 ---------- 

1412 import_mode : `TaskImportMode`, optional 

1413 Whether to import tasks, and how to reconcile any differences 

1414 between the imported task's connections and the those that were 

1415 persisted with the graph. Default is to check that they are the 

1416 same. This method does nothing if this is 

1417 `TaskImportMode.DO_NOT_IMPORT`. 

1418 

1419 Raises 

1420 ------ 

1421 EdgesChangedError 

1422 Raised if ``import_mode`` is 

1423 `TaskImportMode.REQUIRED_CONSISTENT_EDGES` and the edges of a task 

1424 did change after import and reconfiguration. 

1425 

1426 Notes 

1427 ----- 

1428 This method shouldn't need to be called unless the graph was 

1429 deserialized without importing and configuring immediately, which is 

1430 not the default behavior (but it can greatly speed up deserialization). 

1431 If all tasks have already been imported this does nothing. 

1432 

1433 Importing and configuring a task can change its 

1434 `~TaskNode.task_class_name` or `~TaskClass.get_config_str` output, 

1435 usually because the software used to read a serialized graph is newer 

1436 than the software used to write it (e.g. a new config option has been 

1437 added, or the task was moved to a new module with a forwarding alias 

1438 left behind). These changes are allowed by 

1439 `TaskImportMode.REQUIRE_CONSISTENT_EDGES`. 

1440 

1441 If importing and configuring a task causes its edges to change, any 

1442 dataset type nodes linked to those edges will be reset to the 

1443 unresolved state. 

1444 """ 

1445 if import_mode is TaskImportMode.DO_NOT_IMPORT: 

1446 return 

1447 rebuild = ( 

1448 import_mode is TaskImportMode.REQUIRE_CONSISTENT_EDGES 

1449 or import_mode is TaskImportMode.OVERRIDE_EDGES 

1450 ) 

1451 updates: dict[str, TaskNode] = {} 

1452 node_key: NodeKey 

1453 for node_key, node_state in self._xgraph.nodes.items(): 

1454 if node_key.node_type is NodeType.TASK: 

1455 task_node: TaskNode = node_state["instance"] 

1456 new_task_node = task_node._imported_and_configured(rebuild) 

1457 if new_task_node is not task_node: 

1458 updates[task_node.label] = new_task_node 

1459 self._replace_task_nodes( 

1460 updates, 

1461 check_edges_unchanged=(import_mode is TaskImportMode.REQUIRE_CONSISTENT_EDGES), 

1462 assume_edges_unchanged=(import_mode is TaskImportMode.ASSUME_CONSISTENT_EDGES), 

1463 message_header=( 

1464 "In task with label {task_label!r}, persisted edges (A)" 

1465 "differ from imported and configured edges (B):" 

1466 ), 

1467 ) 

1468 

1469 ########################################################################### 

1470 # 

1471 # Advanced PipelineGraph Inspection Interface: 

1472 # 

1473 # - methods to iterate over all nodes and edges, utilizing NodeKeys; 

1474 # 

1475 # - methods to find overall inputs and group nodes by their dimensions, 

1476 # which are important operations for QuantumGraph generation. 

1477 # 

1478 ########################################################################### 

1479 

1480 def iter_edges(self, init: bool = False) -> Iterator[Edge]: 

1481 """Iterate over edges in the graph. 

1482 

1483 Parameters 

1484 ---------- 

1485 init : `bool`, optional 

1486 If `True` (`False` is default) iterate over the edges between task 

1487 initialization node and init input/output dataset types, instead of 

1488 the runtime task nodes and regular input/output/prerequisite 

1489 dataset types. 

1490 

1491 Returns 

1492 ------- 

1493 edges : `~collections.abc.Iterator` [ `Edge` ] 

1494 A lazy iterator over `Edge` (`WriteEdge` or `ReadEdge`) instances. 

1495 

1496 Notes 

1497 ----- 

1498 This method always returns _either_ init edges or runtime edges, never 

1499 both. The full (internal) graph that contains both also includes a 

1500 special edge that connects each task init node to its runtime node; 

1501 that is also never returned by this method, since it is never a part of 

1502 the init-only or runtime-only subgraphs. 

1503 """ 

1504 edge: Edge 

1505 for _, _, edge in self._xgraph.edges(data="instance"): 

1506 if edge is not None and edge.is_init == init: 

1507 yield edge 

1508 

1509 def iter_nodes( 

1510 self, 

1511 ) -> Iterator[ 

1512 tuple[Literal[NodeType.TASK_INIT], str, TaskInitNode] 

1513 | tuple[Literal[NodeType.TASK], str, TaskInitNode] 

1514 | tuple[Literal[NodeType.DATASET_TYPE], str, DatasetTypeNode | None] 

1515 ]: 

1516 """Iterate over nodes in the graph. 

1517 

1518 Returns 

1519 ------- 

1520 nodes : `~collections.abc.Iterator` [ `tuple` ] 

1521 A lazy iterator over all of the nodes in the graph. Each yielded 

1522 element is a tuple of: 

1523 

1524 - the node type enum value (`NodeType`); 

1525 - the string name for the node (task label or parent dataset type 

1526 name); 

1527 - the node value (`TaskNode`, `TaskInitNode`, `DatasetTypeNode`, 

1528 or `None` for dataset type nodes that have not been resolved). 

1529 """ 

1530 key: NodeKey 

1531 if self._sorted_keys is not None: 

1532 for key in self._sorted_keys: 

1533 yield key.node_type, key.name, self._xgraph.nodes[key]["instance"] # type: ignore 

1534 else: 

1535 for key, node in self._xgraph.nodes(data="instance"): 

1536 yield key.node_type, key.name, node # type: ignore 

1537 

1538 def iter_overall_inputs(self) -> Iterator[tuple[str, DatasetTypeNode | None]]: 

1539 """Iterate over all of the dataset types that are consumed but not 

1540 produced by the graph. 

1541 

1542 Returns 

1543 ------- 

1544 dataset_types : `~collections.abc.Iterator` [ `tuple` ] 

1545 A lazy iterator over the overall-input dataset types (including 

1546 overall init inputs and prerequisites). Each yielded element is a 

1547 tuple of: 

1548 

1549 - the parent dataset type name; 

1550 - the resolved `DatasetTypeNode`, or `None` if the dataset type has 

1551 not been resolved. 

1552 """ 

1553 for generation in networkx.algorithms.dag.topological_generations(self._xgraph): 

1554 key: NodeKey 

1555 for key in generation: 

1556 # While we expect all tasks to have at least one input and 

1557 # hence never appear in the first topological generation, that 

1558 # is not true of task init nodes. 

1559 if key.node_type is NodeType.DATASET_TYPE: 

1560 yield key.name, self._xgraph.nodes[key]["instance"] 

1561 return 

1562 

1563 def group_by_dimensions( 

1564 self, prerequisites: bool = False 

1565 ) -> dict[DimensionGroup, tuple[dict[str, TaskNode], dict[str, DatasetTypeNode]]]: 

1566 """Group this graph's tasks and dataset types by their dimensions. 

1567 

1568 Parameters 

1569 ---------- 

1570 prerequisites : `bool`, optional 

1571 If `True`, include prerequisite dataset types as well as regular 

1572 input and output datasets (including intermediates). 

1573 

1574 Returns 

1575 ------- 

1576 groups : `dict` [ `~lsst.daf.butler.DimensionGroup`, `tuple` ] 

1577 A dictionary of groups keyed by `~lsst.daf.butler.DimensionGroup`, 

1578 in which each value is a tuple of: 

1579 

1580 - a `dict` of `TaskNode` instances, keyed by task label 

1581 - a `dict` of `DatasetTypeNode` instances, keyed by 

1582 dataset type name. 

1583 

1584 that have those dimensions. 

1585 

1586 Notes 

1587 ----- 

1588 Init inputs and outputs are always included, but always have empty 

1589 dimensions and are hence are all grouped together. 

1590 """ 

1591 result: dict[DimensionGroup, tuple[dict[str, TaskNode], dict[str, DatasetTypeNode]]] = {} 

1592 next_new_value: tuple[dict[str, TaskNode], dict[str, DatasetTypeNode]] = ({}, {}) 

1593 for task_label, task_node in self.tasks.items(): 

1594 if task_node.dimensions is None: 

1595 raise UnresolvedGraphError(f"Task with label {task_label!r} has not been resolved.") 

1596 if (group := result.setdefault(task_node.dimensions, next_new_value)) is next_new_value: 

1597 next_new_value = ({}, {}) # make new lists for next time 

1598 group[0][task_node.label] = task_node 

1599 for dataset_type_name, dataset_type_node in self.dataset_types.items(): 

1600 if dataset_type_node is None: 

1601 raise UnresolvedGraphError(f"Dataset type {dataset_type_name!r} has not been resolved.") 

1602 if not dataset_type_node.is_prerequisite or prerequisites: 

1603 if ( 

1604 group := result.setdefault(dataset_type_node.dataset_type.dimensions, next_new_value) 

1605 ) is next_new_value: 

1606 next_new_value = ({}, {}) # make new lists for next time 

1607 group[1][dataset_type_node.name] = dataset_type_node 

1608 return result 

1609 

1610 def get_all_dimensions(self, prerequisites: bool = True) -> DimensionGroup: 

1611 """Return all dimensions used in this graph's tasks and dataset types. 

1612 

1613 Parameters 

1614 ---------- 

1615 prerequisites : `bool`, optional 

1616 If `False`, do not include the dimensions that are only used by 

1617 prerequisite input dataset types. 

1618 

1619 Returns 

1620 ------- 

1621 dimensions : `~lsst.daf.butler.DimensionGroup`. 

1622 All dimensions in this pipeline. 

1623 """ 

1624 return DimensionGroup.union( 

1625 *self.group_by_dimensions(prerequisites=prerequisites).keys(), 

1626 universe=self.universe, 

1627 ) 

1628 

1629 def split_independent(self) -> Iterable[PipelineGraph]: 

1630 """Iterate over independent subgraphs that together comprise this 

1631 pipeline graph. 

1632 

1633 Returns 

1634 ------- 

1635 subgraphs : `Iterable` [ `PipelineGraph` ] 

1636 An iterable over component subgraphs that could be run 

1637 independently (they have only overall inputs in common). May be a 

1638 lazy iterator. 

1639 

1640 Notes 

1641 ----- 

1642 All resolved dataset type nodes will be preserved. 

1643 

1644 If there is only one component, ``self`` may be returned as the only 

1645 element in the iterable. 

1646 

1647 If `has_been_sorted`, all subgraphs will be sorted as well. 

1648 

1649 Task subsets will not be included in the returned graphs. 

1650 """ 

1651 # Having an overall input in common isn't enough to make subgraphs 

1652 # dependent on each other, so we want to look for connected component 

1653 # subgraphs of the task-only projected graph. 

1654 bipartite_xgraph = self._make_bipartite_xgraph_internal(init=False) 

1655 task_keys = { 

1656 key 

1657 for key, bipartite in bipartite_xgraph.nodes(data="bipartite") 

1658 if bipartite == NodeType.TASK.bipartite 

1659 } 

1660 task_xgraph = networkx.algorithms.bipartite.projected_graph( 

1661 networkx.DiGraph(bipartite_xgraph), task_keys 

1662 ) 

1663 # "Weakly" connected means connected in only one direction, which is 

1664 # the only kind of "connected" a DAG can ever be. 

1665 for component_task_keys in networkx.algorithms.weakly_connected_components(task_xgraph): 

1666 if component_task_keys == task_keys: 

1667 yield self 

1668 return 

1669 else: 

1670 component_subgraph = PipelineGraph(universe=self._universe, data_id=self._raw_data_id) 

1671 component_subgraph.add_task_nodes( 

1672 [self._xgraph.nodes[key]["instance"] for key in component_task_keys], parent=self 

1673 ) 

1674 if self.has_been_sorted: 

1675 component_subgraph.sort() 

1676 yield component_subgraph 

1677 

1678 ########################################################################### 

1679 # 

1680 # Data repository/collection initialization 

1681 # 

1682 ########################################################################### 

1683 

1684 @property 

1685 def packages_dataset_type(self) -> DatasetType: 

1686 """The special "packages" dataset type that records software versions. 

1687 

1688 This is not associated with a task and hence is 

1689 not considered part of the pipeline graph in other respects, but it 

1690 does get written with other provenance datasets. 

1691 """ 

1692 if self._universe is None: 

1693 raise UnresolvedGraphError( 

1694 "PipelineGraph must be resolved in order to get the packages dataset type." 

1695 ) 

1696 return DatasetType( 

1697 PACKAGES_INIT_OUTPUT_NAME, self._universe.empty, PACKAGES_INIT_OUTPUT_STORAGE_CLASS 

1698 ) 

1699 

1700 def register_dataset_types(self, butler: Butler, include_packages: bool = True) -> None: 

1701 """Register all dataset types in a data repository. 

1702 

1703 Parameters 

1704 ---------- 

1705 butler : `~lsst.daf.butler.Butler` 

1706 Data repository client. 

1707 include_packages : `bool`, optional 

1708 Whether to include the special "packages" dataset type that records 

1709 software versions (this is not associated with a task and hence is 

1710 not considered part of the pipeline graph in other respects, but it 

1711 does get written with other provenance datasets). 

1712 """ 

1713 dataset_types = [node.dataset_type for node in self.dataset_types.values()] 

1714 if include_packages: 

1715 dataset_types.append(self.packages_dataset_type) 

1716 for dataset_type in dataset_types: 

1717 butler.registry.registerDatasetType(dataset_type) 

1718 

1719 def check_dataset_type_registrations(self, butler: Butler, include_packages: bool = True) -> None: 

1720 """Check that dataset type registrations in a data repository match 

1721 the definitions in this pipeline graph. 

1722 

1723 Parameters 

1724 ---------- 

1725 butler : `~lsst.daf.butler.Butler` 

1726 Data repository client. 

1727 include_packages : `bool`, optional 

1728 Whether to include the special "packages" dataset type that records 

1729 software versions (this is not associated with a task and hence is 

1730 not considered part of the pipeline graph in other respects, but it 

1731 does get written with other provenance datasets). 

1732 

1733 Raises 

1734 ------ 

1735 lsst.daf.butler.MissingDatasetTypeError 

1736 Raised if one or more non-optional-input or output dataset types in 

1737 the pipeline is not registered at all. 

1738 lsst.daf.butler.ConflictingDefinitionError 

1739 Raised if the definition in the data repository is not identical 

1740 to the definition in the pipeline graph. 

1741 

1742 Notes 

1743 ----- 

1744 Note that dataset type definitions that are storage-class-conversion 

1745 compatible but not identical are not permitted by these checks, because 

1746 the expectation is that these differences are handled by `resolve`, 

1747 which makes the pipeline graph use the data repository definitions. 

1748 This method is intended to check that none of those definitions have 

1749 changed. 

1750 """ 

1751 dataset_types = [node.dataset_type for node in self.dataset_types.values()] 

1752 if include_packages: 

1753 dataset_types.append(self.packages_dataset_type) 

1754 missing_dataset_types: list[str] = [] 

1755 for dataset_type in dataset_types: 

1756 try: 

1757 expected = butler.registry.getDatasetType(dataset_type.name) 

1758 except MissingDatasetTypeError: 

1759 expected = None 

1760 if expected is None: 

1761 # The user probably forgot to register dataset types 

1762 # at least once (which should be an error), 

1763 # but we could also get here if this is an optional input for 

1764 # which no datasets were found in this repo (not an error). 

1765 if ( 

1766 not ( 

1767 self.producer_of(dataset_type.name) is None 

1768 and all( 

1769 self.tasks[input_edge.task_label].is_optional(input_edge.connection_name) 

1770 for input_edge in self.consuming_edges_of(dataset_type.name) 

1771 ) 

1772 ) 

1773 or dataset_type.name == PACKAGES_INIT_OUTPUT_NAME 

1774 ): 

1775 missing_dataset_types.append(dataset_type.name) 

1776 elif expected != dataset_type: 

1777 raise ConflictingDefinitionError( 

1778 f"DatasetType definition in registry has changed since the pipeline graph was resolved: " 

1779 f"{dataset_type} (graph) != {expected} (registry)." 

1780 ) 

1781 if missing_dataset_types: 

1782 plural = "s" if len(missing_dataset_types) != 1 else "" 

1783 raise MissingDatasetTypeError( 

1784 f"Missing dataset type definition{plural}: {', '.join(missing_dataset_types)}. " 

1785 "Dataset types have to be registered in advance (on the command-line, either via " 

1786 "`butler register-dataset-type` or the `--register-dataset-types` option to `pipetask run`." 

1787 ) 

1788 

1789 def instantiate_tasks( 

1790 self, 

1791 get_init_input: Callable[[DatasetType], Any] | None = None, 

1792 init_outputs: list[tuple[Any, DatasetType]] | None = None, 

1793 labels: Iterable[str] | None = None, 

1794 ) -> list[PipelineTask]: 

1795 """Instantiate all tasks in the pipeline. 

1796 

1797 Parameters 

1798 ---------- 

1799 get_init_input : `~collections.abc.Callable`, optional 

1800 Callable that accepts a single `~lsst.daf.butler.DatasetType` 

1801 parameter and returns the init-input dataset associated with that 

1802 dataset type. Must respect the storage class embedded in the type. 

1803 This is optional if the pipeline does not have any overall init 

1804 inputs. When a full butler is available, 

1805 `lsst.daf.butler.Butler.get` can be used directly here. 

1806 init_outputs : `list`, optional 

1807 A list of ``(obj, dataset type)`` init-output dataset pairs, to be 

1808 appended to in-place. Both the object and the dataset type will 

1809 correspond to the storage class of the output connection, which 

1810 may not be the same as the storage class on the graph's dataset 

1811 type node. 

1812 labels : `~collections.abc.Iterable` [ `str` ], optional 

1813 The labels of tasks to instantiate. If not provided, all tasks in 

1814 the graph will be instantiated. 

1815 

1816 Returns 

1817 ------- 

1818 tasks : `list` 

1819 Constructed `PipelineTask` instances. 

1820 """ 

1821 if not self.is_fully_resolved: 

1822 raise UnresolvedGraphError("Pipeline graph must be fully resolved before instantiating tasks.") 

1823 empty_data_id = DataCoordinate.make_empty(self.universe) 

1824 labels = set(labels) if labels is not None else self.tasks.keys() 

1825 handles: dict[str, InMemoryDatasetHandle] = {} 

1826 tasks: list[PipelineTask] = [] 

1827 for task_node in self.tasks.values(): 

1828 if task_node.label not in labels: 

1829 continue 

1830 task_init_inputs: dict[str, Any] = {} 

1831 for read_edge in task_node.init.inputs.values(): 

1832 if (handle := handles.get(read_edge.dataset_type_name)) is not None: 

1833 obj = handle.get(storageClass=read_edge.storage_class_name) 

1834 elif ( 

1835 read_edge.component is not None 

1836 and (parent_handle := handles.get(read_edge.parent_dataset_type_name)) is not None 

1837 ): 

1838 obj = parent_handle.get( 

1839 storageClass=read_edge.storage_class_name, component=read_edge.component 

1840 ) 

1841 else: 

1842 dataset_type_node = self.dataset_types[read_edge.parent_dataset_type_name] 

1843 if get_init_input is None: 

1844 raise ValueError( 

1845 f"Task {task_node.label!r} requires init-input " 

1846 f"{read_edge.dataset_type_name} but no 'get_init_input' callback was provided." 

1847 ) 

1848 obj = get_init_input(read_edge.adapt_dataset_type(dataset_type_node.dataset_type)) 

1849 n_consumers = len(self.consumers_of(dataset_type_node.name)) 

1850 if ( 

1851 n_consumers > 1 

1852 and read_edge.component is None 

1853 and read_edge.storage_class_name == dataset_type_node.storage_class_name 

1854 ): 

1855 # Caching what we just got is safe in general only 

1856 # if there was no storage class conversion, since 

1857 # a->b and a->c does not imply b->c. 

1858 handles[read_edge.dataset_type_name] = InMemoryDatasetHandle( 

1859 obj, 

1860 storageClass=dataset_type_node.storage_class, 

1861 dataId=empty_data_id, 

1862 copy=True, 

1863 ) 

1864 task_init_inputs[read_edge.connection_name] = obj 

1865 task = task_node.task_class( 

1866 config=task_node.config, initInputs=task_init_inputs, name=task_node.label 

1867 ) 

1868 tasks.append(task) 

1869 for write_edge in task_node.init.outputs.values(): 

1870 dataset_type_node = self.dataset_types[write_edge.parent_dataset_type_name] 

1871 obj = getattr(task, write_edge.connection_name) 

1872 # We don't immediately coerce obj to the dataset_type_node 

1873 # storage class (which should be the repo storage class, if 

1874 # there is one) when appending to `init_outputs` because a 

1875 # formatter might be able to do a better job of that later; 

1876 # instead we pair it with a dataset type that's consistent with 

1877 # the in-memory type. We do coerce when populating `handles`, 

1878 # though, because going through the dataset_type_node storage 

1879 # class is the conversion path we checked when we resolved the 

1880 # pipeline graph. 

1881 if init_outputs is not None: 

1882 init_outputs.append((obj, write_edge.adapt_dataset_type(dataset_type_node.dataset_type))) 

1883 n_consumers = len(self.consumers_of(dataset_type_node.name)) 

1884 if n_consumers > 0: 

1885 handles[dataset_type_node.name] = InMemoryDatasetHandle( 

1886 dataset_type_node.storage_class.coerce_type(obj), 

1887 dataId=empty_data_id, 

1888 storageClass=dataset_type_node.storage_class, 

1889 copy=(n_consumers > 1), 

1890 ) 

1891 return tasks 

1892 

1893 def write_init_outputs(self, butler: Butler) -> None: 

1894 """Write the init-output datasets for all tasks in the pipeline graph. 

1895 

1896 Parameters 

1897 ---------- 

1898 butler : `lsst.daf.butler.Butler` 

1899 A full butler data repository client with its default run set 

1900 to the collection where datasets should be written. 

1901 

1902 Notes 

1903 ----- 

1904 Datasets that already exist in the butler's output run collection will 

1905 not be written. 

1906 

1907 This method writes outputs with new random dataset IDs and should 

1908 hence only be used when writing init-outputs prior to building a 

1909 `QuantumGraph`. Use `QuantumGraph.write_init_outputs` if a quantum 

1910 graph has already been built. 

1911 """ 

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

1913 self.instantiate_tasks(butler.get, init_outputs) 

1914 found_refs: dict[str, DatasetRef] = {} 

1915 to_put: list[tuple[Any, DatasetType]] = [] 

1916 for obj, dataset_type in init_outputs: 

1917 if (ref := butler.find_dataset(dataset_type, collections=butler.run)) is not None: 

1918 found_refs[dataset_type.name] = ref 

1919 else: 

1920 to_put.append((obj, dataset_type)) 

1921 for ref, stored in butler.stored_many(found_refs.values()).items(): 

1922 if not stored: 

1923 raise FileNotFoundError( 

1924 f"Init-output dataset {ref.datasetType.name!r} was found in RUN {ref.run!r} " 

1925 f"but had not actually been stored (or was stored and later deleted)." 

1926 ) 

1927 for obj, dataset_type in to_put: 

1928 butler.put(obj, dataset_type) 

1929 

1930 def write_configs(self, butler: Butler) -> None: 

1931 """Write the config datasets for all tasks in the pipeline graph. 

1932 

1933 Parameters 

1934 ---------- 

1935 butler : `lsst.daf.butler.Butler` 

1936 A full butler data repository client with its default run set 

1937 to the collection where datasets should be written. 

1938 

1939 Notes 

1940 ----- 

1941 Config datasets that already exist in the butler's output run 

1942 collection will be checked for consistency. 

1943 

1944 This method writes outputs with new random dataset IDs and should 

1945 hence only be used when writing init-outputs prior to building a 

1946 `QuantumGraph`. Use `QuantumGraph.write_configs` if a quantum graph 

1947 has already been built. 

1948 

1949 Raises 

1950 ------ 

1951 lsst.daf.butler.registry.ConflictingDefinitionError 

1952 Raised if a config dataset already exists and is not consistent 

1953 with the config in the pipeline graph. 

1954 """ 

1955 to_put: list[tuple[PipelineTaskConfig, str]] = [] 

1956 for task_node in self.tasks.values(): 

1957 dataset_type_name = task_node.init.config_output.dataset_type_name 

1958 if (ref := butler.find_dataset(dataset_type_name, collections=butler.run)) is not None: 

1959 old_config = butler.get(ref) 

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

1961 raise ConflictingDefinitionError( 

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

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

1964 ) 

1965 else: 

1966 to_put.append((task_node.config, dataset_type_name)) 

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

1968 # raise an exception. 

1969 for config, dataset_type_name in to_put: 

1970 butler.put(config, dataset_type_name) 

1971 

1972 def write_packages(self, butler: Butler) -> None: 

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

1974 versions. 

1975 

1976 Parameters 

1977 ---------- 

1978 butler : `lsst.daf.butler.Butler` 

1979 A full butler data repository client with its default run set 

1980 to the collection where datasets should be written. 

1981 

1982 Notes 

1983 ----- 

1984 If the packages dataset already exists, it will be compared to the 

1985 versions in the current packages. New packages that weren't present 

1986 before are not considered an inconsistency. 

1987 

1988 This method writes outputs with new random dataset IDs and should 

1989 hence only be used when writing init-outputs prior to building a 

1990 `QuantumGraph`. Use `QuantumGraph.write_packages` if a quantum graph 

1991 has already been built. 

1992 

1993 Raises 

1994 ------ 

1995 lsst.daf.butler.registry.ConflictingDefinitionError 

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

1997 with the current packages. 

1998 """ 

1999 new_packages = Packages.fromSystem() 

2000 if (ref := butler.find_dataset(self.packages_dataset_type)) is not None: 

2001 packages = butler.get(ref) 

2002 if compare_packages(packages, new_packages): 

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

2004 # replace option. 

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

2006 butler.put(packages, ref) 

2007 else: 

2008 butler.put(new_packages, self.packages_dataset_type) 

2009 

2010 def init_output_run(self, butler: Butler) -> None: 

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

2012 datasets (including configs and packages). 

2013 

2014 Parameters 

2015 ---------- 

2016 butler : `lsst.daf.butler.Butler` 

2017 A full butler data repository client with its default run set 

2018 to the collection where datasets should be written. 

2019 """ 

2020 self.write_configs(butler) 

2021 self.write_packages(butler) 

2022 self.write_init_outputs(butler) 

2023 

2024 ########################################################################### 

2025 # 

2026 # Class- and Package-Private Methods. 

2027 # 

2028 ########################################################################### 

2029 

2030 def _iter_task_defs(self) -> Iterator[TaskDef]: 

2031 """Iterate over this pipeline as a sequence of `TaskDef` instances. 

2032 

2033 Notes 

2034 ----- 

2035 This is a package-private method intended to aid in the transition to a 

2036 codebase more fully integrated with the `PipelineGraph` class, in which 

2037 both `TaskDef` and `PipelineDatasetTypes` are expected to go away, and 

2038 much of the functionality on the `Pipeline` class will be moved to 

2039 `PipelineGraph` as well. 

2040 

2041 Raises 

2042 ------ 

2043 TaskNotImportedError 

2044 Raised if `TaskNode.is_imported` is `False` for any task. 

2045 """ 

2046 from ..pipeline import TaskDef 

2047 

2048 for node in self._tasks.values(): 

2049 yield TaskDef( 

2050 config=node.config, 

2051 taskClass=node.task_class, 

2052 label=node.label, 

2053 connections=node.get_connections(), 

2054 ) 

2055 

2056 def _init_from_args( 

2057 self, 

2058 xgraph: networkx.MultiDiGraph | None, 

2059 sorted_keys: Sequence[NodeKey] | None, 

2060 task_subsets: dict[str, TaskSubset] | None, 

2061 description: str, 

2062 universe: DimensionUniverse | None, 

2063 data_id: DataId | None, 

2064 step_definitions: StepDefinitions, 

2065 ) -> None: 

2066 """Initialize the graph with possibly-nontrivial arguments. 

2067 

2068 Parameters 

2069 ---------- 

2070 xgraph : `networkx.MultiDiGraph` or `None` 

2071 The backing networkx graph, or `None` to create an empty one. 

2072 This graph has `NodeKey` instances for nodes and the same structure 

2073 as the graph exported by `make_xgraph`, but its nodes and edges 

2074 have a single ``instance`` attribute that holds a `TaskNode`, 

2075 `TaskInitNode`, `DatasetTypeNode` (or `None`), `ReadEdge`, or 

2076 `WriteEdge` instance. 

2077 sorted_keys : `~collections.abc.Sequence` [ `NodeKey` ] or `None` 

2078 Topologically sorted sequence of node keys, or `None` if the graph 

2079 is not sorted. 

2080 task_subsets : `dict` [ `str`, `TaskSubset` ] 

2081 Labeled subsets of tasks. Values must be constructed with 

2082 ``xgraph`` as their parent graph. 

2083 description : `str` 

2084 String description for this pipeline. 

2085 universe : `lsst.daf.butler.DimensionUniverse` or `None` 

2086 Definitions of all dimensions. 

2087 data_id : `lsst.daf.butler.DataCoordinate` or other data ID mapping. 

2088 Data ID that represents a constraint on all quanta generated from 

2089 this pipeline. 

2090 step_definitions : `StepDefinitions` 

2091 Struct holding information about steps. 

2092 

2093 Notes 

2094 ----- 

2095 Only empty `PipelineGraph` instances should be constructed directly by 

2096 users, which sets the signature of ``__init__`` itself, but methods on 

2097 `PipelineGraph` and its helper classes need to be able to create them 

2098 with state. Those methods can call this after calling ``__new__`` 

2099 manually, skipping ``__init__``. 

2100 """ 

2101 self._xgraph = xgraph if xgraph is not None else networkx.MultiDiGraph() 

2102 self._sorted_keys: Sequence[NodeKey] | None = None 

2103 self._task_subsets = task_subsets if task_subsets is not None else {} 

2104 self._description = description 

2105 self._tasks = TaskMappingView(self._xgraph) 

2106 self._dataset_types = DatasetTypeMappingView(self._xgraph) 

2107 self._raw_data_id: dict[str, Any] 

2108 if isinstance(data_id, DataCoordinate): 

2109 if universe is None: 

2110 universe = data_id.universe 

2111 else: 

2112 assert universe is data_id.universe, "data_id.universe and given universe differ" 

2113 self._raw_data_id = dict(data_id.required) 

2114 elif data_id is None: 

2115 self._raw_data_id = {} 

2116 else: 

2117 self._raw_data_id = dict(data_id) 

2118 self._universe = universe 

2119 if sorted_keys is not None: 

2120 self._reorder(sorted_keys) 

2121 self._step_definitions = step_definitions 

2122 

2123 def _make_bipartite_xgraph_internal(self, init: bool) -> networkx.MultiDiGraph: 

2124 """Make a bipartite init-only or runtime-only internal subgraph. 

2125 

2126 See `make_bipartite_xgraph` for parameters and return values. 

2127 

2128 Notes 

2129 ----- 

2130 This method returns a view of the `PipelineGraph` object's internal 

2131 backing graph, and hence should only be called in methods that copy the 

2132 result either explicitly or by running a copying algorithm before 

2133 returning it to the user. 

2134 """ 

2135 return self._xgraph.edge_subgraph([edge.key for edge in self.iter_edges(init)]) 

2136 

2137 def _make_task_xgraph_internal(self, init: bool) -> networkx.DiGraph: 

2138 """Make a init-only or runtime-only internal task subgraph. 

2139 

2140 See `make_task_xgraph` for parameters and return values. 

2141 

2142 Notes 

2143 ----- 

2144 This method returns a view of the `PipelineGraph` object's internal 

2145 backing graph, and hence should only be called in methods that copy the 

2146 result either explicitly or by running a copying algorithm before 

2147 returning it to the user. 

2148 """ 

2149 bipartite_xgraph = self._make_bipartite_xgraph_internal(init=init) 

2150 task_keys = [ 

2151 key 

2152 for key, bipartite in bipartite_xgraph.nodes(data="bipartite") 

2153 if bipartite == NodeType.TASK.bipartite 

2154 ] 

2155 return networkx.algorithms.bipartite.projected_graph(networkx.DiGraph(bipartite_xgraph), task_keys) 

2156 

2157 def _transform_xgraph_state(self, xgraph: _G, skip_edges: bool) -> _G: 

2158 """Transform networkx graph attributes in-place from the internal 

2159 "instance" attributes to the documented exported attributes. 

2160 

2161 Parameters 

2162 ---------- 

2163 xgraph : `networkx.DiGraph` or `networkx.MultiDiGraph` 

2164 Graph whose state should be transformed. 

2165 skip_edges : `bool` 

2166 If `True`, do not transform edge state. 

2167 

2168 Returns 

2169 ------- 

2170 xgraph : `networkx.DiGraph` or `networkx.MultiDiGraph` 

2171 The same object passed in, after modification. 

2172 

2173 Notes 

2174 ----- 

2175 This should be called after making a copy of the internal graph but 

2176 before any projection down to just task or dataset type nodes, since 

2177 it assumes stateful edges. 

2178 """ 

2179 state: dict[str, Any] 

2180 for state in xgraph.nodes.values(): 

2181 node_value: TaskInitNode | TaskNode | DatasetTypeNode | None = state.pop("instance") 

2182 if node_value is not None: 

2183 state.update(node_value._to_xgraph_state()) 

2184 else: 

2185 # This is a dataset type node that is not resolved. 

2186 state["bipartite"] = NodeType.DATASET_TYPE.bipartite 

2187 if not skip_edges: 

2188 for _, _, state in xgraph.edges(data=True): 

2189 edge: Edge | None = state.pop("instance", None) 

2190 if edge is not None: 

2191 state.update(edge._to_xgraph_state()) 

2192 return xgraph 

2193 

2194 def _replace_task_nodes( 

2195 self, 

2196 updates: Mapping[str, TaskNode], 

2197 check_edges_unchanged: bool, 

2198 assume_edges_unchanged: bool, 

2199 message_header: str, 

2200 ) -> None: 

2201 """Replace task nodes and update edges and dataset type nodes 

2202 accordingly. 

2203 

2204 Parameters 

2205 ---------- 

2206 updates : `Mapping` [ `str`, `TaskNode` ] 

2207 New task nodes with task label keys. All keys must be task labels 

2208 that are already present in the graph. 

2209 check_edges_unchanged : `bool`, optional 

2210 If `True`, require the edges (connections) of the modified tasks to 

2211 remain unchanged after importing and configuring each task, and 

2212 verify that this is the case. 

2213 assume_edges_unchanged : `bool`, optional 

2214 If `True`, the caller declares that the edges (connections) of the 

2215 modified tasks will remain unchanged importing and configuring each 

2216 task, and that it is unnecessary to check this. 

2217 message_header : `str` 

2218 Template for `str.format` with a single ``task_label`` placeholder 

2219 to use as the first line in `EdgesChangedError` messages that show 

2220 the differences between new task edges and old task edges. Should 

2221 include the fact that the rest of the message will refer to the old 

2222 task as "A" and the new task as "B", and end with a colon. 

2223 

2224 Raises 

2225 ------ 

2226 ValueError 

2227 Raised if ``assume_edges_unchanged`` and ``check_edges_unchanged`` 

2228 are both `True`, or if a full config is provided for a task after 

2229 another full config or an override has already been provided. 

2230 EdgesChangedError 

2231 Raised if ``check_edges_unchanged=True`` and the edges of a task do 

2232 change. 

2233 """ 

2234 deep: dict[str, TaskNode] = {} 

2235 shallow: dict[str, TaskNode] = {} 

2236 if assume_edges_unchanged: 

2237 if check_edges_unchanged: 

2238 raise ValueError("Cannot simultaneously assume and check that edges have not changed.") 

2239 shallow.update(updates) 

2240 else: 

2241 for task_label, new_task_node in updates.items(): 

2242 old_task_node = self.tasks[task_label] 

2243 messages = old_task_node.diff_edges(new_task_node) 

2244 if messages: 

2245 if check_edges_unchanged: 

2246 messages.insert(0, message_header.format(task_label=task_label)) 

2247 raise EdgesChangedError("\n".join(messages)) 

2248 else: 

2249 deep[task_label] = new_task_node 

2250 else: 

2251 shallow[task_label] = new_task_node 

2252 try: 

2253 if deep: 

2254 removed = self.remove_tasks(deep.keys(), drop_from_subsets=True) 

2255 self.add_task_nodes(deep.values()) 

2256 for replaced_task_node, referencing_subsets in removed: 

2257 for subset_label in referencing_subsets: 

2258 self._task_subsets[subset_label].add(replaced_task_node.label) 

2259 for task_node in shallow.values(): 

2260 self._xgraph.nodes[task_node.key]["instance"] = task_node 

2261 self._xgraph.nodes[task_node.init.key]["instance"] = task_node.init 

2262 except PipelineGraphExceptionSafetyError: # pragma: no cover 

2263 raise 

2264 except Exception as err: # pragma: no cover 

2265 # There's no known way to get here, but we want to make it clear 

2266 # it's a big problem if we do. 

2267 raise PipelineGraphExceptionSafetyError( 

2268 "Error while replacing tasks has left the graph in an inconsistent state." 

2269 ) from err 

2270 

2271 def _append_graph_data_from_edge( 

2272 self, 

2273 node_data: list[tuple[NodeKey, dict[str, Any]]], 

2274 edge_data: list[tuple[NodeKey, NodeKey, str, dict[str, Any]]], 

2275 edge: Edge, 

2276 parent: PipelineGraph | None, 

2277 ) -> None: 

2278 """Append networkx state dictionaries for an edge and the corresponding 

2279 dataset type node. 

2280 

2281 Parameters 

2282 ---------- 

2283 node_data : `list` 

2284 List of node keys and state dictionaries. A node is appended if 

2285 one does not already exist for this dataset type. 

2286 edge_data : `list` 

2287 List of node key pairs, connection names, and state dictionaries 

2288 for edges. 

2289 edge : `Edge` 

2290 New edge being processed. 

2291 parent : `PipelineGraph` or `None` 

2292 Another pipeline graph whose dataset type nodes should be used 

2293 when present. 

2294 """ 

2295 new_dataset_type_node = None 

2296 if parent is not None: 

2297 new_dataset_type_node = parent._xgraph.nodes[edge.dataset_type_key].get("instance") 

2298 if (existing_dataset_type_state := self._xgraph.nodes.get(edge.dataset_type_key)) is not None: 

2299 existing_dataset_type_state["instance"] = new_dataset_type_node 

2300 else: 

2301 node_data.append( 

2302 ( 

2303 edge.dataset_type_key, 

2304 { 

2305 "instance": new_dataset_type_node, 

2306 "bipartite": NodeType.DATASET_TYPE.bipartite, 

2307 }, 

2308 ) 

2309 ) 

2310 edge_data.append( 

2311 edge.nodes 

2312 + ( 

2313 edge.connection_name, 

2314 {"instance": edge}, 

2315 ) 

2316 ) 

2317 

2318 def _reorder(self, sorted_keys: Sequence[NodeKey]) -> None: 

2319 """Set the order of all views of this graph from the given sorted 

2320 sequence of task labels and dataset type names. 

2321 """ 

2322 self._sorted_keys = sorted_keys 

2323 self._tasks._reorder(sorted_keys) 

2324 self._dataset_types._reorder(sorted_keys) 

2325 

2326 def _reset(self) -> None: 

2327 """Reset all views of this graph following a modification that might 

2328 invalidate them. 

2329 """ 

2330 self._sorted_keys = None 

2331 self._tasks._reset() 

2332 self._dataset_types._reset() 

2333 

2334 def _resolve_step_flow(self) -> Sequence[NodeKey] | None: 

2335 """Check that step definitions are consistent with the ordering of 

2336 the graph's nodes and that they partition the task graph. 

2337 

2338 Returns 

2339 ------- 

2340 sort_keys : `~collections.abc.Sequence` [ `NodeKey` ] or `None` 

2341 Sort order for the pipeline graph that is consistent with the 

2342 step order, or `None` if there were no steps defined for this 

2343 pipeline. 

2344 """ 

2345 if not self._step_definitions: 

2346 return None 

2347 task_labels_so_far: set[str] = set() 

2348 # Inputs we've already seen, and the tasks that wanted them: 

2349 inputs_so_far: dict[str, set[str]] = {} 

2350 sort_keys: list[NodeKey] = [] 

2351 keys_already_sorted: set[NodeKey] = set() 

2352 for step_label in self.steps: 

2353 try: 

2354 task_subset = self.task_subsets[step_label] 

2355 except KeyError: 

2356 raise InvalidStepsError(f"Step {step_label!r} is not a task subset.") from None 

2357 if not task_labels_so_far.isdisjoint(task_subset): 

2358 raise InvalidStepsError( 

2359 f"Step {step_label!r} repeats task(s) {task_labels_so_far & task_subset}." 

2360 ) 

2361 task_labels_so_far.update(task_subset) 

2362 # We need a temporary data structure for tracking the inputs of 

2363 # just this step to avoid complaining about input-output 

2364 # relationships within the step. We'll merge this into the 

2365 # cumulative one later. 

2366 step_inputs: dict[str, set[str]] = {} 

2367 # We'll also gather all task, task-init and dataset type networkx 

2368 # graph keys for the step so we can make and sort a step sub-graph. 

2369 new_step_keys: set[NodeKey] = set() 

2370 for task_label in task_subset: 

2371 for input_name in self.inputs_of(task_label): 

2372 step_inputs.setdefault(input_name, set()).add(task_label) 

2373 # Check that none of the outputs of the tasks in this step were 

2374 # expected as inputs of a previous step's tasks. 

2375 task_outputs = self.outputs_of(task_label) 

2376 if not inputs_so_far.keys().isdisjoint(task_outputs.keys()): 

2377 msg: list[str] = [] 

2378 for input_name in inputs_so_far.keys() & task_outputs.keys(): 

2379 msg.append(f"{input_name} (used by {inputs_so_far[input_name]})") 

2380 raise InvalidStepsError( 

2381 f"Task {task_label} in step {step_label!r} produces dataset types " 

2382 f"[{', '.join(msg)}], but these are consumed by tasks in earlier " 

2383 "steps. Either steps are out of order or the graph is cyclic." 

2384 ) 

2385 task_init_key = NodeKey(NodeType.TASK_INIT, task_label) 

2386 task_key = NodeKey(NodeType.TASK, task_label) 

2387 new_step_keys.add(task_init_key) 

2388 new_step_keys.add(task_key) 

2389 new_step_keys.update(self._xgraph.predecessors(task_init_key)) 

2390 new_step_keys.update(self._xgraph.predecessors(task_key)) 

2391 new_step_keys.update(self._xgraph.successors(task_init_key)) 

2392 new_step_keys.update(self._xgraph.successors(task_key)) 

2393 # Also record the step the task is in as a private xgraph 

2394 # attribute so we can look up the step given a task (or, 

2395 # indirectly, dataset type); this has to be used with care 

2396 # because if the steps haven't been verified it can be wrong. 

2397 self._xgraph.nodes[task_init_key]["step"] = step_label 

2398 self._xgraph.nodes[task_key]["step"] = step_label 

2399 # Drop step input keys that were already either inputs or outputs 

2400 # of a previous step, since they'll have already been added to 

2401 # sort_keys. 

2402 new_step_keys.difference_update(keys_already_sorted) 

2403 # Make the step subgraph, sort it, and extend the overall sort_keys 

2404 # with result. 

2405 step_xgraph = self._xgraph.subgraph(new_step_keys) 

2406 sort_keys.extend(networkx.dag.lexicographical_topological_sort(step_xgraph)) 

2407 keys_already_sorted.update(new_step_keys) 

2408 for input_name, consuming_tasks in step_inputs.items(): 

2409 inputs_so_far.setdefault(input_name, set()).update(consuming_tasks) 

2410 if not task_labels_so_far.issuperset(self.tasks.keys()): 

2411 # Note that the converse issubset test effectively happens when we 

2412 # look up inputs and outputs of each task. 

2413 raise InvalidStepsError(f"No step contains task(s) {self.tasks.keys() - task_labels_so_far}.") 

2414 return sort_keys 

2415 

2416 def _resolve_step_dimensions(self, universe: DimensionUniverse) -> None: 

2417 """Check that step sharding dimensions are consistent with task and 

2418 output dataset dimensions. 

2419 

2420 Parameters 

2421 ---------- 

2422 universe : `~lsst.daf.butler.DimensionUniverse` 

2423 Definitions for all dimensions. Will be attached to the step 

2424 definitions by this method. 

2425 """ 

2426 self._step_definitions._universe = universe 

2427 for step_label in self.steps: 

2428 dimensions = self.steps.get_dimensions(step_label) 

2429 for task_label in self.task_subsets[step_label]: 

2430 task_node = self.tasks[task_label] 

2431 if not _dimensions_compatible(dimensions, task_node.dimensions): 

2432 raise InvalidStepsError( 

2433 f"Dimensions {task_node.dimensions} of task {task_label!r} are not compatible with " 

2434 f"the sharding dimensions {dimensions} of step {step_label!r}." 

2435 ) 

2436 for dataset_type_node in self.outputs_of(task_label).values(): 

2437 assert dataset_type_node is not None, "dataset types should be resolved first" 

2438 if not _dimensions_compatible(dimensions, dataset_type_node.dimensions): 

2439 raise InvalidStepsError( 

2440 f"Dimensions {dataset_type_node.dimensions} of dataset type " 

2441 f"{dataset_type_node.name!r} (produced by task {task_label!r}) are not " 

2442 f"compatible with the sharding dimensions {dimensions} of step " 

2443 f"{step_label!r}." 

2444 ) 

2445 

2446 def _select_expression(self, expr_tree: expressions.Node, task_xgraph: networkx.DiGraph) -> set[NodeKey]: 

2447 """Select tasks from a pipeline based on a string expression. 

2448 

2449 This is the primary implementation method for `select` and 

2450 `select_tasks`. 

2451 

2452 Parameters 

2453 ---------- 

2454 expr_tree : `expressions.Node` 

2455 Expression [sub]tree to process (recursively). 

2456 task_xgraph : `networkx.DiGraph` 

2457 NetworkX graph of all tasks (runtime nodes only) in the pipeline. 

2458 

2459 Returns 

2460 ------- 

2461 selected : `set` [ `NodeKey` ] 

2462 Set of `NodeKey` objects for matching tasks (only; no dataset type 

2463 or task-init nodes). 

2464 """ 

2465 match expr_tree: 

2466 case expressions.IdentifierNode(qualifier=qualifier, label=label): 

2467 match self._select_identifier(qualifier, label): 

2468 case NodeKey(node_type=NodeType.TASK) as task_key: 

2469 return {task_key} 

2470 case NodeKey(node_type=NodeType.DATASET_TYPE) as dataset_type_key: 

2471 # Since a dataset type can have only one producer, this 

2472 # yields 0- (for overall inputs) or 1-element sets. 

2473 for producer_key, _ in self._xgraph.in_edges(dataset_type_key): 

2474 if producer_key.node_type is NodeType.TASK_INIT: 

2475 raise InvalidExpressionError( 

2476 f"Init-output dataset type {label!r} cannot be used directly in an " 

2477 "expression." 

2478 ) 

2479 return {producer_key} 

2480 return set() 

2481 case TaskSubset() as task_subset: 

2482 return {NodeKey(NodeType.TASK, label) for label in task_subset} 

2483 case _: # pragma: no cover 

2484 raise AssertionError("Identifier type inconsistent with grammar.") 

2485 case expressions.DirectionNode(operator=operator, start=start): 

2486 match self._select_identifier(start.qualifier, start.label): 

2487 case NodeKey(node_type=NodeType.TASK) as task_key: 

2488 if operator.startswith("<"): 

2489 return self._select_task_ancestors( 

2490 task_key, task_xgraph, inclusive=operator.endswith("=") 

2491 ) 

2492 else: 

2493 assert operator.startswith(">"), "Guaranteed by grammar." 

2494 return self._select_task_descendants( 

2495 task_key, task_xgraph, inclusive=operator.endswith("=") 

2496 ) 

2497 case NodeKey(node_type=NodeType.DATASET_TYPE) as dataset_type_key: 

2498 if operator.startswith("<"): 

2499 return self._select_dataset_type_ancestors( 

2500 dataset_type_key, task_xgraph, inclusive=operator.endswith("=") 

2501 ) 

2502 else: 

2503 assert operator.startswith(">"), "Guaranteed by grammar." 

2504 return self._select_dataset_type_descendants( 

2505 dataset_type_key, task_xgraph, inclusive=operator.endswith("=") 

2506 ) 

2507 case TaskSubset(): 

2508 raise InvalidExpressionError( 

2509 f"Task subset identifier {start!r} cannot be used as the start of an " 

2510 "ancestor/descendant search." 

2511 ) 

2512 case _: # pragma: no cover 

2513 raise AssertionError("Unexpected parsed identifier result type.") 

2514 case expressions.NotNode(operand=operand): 

2515 operand_result = self._select_expression(operand, task_xgraph) 

2516 return set(task_xgraph.nodes.keys() - operand_result) 

2517 case expressions.UnionNode(lhs=lhs, rhs=rhs): 

2518 lhs_result = self._select_expression(lhs, task_xgraph) 

2519 rhs_result = self._select_expression(rhs, task_xgraph) 

2520 return lhs_result.union(rhs_result) 

2521 case expressions.IntersectionNode(lhs=lhs, rhs=rhs): 

2522 lhs_result = self._select_expression(lhs, task_xgraph) 

2523 rhs_result = self._select_expression(rhs, task_xgraph) 

2524 return lhs_result.intersection(rhs_result) 

2525 case _: # pragma: no cover 

2526 raise AssertionError("Expression parse node inconsistent with grammar.") 

2527 

2528 def _select_task_ancestors( 

2529 self, start: NodeKey, task_xgraph: networkx.DiGraph, inclusive: bool 

2530 ) -> set[NodeKey]: 

2531 """Return all task-node ancestors of the given task node, as defined by 

2532 the `select` expression language. 

2533 

2534 Parameters 

2535 ---------- 

2536 start : `NodeKey` 

2537 A runtime task node key. 

2538 task_xgraph : `networkx.DiGraph` 

2539 NetworkX graph of all tasks (runtime nodes only) in the pipeline. 

2540 inclusive : `bool` 

2541 Whether to include the ``start`` node in the results. 

2542 

2543 Returns 

2544 ------- 

2545 selected : `set` [ `NodeKey` ] 

2546 Set of `NodeKey` objects for matching tasks (only; no dataset type 

2547 or task-init nodes). 

2548 """ 

2549 result = set(networkx.dag.ancestors(task_xgraph, start)) 

2550 if inclusive: 

2551 result.add(start) 

2552 return result 

2553 

2554 def _select_task_descendants( 

2555 self, start: NodeKey, task_xgraph: networkx.DiGraph, inclusive: bool 

2556 ) -> set[NodeKey]: 

2557 """Return all task-node descendants of the given task node, as defined 

2558 by the `select` expression language. 

2559 

2560 Parameters 

2561 ---------- 

2562 start : `NodeKey` 

2563 A runtime task node key. 

2564 task_xgraph : `networkx.DiGraph` 

2565 NetworkX graph of all tasks (runtime nodes only) in the pipeline. 

2566 inclusive : `bool` 

2567 Whether to include the ``start`` node in the results. 

2568 

2569 Returns 

2570 ------- 

2571 selected : `set` [ `NodeKey` ] 

2572 Set of `NodeKey` objects for matching tasks (only; no dataset type 

2573 or task-init nodes). 

2574 """ 

2575 result = set(networkx.dag.descendants(task_xgraph, start)) 

2576 if inclusive: 

2577 result.add(start) 

2578 return result 

2579 

2580 def _select_dataset_type_ancestors( 

2581 self, start: NodeKey, task_xgraph: networkx.DiGraph, inclusive: bool 

2582 ) -> set[NodeKey]: 

2583 """Return all task-node ancestors of the given dataset type node, as 

2584 defined by the `select` expression language. 

2585 

2586 Parameters 

2587 ---------- 

2588 start : `NodeKey` 

2589 A dataset type node key. May not be an init-output. 

2590 task_xgraph : `networkx.DiGraph` 

2591 NetworkX graph of all tasks (runtime nodes only) in the pipeline. 

2592 inclusive : `bool` 

2593 Whether to include the producer of the ``start`` node in the 

2594 results. 

2595 

2596 Returns 

2597 ------- 

2598 selected : `set` [ `NodeKey` ] 

2599 Set of `NodeKey` objects for matching tasks (only; no dataset type 

2600 or task-init nodes). 

2601 """ 

2602 result: set[NodeKey] = set() 

2603 for producer_key, _ in self._xgraph.in_edges(start): 

2604 if producer_key.node_type is NodeType.TASK_INIT: 

2605 raise InvalidExpressionError( 

2606 f"Init-output dataset type {start.name!r} cannot be used as the " 

2607 "starting point for an ancestor ('<' or '<=') search." 

2608 ) 

2609 result.update(networkx.dag.ancestors(task_xgraph, producer_key)) 

2610 if inclusive: 

2611 result.add(producer_key) 

2612 return result 

2613 

2614 def _select_dataset_type_descendants( 

2615 self, start: NodeKey, task_xgraph: networkx.DiGraph, inclusive: bool 

2616 ) -> set[NodeKey]: 

2617 """Return all task-node descendatns of the given dataset type node, as 

2618 defined by the `select` expression language. 

2619 

2620 Parameters 

2621 ---------- 

2622 start : `NodeKey` 

2623 A dataset type node key. May not be an init-output if 

2624 ``inclusive=True``. 

2625 task_xgraph : `networkx.DiGraph` 

2626 NetworkX graph of all tasks (runtime nodes only) in the pipeline. 

2627 inclusive : `bool` 

2628 Whether to include the producer of the ``start`` node in the 

2629 results. 

2630 

2631 Returns 

2632 ------- 

2633 selected : `set` [ `NodeKey` ] 

2634 Set of `NodeKey` objects for matching tasks (only; no dataset type 

2635 or task-init nodes). 

2636 """ 

2637 result: set[NodeKey] = set() 

2638 if inclusive: 

2639 for producer_key, _ in self._xgraph.in_edges(start): 

2640 if producer_key.node_type is NodeType.TASK_INIT: 

2641 raise InvalidExpressionError( 

2642 f"Init-output dataset type {start.name!r} cannot be used as the " 

2643 "starting point for an includsive descendant ('>=') search." 

2644 ) 

2645 result.add(producer_key) 

2646 # We also include tasks that consume a dataset type as an init-input, 

2647 # since that can affect their runtime behavior. 

2648 consumer_keys: set[NodeKey] = { 

2649 ( 

2650 consumer_key 

2651 if consumer_key.node_type is NodeType.TASK 

2652 else NodeKey(NodeType.TASK, consumer_key.name) 

2653 ) 

2654 for _, consumer_key in self._xgraph.out_edges(start) 

2655 } 

2656 for consumer_key in consumer_keys: 

2657 result.add(consumer_key) 

2658 result.update(networkx.dag.descendants(task_xgraph, consumer_key)) 

2659 return result 

2660 

2661 def _select_identifier( 

2662 self, qualifier: Literal["T", "D", "S"] | None, label: str 

2663 ) -> NodeKey | TaskSubset: 

2664 """Return the node key or task subset that corresponds to a `select` 

2665 expression identifier. 

2666 

2667 Parameters 

2668 ---------- 

2669 qualifier : `str` or `None` 

2670 Task, dataset type, or task subset qualifier included in the 

2671 identifier, if any. 

2672 label : `str` 

2673 Task label, dataset type name, or task subset label. 

2674 

2675 Returns 

2676 ------- 

2677 key_or_subset : `NodeKey` or `TaskSubset` 

2678 A `NodeKey` for a task or dataset type, or a `TaskSubset` for a 

2679 task subset. 

2680 """ 

2681 match qualifier: 

2682 case None: 

2683 task_key = NodeKey(NodeType.TASK, label) 

2684 dataset_type_key = NodeKey(NodeType.DATASET_TYPE, label) 

2685 if task_key in self._xgraph.nodes: 

2686 if dataset_type_key in self._xgraph.nodes: 

2687 raise InvalidExpressionError( 

2688 f"{label!r} is both a task label and a dataset type name; " 

2689 "prefix with 'T:' or 'D:' (respectively) to specify which." 

2690 ) 

2691 assert label not in self._task_subsets, "Should be prohibited at construction." 

2692 return task_key 

2693 elif dataset_type_key in self._xgraph.nodes: 

2694 if label in self._task_subsets: 

2695 raise InvalidExpressionError( 

2696 f"{label!r} is both a subset label and a dataset type name; " 

2697 "prefix with 'S:' or 'D:' (respectively) to specify which." 

2698 ) 

2699 return dataset_type_key 

2700 elif label in self._task_subsets: 

2701 return self._task_subsets[label] 

2702 else: 

2703 raise InvalidExpressionError( 

2704 f"{label!r} is not a task label, task subset label, or dataset type name." 

2705 ) 

2706 case "T": 

2707 task_key = NodeKey(NodeType.TASK, label) 

2708 if task_key not in self._xgraph.nodes: 

2709 raise InvalidExpressionError(f"Task with label {label!r} does not exist.") 

2710 return task_key 

2711 case "D": 

2712 dataset_type_key = NodeKey(NodeType.DATASET_TYPE, label) 

2713 if dataset_type_key not in self._xgraph.nodes: 

2714 raise InvalidExpressionError(f"Dataset type with name {label!r} does not exist.") 

2715 return dataset_type_key 

2716 case "S": 

2717 try: 

2718 return self._task_subsets[label] 

2719 except KeyError: 

2720 raise InvalidExpressionError(f"Task subset with label {label!r} does not exist.") 

2721 case _: # pragma: no cover 

2722 raise AssertionError("Unexpected identifier qualifier in expression.") 

2723 

2724 _xgraph: networkx.MultiDiGraph 

2725 _sorted_keys: Sequence[NodeKey] | None 

2726 _task_subsets: dict[str, TaskSubset] 

2727 _step_definitions: StepDefinitions 

2728 _description: str 

2729 _tasks: TaskMappingView 

2730 _dataset_types: DatasetTypeMappingView 

2731 _raw_data_id: dict[str, Any] 

2732 _universe: DimensionUniverse | None 

2733 

2734 

2735def log_config_mismatch(msg: str) -> None: 

2736 """Log messages about configuration mismatch. 

2737 

2738 Parameters 

2739 ---------- 

2740 msg : `str` 

2741 Log message to use. 

2742 """ 

2743 _LOG.fatal("Comparing configuration: %s", msg) 

2744 

2745 

2746def compare_packages(packages: Packages, new_packages: Packages) -> bool: 

2747 """Compare two versions of Packages. 

2748 

2749 Parameters 

2750 ---------- 

2751 packages : `Packages` 

2752 Previously recorded package versions. Updated in place to include 

2753 any new packages that weren't present before. 

2754 new_packages : `Packages` 

2755 New set of package versions. 

2756 

2757 Returns 

2758 ------- 

2759 updated : `bool` 

2760 `True` if ``packages`` was updated, `False` if not. 

2761 

2762 Raises 

2763 ------ 

2764 ConflictingDefinitionError 

2765 Raised if versions are inconsistent. 

2766 """ 

2767 diff = new_packages.difference(packages) 

2768 if diff: 

2769 versions_str = "; ".join(f"{pkg}: {diff[pkg][1]} vs {diff[pkg][0]}" for pkg in diff) 

2770 raise ConflictingDefinitionError(f"Package versions mismatch: ({versions_str})") 

2771 else: 

2772 _LOG.debug("new packages are consistent with old") 

2773 # Update the old set of packages in case we have more packages 

2774 # that haven't been persisted. 

2775 extra = new_packages.extra(packages) 

2776 if extra: 

2777 _LOG.debug("extra packages: %s", extra) 

2778 packages.update(new_packages) 

2779 return True 

2780 return False 

2781 

2782 

2783def _dimensions_compatible(dimensions: DimensionGroup, object_dimensions: DimensionGroup) -> bool: 

2784 if dimensions.issubset(object_dimensions): 

2785 # Easy typical case. 

2786 return True 

2787 # Hard case: if any sharding dimensions that are not in the object 

2788 # dimensions are related to something in the object dimensions by a 

2789 # many-to-many join table, this is okay too. The main use case here is to 

2790 # let {exposure, [detector]} satisfy sharding dimensions of 

2791 # {visit, [detector]}. 

2792 universe = dimensions.universe 

2793 unmatched_sharding = dimensions.required - object_dimensions.required 

2794 unmatched_object = object_dimensions.required - dimensions.required 

2795 unmatched_union_group = dimensions.union(object_dimensions) 

2796 for element_name in unmatched_union_group.elements: 

2797 if ( 

2798 (element := universe[element_name]).defines_relationships 

2799 and not element.dimensions.isdisjoint(unmatched_sharding) 

2800 and not element.dimensions.isdisjoint(unmatched_object) 

2801 ): 

2802 return True 

2803 return False