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

746 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-24 01:12 -0800

1# This file is part of pipe_base. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This software is dual licensed under the GNU General Public License and also 

10# under a 3-clause BSD license. Recipients may choose which of these licenses 

11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt, 

12# respectively. If you choose the GPL option then the following text applies 

13# (but note that there is still no warranty even if you opt for BSD instead): 

14# 

15# This program is free software: you can redistribute it and/or modify 

16# it under the terms of the GNU General Public License as published by 

17# the Free Software Foundation, either version 3 of the License, or 

18# (at your option) any later version. 

19# 

20# This program is distributed in the hope that it will be useful, 

21# but WITHOUT ANY WARRANTY; without even the implied warranty of 

22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

23# GNU General Public License for more details. 

24# 

25# You should have received a copy of the GNU General Public License 

26# along with this program. If not, see <http://www.gnu.org/licenses/>. 

27from __future__ import annotations 

28 

29__all__ = ("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 Returns 

901 ------- 

902 None 

903 

904 Raises 

905 ------ 

906 ValueError 

907 Raised if ``assume_edges_unchanged`` and ``check_edges_unchanged`` 

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

909 EdgesChangedError 

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

911 change. 

912 

913 Notes 

914 ----- 

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

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

917 will be unresolved. 

918 """ 

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

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

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

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

923 updates = { 

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

925 for task_label, config in new_configs.items() 

926 } 

927 self._replace_task_nodes( 

928 updates, 

929 check_edges_unchanged=check_edges_unchanged, 

930 assume_edges_unchanged=assume_edges_unchanged, 

931 message_header=( 

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

933 "new configs (B):" 

934 ), 

935 ) 

936 

937 def remove_tasks( 

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

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

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

941 

942 Parameters 

943 ---------- 

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

945 Iterable of the labels of the tasks to remove. 

946 drop_from_subsets : `bool`, optional 

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

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

949 such subsets exist. 

950 

951 Returns 

952 ------- 

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

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

955 referenced them. 

956 

957 Raises 

958 ------ 

959 PipelineGraphError 

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

961 part of one or more subsets. 

962 

963 Notes 

964 ----- 

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

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

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

968 """ 

969 task_nodes_and_subsets = [] 

970 dataset_types: set[NodeKey] = set() 

971 nodes_to_remove = set() 

972 for label in labels: 

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

974 # Find task subsets that reference this task. 

975 referencing_subsets = { 

976 subset_label 

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

978 if label in task_subset 

979 } 

980 if not drop_from_subsets and referencing_subsets: 

981 raise PipelineGraphError( 

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

983 ) 

984 task_nodes_and_subsets.append((task_node, referencing_subsets)) 

985 # Find dataset types referenced by this task. 

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

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

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

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

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

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

992 dataset_types.remove(task_node.init.key) 

993 dataset_types.remove(task_node.key) 

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

995 nodes_to_remove.add(task_node.key) 

996 nodes_to_remove.add(task_node.init.key) 

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

998 # need to be removed vs. just unresolved. 

999 nodes_to_unresolve = [] 

1000 for dataset_type_key in dataset_types: 

1001 related_tasks = set() 

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

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

1004 related_tasks.difference_update(nodes_to_remove) 

1005 if not related_tasks: 

1006 nodes_to_remove.add(dataset_type_key) 

1007 else: 

1008 nodes_to_unresolve.append(dataset_type_key) 

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

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

1011 # safety. Start by resetting the sort ordering. 

1012 self._reset() 

1013 try: 

1014 for dataset_type_key in nodes_to_unresolve: 

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

1016 for task_node, referencing_subsets in task_nodes_and_subsets: 

1017 for subset_label in referencing_subsets: 

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

1019 self._xgraph.remove_nodes_from(nodes_to_remove) 

1020 except Exception as err: # pragma: no cover 

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

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

1023 raise PipelineGraphExceptionSafetyError( 

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

1025 ) from err 

1026 return task_nodes_and_subsets 

1027 

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

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

1030 

1031 Parameters 

1032 ---------- 

1033 subset_label : `str` 

1034 Label for this set of tasks. 

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

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

1037 included in the graph. 

1038 description : `str`, optional 

1039 String description to associate with this label. 

1040 """ 

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

1042 self._task_subsets[subset_label] = subset 

1043 

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

1045 """Remove a labeled set of tasks. 

1046 

1047 Parameters 

1048 ---------- 

1049 subset_label : `str` 

1050 Label for this set of tasks. 

1051 

1052 Notes 

1053 ----- 

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

1055 """ 

1056 try: 

1057 self._step_definitions.remove(subset_label) 

1058 except KeyError: 

1059 pass 

1060 del self._task_subsets[subset_label] 

1061 

1062 ########################################################################### 

1063 # 

1064 # NetworkX Export Interface: 

1065 # 

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

1067 # thereof) as NetworkX objects. 

1068 # 

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

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

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

1072 # attributes regardless of their structure. 

1073 # 

1074 ########################################################################### 

1075 

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

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

1078 including both init and runtime edges. 

1079 

1080 Returns 

1081 ------- 

1082 xgraph : `networkx.MultiDiGraph` 

1083 Directed acyclic graph with parallel edges. 

1084 

1085 Notes 

1086 ----- 

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

1088 represent the same dataset type appearing in multiple connections for 

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

1090 edge key to disambiguate those parallel edges. 

1091 

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

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

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

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

1096 subgraphs are bipartite. 

1097 

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

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

1100 """ 

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

1102 

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

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

1105 init-time pipeline graph. 

1106 

1107 Parameters 

1108 ---------- 

1109 init : `bool`, optional 

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

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

1112 of the graph of runtime task nodes and regular 

1113 input/output/prerequisite dataset types. 

1114 

1115 Returns 

1116 ------- 

1117 xgraph : `networkx.MultiDiGraph` 

1118 Directed acyclic graph with parallel edges. 

1119 

1120 Notes 

1121 ----- 

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

1123 represent the same dataset type appearing in multiple connections for 

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

1125 edge key to disambiguate those parallel edges. 

1126 

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

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

1129 

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

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

1132 """ 

1133 return self._transform_xgraph_state( 

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

1135 ) 

1136 

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

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

1139 

1140 Parameters 

1141 ---------- 

1142 init : `bool`, optional 

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

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

1145 

1146 Returns 

1147 ------- 

1148 xgraph : `networkx.DiGraph` 

1149 Directed acyclic graph with no parallel edges. 

1150 

1151 Notes 

1152 ----- 

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

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

1155 attributes, and there are no parallel edges. 

1156 

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

1158 attributes added. 

1159 """ 

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

1161 

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

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

1164 pipeline. 

1165 

1166 Parameters 

1167 ---------- 

1168 init : `bool`, optional 

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

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

1171 output, prerequisite input) dataset types. 

1172 

1173 Returns 

1174 ------- 

1175 xgraph : `networkx.DiGraph` 

1176 Directed acyclic graph with no parallel edges. 

1177 

1178 Notes 

1179 ----- 

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

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

1182 and there are no parallel edges. 

1183 

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

1185 """ 

1186 bipartite_xgraph = self._make_bipartite_xgraph_internal(init) 

1187 dataset_type_keys = [ 

1188 key 

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

1190 if bipartite == NodeType.DATASET_TYPE.bipartite 

1191 ] 

1192 return self._transform_xgraph_state( 

1193 networkx.algorithms.bipartite.projected_graph( 

1194 networkx.DiGraph(bipartite_xgraph), dataset_type_keys 

1195 ), 

1196 skip_edges=True, 

1197 ) 

1198 

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

1200 # 

1201 # Expression-based Selection Interface. 

1202 # 

1203 ########################################################################### 

1204 

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

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

1207 

1208 Parameters 

1209 ---------- 

1210 expression : `str` 

1211 String expression to evaluate. See 

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

1213 

1214 Returns 

1215 ------- 

1216 task_labels : `set` [ `str` ] 

1217 Set of matching task labels. 

1218 """ 

1219 task_xgraph = self._make_task_xgraph_internal(init=False) 

1220 expr_tree = expressions.parse(expression) 

1221 matching_task_keys = self._select_expression(expr_tree, task_xgraph) 

1222 return {key.name for key in matching_task_keys} 

1223 

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

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

1226 

1227 Parameters 

1228 ---------- 

1229 expression : `str` 

1230 String expression to evaluate. See 

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

1232 

1233 Returns 

1234 ------- 

1235 new_graph : `PipelineGraph` 

1236 New pipeline graph with just the matching tasks. 

1237 

1238 Notes 

1239 ----- 

1240 All resolved dataset type nodes will be preserved. 

1241 

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

1243 

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

1245 """ 

1246 selected_tasks = self.select_tasks(expression) 

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

1248 new_pipeline_graph.add_task_nodes( 

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

1250 ) 

1251 if self.has_been_sorted: 

1252 new_pipeline_graph.sort() 

1253 return new_pipeline_graph 

1254 

1255 ########################################################################### 

1256 # 

1257 # Serialization Interface. 

1258 # 

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

1260 # retained in the future. All serialization methods are 

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

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

1263 # 

1264 ########################################################################### 

1265 

1266 @classmethod 

1267 def _read_stream( 

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

1269 ) -> PipelineGraph: 

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

1271 

1272 Parameters 

1273 ---------- 

1274 stream : `BinaryIO` 

1275 File-like object opened for binary reading, containing 

1276 gzip-compressed JSON. 

1277 import_mode : `TaskImportMode`, optional 

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

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

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

1281 same. 

1282 

1283 Returns 

1284 ------- 

1285 graph : `PipelineGraph` 

1286 Deserialized pipeline graph. 

1287 

1288 Raises 

1289 ------ 

1290 PipelineGraphReadError 

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

1292 EdgesChangedError 

1293 Raised if ``import_mode`` is 

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

1295 did change after import and reconfiguration. 

1296 

1297 Notes 

1298 ----- 

1299 `PipelineGraph` serialization is currently experimental and may be 

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

1301 period. 

1302 """ 

1303 from .io import SerializedPipelineGraph 

1304 

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

1306 data = json.load(uncompressed_stream) 

1307 serialized_graph = SerializedPipelineGraph.model_validate(data) 

1308 return serialized_graph.deserialize(import_mode) 

1309 

1310 @classmethod 

1311 def _read_uri( 

1312 cls, 

1313 uri: ResourcePathExpression, 

1314 import_mode: TaskImportMode = TaskImportMode.REQUIRE_CONSISTENT_EDGES, 

1315 ) -> PipelineGraph: 

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

1317 

1318 Parameters 

1319 ---------- 

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

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

1322 graph. 

1323 import_mode : `TaskImportMode`, optional 

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

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

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

1327 same. 

1328 

1329 Returns 

1330 ------- 

1331 graph : `PipelineGraph` 

1332 Deserialized pipeline graph. 

1333 

1334 Raises 

1335 ------ 

1336 PipelineGraphReadError 

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

1338 EdgesChangedError 

1339 Raised if ``import_mode`` is 

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

1341 did change after import and reconfiguration. 

1342 

1343 Notes 

1344 ----- 

1345 `PipelineGraph` serialization is currently experimental and may be 

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

1347 period. 

1348 """ 

1349 uri = ResourcePath(uri) 

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

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

1352 

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

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

1355 

1356 Parameters 

1357 ---------- 

1358 stream 

1359 File-like object opened for binary writing. 

1360 

1361 Notes 

1362 ----- 

1363 `PipelineGraph` serialization is currently experimental and may be 

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

1365 period. 

1366 

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

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

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

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

1371 """ 

1372 from .io import SerializedPipelineGraph 

1373 

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

1375 compressed_stream.write( 

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

1377 ) 

1378 

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

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

1381 

1382 Parameters 

1383 ---------- 

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

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

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

1387 

1388 Notes 

1389 ----- 

1390 `PipelineGraph` serialization is currently experimental and may be 

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

1392 period. 

1393 

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

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

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

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

1398 """ 

1399 uri = ResourcePath(uri) 

1400 extension = uri.getExtension() 

1401 if not extension: 

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

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

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

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

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

1407 

1408 def _import_and_configure( 

1409 self, import_mode: TaskImportMode = TaskImportMode.REQUIRE_CONSISTENT_EDGES 

1410 ) -> None: 

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

1412 update those nodes accordingly. 

1413 

1414 Parameters 

1415 ---------- 

1416 import_mode : `TaskImportMode`, optional 

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

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

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

1420 same. This method does nothing if this is 

1421 `TaskImportMode.DO_NOT_IMPORT`. 

1422 

1423 Raises 

1424 ------ 

1425 EdgesChangedError 

1426 Raised if ``import_mode`` is 

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

1428 did change after import and reconfiguration. 

1429 

1430 Notes 

1431 ----- 

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

1433 deserialized without importing and configuring immediately, which is 

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

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

1436 

1437 Importing and configuring a task can change its 

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

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

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

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

1442 left behind). These changes are allowed by 

1443 `TaskImportMode.REQUIRE_CONSISTENT_EDGES`. 

1444 

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

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

1447 unresolved state. 

1448 """ 

1449 if import_mode is TaskImportMode.DO_NOT_IMPORT: 

1450 return 

1451 rebuild = ( 

1452 import_mode is TaskImportMode.REQUIRE_CONSISTENT_EDGES 

1453 or import_mode is TaskImportMode.OVERRIDE_EDGES 

1454 ) 

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

1456 node_key: NodeKey 

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

1458 if node_key.node_type is NodeType.TASK: 

1459 task_node: TaskNode = node_state["instance"] 

1460 new_task_node = task_node._imported_and_configured(rebuild) 

1461 if new_task_node is not task_node: 

1462 updates[task_node.label] = new_task_node 

1463 self._replace_task_nodes( 

1464 updates, 

1465 check_edges_unchanged=(import_mode is TaskImportMode.REQUIRE_CONSISTENT_EDGES), 

1466 assume_edges_unchanged=(import_mode is TaskImportMode.ASSUME_CONSISTENT_EDGES), 

1467 message_header=( 

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

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

1470 ), 

1471 ) 

1472 

1473 ########################################################################### 

1474 # 

1475 # Advanced PipelineGraph Inspection Interface: 

1476 # 

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

1478 # 

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

1480 # which are important operations for QuantumGraph generation. 

1481 # 

1482 ########################################################################### 

1483 

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

1485 """Iterate over edges in the graph. 

1486 

1487 Parameters 

1488 ---------- 

1489 init : `bool`, optional 

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

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

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

1493 dataset types. 

1494 

1495 Returns 

1496 ------- 

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

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

1499 

1500 Notes 

1501 ----- 

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

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

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

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

1506 the init-only or runtime-only subgraphs. 

1507 """ 

1508 edge: Edge 

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

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

1511 yield edge 

1512 

1513 def iter_nodes( 

1514 self, 

1515 ) -> Iterator[ 

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

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

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

1519 ]: 

1520 """Iterate over nodes in the graph. 

1521 

1522 Returns 

1523 ------- 

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

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

1526 element is a tuple of: 

1527 

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

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

1530 name); 

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

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

1533 """ 

1534 key: NodeKey 

1535 if self._sorted_keys is not None: 

1536 for key in self._sorted_keys: 

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

1538 else: 

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

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

1541 

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

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

1544 produced by the graph. 

1545 

1546 Returns 

1547 ------- 

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

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

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

1551 tuple of: 

1552 

1553 - the parent dataset type name; 

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

1555 not been resolved. 

1556 """ 

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

1558 key: NodeKey 

1559 for key in generation: 

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

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

1562 # is not true of task init nodes. 

1563 if key.node_type is NodeType.DATASET_TYPE: 

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

1565 return 

1566 

1567 def group_by_dimensions( 

1568 self, prerequisites: bool = False 

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

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

1571 

1572 Parameters 

1573 ---------- 

1574 prerequisites : `bool`, optional 

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

1576 input and output datasets (including intermediates). 

1577 

1578 Returns 

1579 ------- 

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

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

1582 in which each value is a tuple of: 

1583 

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

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

1586 dataset type name. 

1587 

1588 that have those dimensions. 

1589 

1590 Notes 

1591 ----- 

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

1593 dimensions and are hence are all grouped together. 

1594 """ 

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

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

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

1598 if task_node.dimensions is None: 

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

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

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

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

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

1604 if dataset_type_node is None: 

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

1606 if not dataset_type_node.is_prerequisite or prerequisites: 

1607 if ( 

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

1609 ) is next_new_value: 

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

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

1612 return result 

1613 

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

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

1616 

1617 Parameters 

1618 ---------- 

1619 prerequisites : `bool`, optional 

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

1621 prerequisite input dataset types. 

1622 

1623 Returns 

1624 ------- 

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

1626 All dimensions in this pipeline. 

1627 """ 

1628 return DimensionGroup.union( 

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

1630 universe=self.universe, 

1631 ) 

1632 

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

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

1635 pipeline graph. 

1636 

1637 Returns 

1638 ------- 

1639 subgraphs : `Iterable` [ `PipelineGraph` ] 

1640 An iterable over component subgraphs that could be run 

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

1642 lazy iterator. 

1643 

1644 Notes 

1645 ----- 

1646 All resolved dataset type nodes will be preserved. 

1647 

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

1649 element in the iterable. 

1650 

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

1652 

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

1654 """ 

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

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

1657 # subgraphs of the task-only projected graph. 

1658 bipartite_xgraph = self._make_bipartite_xgraph_internal(init=False) 

1659 task_keys = { 

1660 key 

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

1662 if bipartite == NodeType.TASK.bipartite 

1663 } 

1664 task_xgraph = networkx.algorithms.bipartite.projected_graph( 

1665 networkx.DiGraph(bipartite_xgraph), task_keys 

1666 ) 

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

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

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

1670 if component_task_keys == task_keys: 

1671 yield self 

1672 return 

1673 else: 

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

1675 component_subgraph.add_task_nodes( 

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

1677 ) 

1678 if self.has_been_sorted: 

1679 component_subgraph.sort() 

1680 yield component_subgraph 

1681 

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

1683 # 

1684 # Data repository/collection initialization 

1685 # 

1686 ########################################################################### 

1687 

1688 @property 

1689 def packages_dataset_type(self) -> DatasetType: 

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

1691 

1692 This is not associated with a task and hence is 

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

1694 does get written with other provenance datasets. 

1695 """ 

1696 if self._universe is None: 

1697 raise UnresolvedGraphError( 

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

1699 ) 

1700 return DatasetType( 

1701 PACKAGES_INIT_OUTPUT_NAME, self._universe.empty, PACKAGES_INIT_OUTPUT_STORAGE_CLASS 

1702 ) 

1703 

1704 def register_dataset_types( 

1705 self, 

1706 butler: Butler, 

1707 include_packages: bool = True, 

1708 *, 

1709 include_inputs: bool = True, 

1710 include_configs: bool = True, 

1711 include_logs: bool = True, 

1712 ) -> None: 

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

1714 

1715 Parameters 

1716 ---------- 

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

1718 Data repository client. 

1719 include_packages : `bool`, optional 

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

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

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

1723 does get written with other provenance datasets). 

1724 include_inputs : `bool`, optional 

1725 Whether to register overall-input dataset types as well as outputs. 

1726 include_configs : `bool`, optional 

1727 Whether to register task config dataset types. 

1728 include_logs : `bool`, optional 

1729 Whether to register task log dataset types. 

1730 """ 

1731 dataset_types = { 

1732 node.name: node.dataset_type 

1733 for node in self.dataset_types.values() 

1734 if include_inputs or self.producer_of(node.name) is not None 

1735 } 

1736 if include_packages: 

1737 dataset_types[self.packages_dataset_type.name] = self.packages_dataset_type 

1738 if not include_configs: 

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

1740 del dataset_types[task_node.init.config_output.dataset_type_name] 

1741 if not include_logs: 

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

1743 if task_node.log_output is not None: 

1744 del dataset_types[task_node.log_output.dataset_type_name] 

1745 for dataset_type in dataset_types.values(): 

1746 butler.registry.registerDatasetType(dataset_type) 

1747 

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

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

1750 the definitions in this pipeline graph. 

1751 

1752 Parameters 

1753 ---------- 

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

1755 Data repository client. 

1756 include_packages : `bool`, optional 

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

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

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

1760 does get written with other provenance datasets). 

1761 

1762 Returns 

1763 ------- 

1764 None 

1765 

1766 Raises 

1767 ------ 

1768 lsst.daf.butler.MissingDatasetTypeError 

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

1770 the pipeline is not registered at all. 

1771 lsst.daf.butler.ConflictingDefinitionError 

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

1773 to the definition in the pipeline graph. 

1774 

1775 Notes 

1776 ----- 

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

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

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

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

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

1782 changed. 

1783 """ 

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

1785 if include_packages: 

1786 dataset_types.append(self.packages_dataset_type) 

1787 missing_dataset_types: list[str] = [] 

1788 for dataset_type in dataset_types: 

1789 try: 

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

1791 except MissingDatasetTypeError: 

1792 expected = None 

1793 if expected is None: 

1794 # The user probably forgot to register dataset types 

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

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

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

1798 if ( 

1799 not ( 

1800 self.producer_of(dataset_type.name) is None 

1801 and all( 

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

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

1804 ) 

1805 ) 

1806 or dataset_type.name == PACKAGES_INIT_OUTPUT_NAME 

1807 ): 

1808 missing_dataset_types.append(dataset_type.name) 

1809 elif expected != dataset_type: 

1810 raise ConflictingDefinitionError( 

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

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

1813 ) 

1814 if missing_dataset_types: 

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

1816 raise MissingDatasetTypeError( 

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

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

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

1820 ) 

1821 

1822 def instantiate_tasks( 

1823 self, 

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

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

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

1827 ) -> list[PipelineTask]: 

1828 """Instantiate all tasks in the pipeline. 

1829 

1830 Parameters 

1831 ---------- 

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

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

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

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

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

1837 inputs. When a full butler is available, 

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

1839 init_outputs : `list`, optional 

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

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

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

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

1844 type node. 

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

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

1847 the graph will be instantiated. 

1848 

1849 Returns 

1850 ------- 

1851 tasks : `list` 

1852 Constructed `PipelineTask` instances. 

1853 """ 

1854 if not self.is_fully_resolved: 

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

1856 empty_data_id = DataCoordinate.make_empty(self.universe) 

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

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

1859 tasks: list[PipelineTask] = [] 

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

1861 if task_node.label not in labels: 

1862 continue 

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

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

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

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

1867 elif ( 

1868 read_edge.component is not None 

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

1870 ): 

1871 obj = parent_handle.get( 

1872 storageClass=read_edge.storage_class_name, component=read_edge.component 

1873 ) 

1874 else: 

1875 dataset_type_node = self.dataset_types[read_edge.parent_dataset_type_name] 

1876 if get_init_input is None: 

1877 raise ValueError( 

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

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

1880 ) 

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

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

1883 if ( 

1884 n_consumers > 1 

1885 and read_edge.component is None 

1886 and read_edge.storage_class_name == dataset_type_node.storage_class_name 

1887 ): 

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

1889 # if there was no storage class conversion, since 

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

1891 handles[read_edge.dataset_type_name] = InMemoryDatasetHandle( 

1892 obj, 

1893 storageClass=dataset_type_node.storage_class, 

1894 dataId=empty_data_id, 

1895 copy=True, 

1896 ) 

1897 task_init_inputs[read_edge.connection_name] = obj 

1898 task = task_node.task_class( 

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

1900 ) 

1901 tasks.append(task) 

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

1903 dataset_type_node = self.dataset_types[write_edge.parent_dataset_type_name] 

1904 obj = getattr(task, write_edge.connection_name) 

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

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

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

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

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

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

1911 # though, because going through the dataset_type_node storage 

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

1913 # pipeline graph. 

1914 if init_outputs is not None: 

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

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

1917 if n_consumers > 0: 

1918 handles[dataset_type_node.name] = InMemoryDatasetHandle( 

1919 dataset_type_node.storage_class.coerce_type(obj), 

1920 dataId=empty_data_id, 

1921 storageClass=dataset_type_node.storage_class, 

1922 copy=(n_consumers > 1), 

1923 ) 

1924 return tasks 

1925 

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

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

1928 

1929 Parameters 

1930 ---------- 

1931 butler : `lsst.daf.butler.Butler` 

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

1933 to the collection where datasets should be written. 

1934 

1935 Notes 

1936 ----- 

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

1938 not be written. 

1939 

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

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

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

1943 graph has already been built. 

1944 """ 

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

1946 self.instantiate_tasks(butler.get, init_outputs) 

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

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

1949 for obj, dataset_type in init_outputs: 

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

1951 found_refs[dataset_type.name] = ref 

1952 else: 

1953 to_put.append((obj, dataset_type)) 

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

1955 if not stored: 

1956 raise FileNotFoundError( 

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

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

1959 ) 

1960 for obj, dataset_type in to_put: 

1961 butler.put(obj, dataset_type) 

1962 

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

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

1965 

1966 Parameters 

1967 ---------- 

1968 butler : `lsst.daf.butler.Butler` 

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

1970 to the collection where datasets should be written. 

1971 

1972 Notes 

1973 ----- 

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

1975 collection will be checked for consistency. 

1976 

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

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

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

1980 has already been built. 

1981 

1982 Raises 

1983 ------ 

1984 lsst.daf.butler.registry.ConflictingDefinitionError 

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

1986 with the config in the pipeline graph. 

1987 """ 

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

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

1990 dataset_type_name = task_node.init.config_output.dataset_type_name 

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

1992 old_config = butler.get(ref) 

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

1994 raise ConflictingDefinitionError( 

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

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

1997 ) 

1998 else: 

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

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

2001 # raise an exception. 

2002 for config, dataset_type_name in to_put: 

2003 butler.put(config, dataset_type_name) 

2004 

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

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

2007 versions. 

2008 

2009 Parameters 

2010 ---------- 

2011 butler : `lsst.daf.butler.Butler` 

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

2013 to the collection where datasets should be written. 

2014 

2015 Notes 

2016 ----- 

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

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

2019 before are not considered an inconsistency. 

2020 

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

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

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

2024 has already been built. 

2025 

2026 Raises 

2027 ------ 

2028 lsst.daf.butler.registry.ConflictingDefinitionError 

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

2030 with the current packages. 

2031 """ 

2032 new_packages = Packages.fromSystem() 

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

2034 packages = butler.get(ref) 

2035 if compare_packages(packages, new_packages): 

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

2037 # replace option. 

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

2039 butler.put(packages, ref) 

2040 else: 

2041 butler.put(new_packages, self.packages_dataset_type) 

2042 

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

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

2045 datasets (including configs and packages). 

2046 

2047 Parameters 

2048 ---------- 

2049 butler : `lsst.daf.butler.Butler` 

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

2051 to the collection where datasets should be written. 

2052 """ 

2053 self.write_configs(butler) 

2054 self.write_packages(butler) 

2055 self.write_init_outputs(butler) 

2056 

2057 ########################################################################### 

2058 # 

2059 # Class- and Package-Private Methods. 

2060 # 

2061 ########################################################################### 

2062 

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

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

2065 

2066 Notes 

2067 ----- 

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

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

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

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

2072 `PipelineGraph` as well. 

2073 

2074 Raises 

2075 ------ 

2076 TaskNotImportedError 

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

2078 """ 

2079 from ..pipeline import TaskDef 

2080 

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

2082 yield TaskDef( 

2083 config=node.config, 

2084 taskClass=node.task_class, 

2085 label=node.label, 

2086 connections=node.get_connections(), 

2087 ) 

2088 

2089 def _init_from_args( 

2090 self, 

2091 xgraph: networkx.MultiDiGraph | None, 

2092 sorted_keys: Sequence[NodeKey] | None, 

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

2094 description: str, 

2095 universe: DimensionUniverse | None, 

2096 data_id: DataId | None, 

2097 step_definitions: StepDefinitions, 

2098 ) -> None: 

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

2100 

2101 Parameters 

2102 ---------- 

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

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

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

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

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

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

2109 `WriteEdge` instance. 

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

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

2112 is not sorted. 

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

2114 Labeled subsets of tasks. Values must be constructed with 

2115 ``xgraph`` as their parent graph. 

2116 description : `str` 

2117 String description for this pipeline. 

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

2119 Definitions of all dimensions. 

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

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

2122 this pipeline. 

2123 step_definitions : `StepDefinitions` 

2124 Struct holding information about steps. 

2125 

2126 Notes 

2127 ----- 

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

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

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

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

2132 manually, skipping ``__init__``. 

2133 """ 

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

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

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

2137 self._description = description 

2138 self._tasks = TaskMappingView(self._xgraph) 

2139 self._dataset_types = DatasetTypeMappingView(self._xgraph) 

2140 self._raw_data_id: dict[str, Any] 

2141 if isinstance(data_id, DataCoordinate): 

2142 if universe is None: 

2143 universe = data_id.universe 

2144 else: 

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

2146 self._raw_data_id = dict(data_id.required) 

2147 elif data_id is None: 

2148 self._raw_data_id = {} 

2149 else: 

2150 self._raw_data_id = dict(data_id) 

2151 self._universe = universe 

2152 if sorted_keys is not None: 

2153 self._reorder(sorted_keys) 

2154 self._step_definitions = step_definitions 

2155 

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

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

2158 

2159 See `make_bipartite_xgraph` for parameters and return values. 

2160 

2161 Notes 

2162 ----- 

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

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

2165 result either explicitly or by running a copying algorithm before 

2166 returning it to the user. 

2167 """ 

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

2169 

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

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

2172 

2173 See `make_task_xgraph` for parameters and return values. 

2174 

2175 Notes 

2176 ----- 

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

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

2179 result either explicitly or by running a copying algorithm before 

2180 returning it to the user. 

2181 """ 

2182 bipartite_xgraph = self._make_bipartite_xgraph_internal(init=init) 

2183 task_keys = [ 

2184 key 

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

2186 if bipartite == NodeType.TASK.bipartite 

2187 ] 

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

2189 

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

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

2192 "instance" attributes to the documented exported attributes. 

2193 

2194 Parameters 

2195 ---------- 

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

2197 Graph whose state should be transformed. 

2198 skip_edges : `bool` 

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

2200 

2201 Returns 

2202 ------- 

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

2204 The same object passed in, after modification. 

2205 

2206 Notes 

2207 ----- 

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

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

2210 it assumes stateful edges. 

2211 """ 

2212 state: dict[str, Any] 

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

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

2215 if node_value is not None: 

2216 state.update(node_value._to_xgraph_state()) 

2217 else: 

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

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

2220 if not skip_edges: 

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

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

2223 if edge is not None: 

2224 state.update(edge._to_xgraph_state()) 

2225 return xgraph 

2226 

2227 def _replace_task_nodes( 

2228 self, 

2229 updates: Mapping[str, TaskNode], 

2230 check_edges_unchanged: bool, 

2231 assume_edges_unchanged: bool, 

2232 message_header: str, 

2233 ) -> None: 

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

2235 accordingly. 

2236 

2237 Parameters 

2238 ---------- 

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

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

2241 that are already present in the graph. 

2242 check_edges_unchanged : `bool`, optional 

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

2244 remain unchanged after importing and configuring each task, and 

2245 verify that this is the case. 

2246 assume_edges_unchanged : `bool`, optional 

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

2248 modified tasks will remain unchanged importing and configuring each 

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

2250 message_header : `str` 

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

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

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

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

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

2256 

2257 Raises 

2258 ------ 

2259 ValueError 

2260 Raised if ``assume_edges_unchanged`` and ``check_edges_unchanged`` 

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

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

2263 EdgesChangedError 

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

2265 change. 

2266 """ 

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

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

2269 if assume_edges_unchanged: 

2270 if check_edges_unchanged: 

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

2272 shallow.update(updates) 

2273 else: 

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

2275 old_task_node = self.tasks[task_label] 

2276 messages = old_task_node.diff_edges(new_task_node) 

2277 if messages: 

2278 if check_edges_unchanged: 

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

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

2281 else: 

2282 deep[task_label] = new_task_node 

2283 else: 

2284 shallow[task_label] = new_task_node 

2285 try: 

2286 if deep: 

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

2288 self.add_task_nodes(deep.values()) 

2289 for replaced_task_node, referencing_subsets in removed: 

2290 for subset_label in referencing_subsets: 

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

2292 for task_node in shallow.values(): 

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

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

2295 except PipelineGraphExceptionSafetyError: # pragma: no cover 

2296 raise 

2297 except Exception as err: # pragma: no cover 

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

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

2300 raise PipelineGraphExceptionSafetyError( 

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

2302 ) from err 

2303 

2304 def _append_graph_data_from_edge( 

2305 self, 

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

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

2308 edge: Edge, 

2309 parent: PipelineGraph | None, 

2310 ) -> None: 

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

2312 dataset type node. 

2313 

2314 Parameters 

2315 ---------- 

2316 node_data : `list` 

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

2318 one does not already exist for this dataset type. 

2319 edge_data : `list` 

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

2321 for edges. 

2322 edge : `Edge` 

2323 New edge being processed. 

2324 parent : `PipelineGraph` or `None` 

2325 Another pipeline graph whose dataset type nodes should be used 

2326 when present. 

2327 """ 

2328 new_dataset_type_node = None 

2329 if parent is not None: 

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

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

2332 existing_dataset_type_state["instance"] = new_dataset_type_node 

2333 else: 

2334 node_data.append( 

2335 ( 

2336 edge.dataset_type_key, 

2337 { 

2338 "instance": new_dataset_type_node, 

2339 "bipartite": NodeType.DATASET_TYPE.bipartite, 

2340 }, 

2341 ) 

2342 ) 

2343 edge_data.append( 

2344 edge.nodes 

2345 + ( 

2346 edge.connection_name, 

2347 {"instance": edge}, 

2348 ) 

2349 ) 

2350 

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

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

2353 sequence of task labels and dataset type names. 

2354 """ 

2355 self._sorted_keys = sorted_keys 

2356 self._tasks._reorder(sorted_keys) 

2357 self._dataset_types._reorder(sorted_keys) 

2358 

2359 def _reset(self) -> None: 

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

2361 invalidate them. 

2362 """ 

2363 self._sorted_keys = None 

2364 self._tasks._reset() 

2365 self._dataset_types._reset() 

2366 

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

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

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

2370 

2371 Returns 

2372 ------- 

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

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

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

2376 pipeline. 

2377 """ 

2378 if not self._step_definitions: 

2379 return None 

2380 task_labels_so_far: set[str] = set() 

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

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

2383 sort_keys: list[NodeKey] = [] 

2384 keys_already_sorted: set[NodeKey] = set() 

2385 for step_label in self.steps: 

2386 try: 

2387 task_subset = self.task_subsets[step_label] 

2388 except KeyError: 

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

2390 if not task_labels_so_far.isdisjoint(task_subset): 

2391 raise InvalidStepsError( 

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

2393 ) 

2394 task_labels_so_far.update(task_subset) 

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

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

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

2398 # cumulative one later. 

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

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

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

2402 new_step_keys: set[NodeKey] = set() 

2403 for task_label in task_subset: 

2404 for input_name in self.inputs_of(task_label): 

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

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

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

2408 task_outputs = self.outputs_of(task_label) 

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

2410 msg: list[str] = [] 

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

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

2413 raise InvalidStepsError( 

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

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

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

2417 ) 

2418 task_init_key = NodeKey(NodeType.TASK_INIT, task_label) 

2419 task_key = NodeKey(NodeType.TASK, task_label) 

2420 new_step_keys.add(task_init_key) 

2421 new_step_keys.add(task_key) 

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

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

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

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

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

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

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

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

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

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

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

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

2434 # sort_keys. 

2435 new_step_keys.difference_update(keys_already_sorted) 

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

2437 # with result. 

2438 step_xgraph = self._xgraph.subgraph(new_step_keys) 

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

2440 keys_already_sorted.update(new_step_keys) 

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

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

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

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

2445 # look up inputs and outputs of each task. 

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

2447 return sort_keys 

2448 

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

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

2451 output dataset dimensions. 

2452 

2453 Parameters 

2454 ---------- 

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

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

2457 definitions by this method. 

2458 """ 

2459 self._step_definitions._universe = universe 

2460 for step_label in self.steps: 

2461 dimensions = self.steps.get_dimensions(step_label) 

2462 for task_label in self.task_subsets[step_label]: 

2463 task_node = self.tasks[task_label] 

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

2465 raise InvalidStepsError( 

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

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

2468 ) 

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

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

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

2472 raise InvalidStepsError( 

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

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

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

2476 f"{step_label!r}." 

2477 ) 

2478 

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

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

2481 

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

2483 `select_tasks`. 

2484 

2485 Parameters 

2486 ---------- 

2487 expr_tree : `expressions.Node` 

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

2489 task_xgraph : `networkx.DiGraph` 

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

2491 

2492 Returns 

2493 ------- 

2494 selected : `set` [ `NodeKey` ] 

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

2496 or task-init nodes). 

2497 """ 

2498 match expr_tree: 

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

2500 match self._select_identifier(qualifier, label): 

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

2502 return {task_key} 

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

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

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

2506 for producer_key, _ in self._xgraph.in_edges(dataset_type_key): 

2507 if producer_key.node_type is NodeType.TASK_INIT: 

2508 raise InvalidExpressionError( 

2509 f"Init-output dataset type {label!r} cannot be used directly in an " 

2510 "expression." 

2511 ) 

2512 return {producer_key} 

2513 return set() 

2514 case TaskSubset() as task_subset: 

2515 return {NodeKey(NodeType.TASK, label) for label in task_subset} 

2516 case _: # pragma: no cover 

2517 raise AssertionError("Identifier type inconsistent with grammar.") 

2518 case expressions.DirectionNode(operator=operator, start=start): 

2519 match self._select_identifier(start.qualifier, start.label): 

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

2521 if operator.startswith("<"): 

2522 return self._select_task_ancestors( 

2523 task_key, task_xgraph, inclusive=operator.endswith("=") 

2524 ) 

2525 else: 

2526 assert operator.startswith(">"), "Guaranteed by grammar." 

2527 return self._select_task_descendants( 

2528 task_key, task_xgraph, inclusive=operator.endswith("=") 

2529 ) 

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

2531 if operator.startswith("<"): 

2532 return self._select_dataset_type_ancestors( 

2533 dataset_type_key, task_xgraph, inclusive=operator.endswith("=") 

2534 ) 

2535 else: 

2536 assert operator.startswith(">"), "Guaranteed by grammar." 

2537 return self._select_dataset_type_descendants( 

2538 dataset_type_key, task_xgraph, inclusive=operator.endswith("=") 

2539 ) 

2540 case TaskSubset(): 

2541 raise InvalidExpressionError( 

2542 f"Task subset identifier {start!r} cannot be used as the start of an " 

2543 "ancestor/descendant search." 

2544 ) 

2545 case _: # pragma: no cover 

2546 raise AssertionError("Unexpected parsed identifier result type.") 

2547 case expressions.NotNode(operand=operand): 

2548 operand_result = self._select_expression(operand, task_xgraph) 

2549 return set(task_xgraph.nodes.keys() - operand_result) 

2550 case expressions.UnionNode(lhs=lhs, rhs=rhs): 

2551 lhs_result = self._select_expression(lhs, task_xgraph) 

2552 rhs_result = self._select_expression(rhs, task_xgraph) 

2553 return lhs_result.union(rhs_result) 

2554 case expressions.IntersectionNode(lhs=lhs, rhs=rhs): 

2555 lhs_result = self._select_expression(lhs, task_xgraph) 

2556 rhs_result = self._select_expression(rhs, task_xgraph) 

2557 return lhs_result.intersection(rhs_result) 

2558 case _: # pragma: no cover 

2559 raise AssertionError("Expression parse node inconsistent with grammar.") 

2560 

2561 def _select_task_ancestors( 

2562 self, start: NodeKey, task_xgraph: networkx.DiGraph, inclusive: bool 

2563 ) -> set[NodeKey]: 

2564 """Return all task-node ancestors of the given task node, as defined by 

2565 the `select` expression language. 

2566 

2567 Parameters 

2568 ---------- 

2569 start : `NodeKey` 

2570 A runtime task node key. 

2571 task_xgraph : `networkx.DiGraph` 

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

2573 inclusive : `bool` 

2574 Whether to include the ``start`` node in the results. 

2575 

2576 Returns 

2577 ------- 

2578 selected : `set` [ `NodeKey` ] 

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

2580 or task-init nodes). 

2581 """ 

2582 result = set(networkx.dag.ancestors(task_xgraph, start)) 

2583 if inclusive: 

2584 result.add(start) 

2585 return result 

2586 

2587 def _select_task_descendants( 

2588 self, start: NodeKey, task_xgraph: networkx.DiGraph, inclusive: bool 

2589 ) -> set[NodeKey]: 

2590 """Return all task-node descendants of the given task node, as defined 

2591 by the `select` expression language. 

2592 

2593 Parameters 

2594 ---------- 

2595 start : `NodeKey` 

2596 A runtime task node key. 

2597 task_xgraph : `networkx.DiGraph` 

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

2599 inclusive : `bool` 

2600 Whether to include the ``start`` node in the results. 

2601 

2602 Returns 

2603 ------- 

2604 selected : `set` [ `NodeKey` ] 

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

2606 or task-init nodes). 

2607 """ 

2608 result = set(networkx.dag.descendants(task_xgraph, start)) 

2609 if inclusive: 

2610 result.add(start) 

2611 return result 

2612 

2613 def _select_dataset_type_ancestors( 

2614 self, start: NodeKey, task_xgraph: networkx.DiGraph, inclusive: bool 

2615 ) -> set[NodeKey]: 

2616 """Return all task-node ancestors of the given dataset type node, as 

2617 defined by the `select` expression language. 

2618 

2619 Parameters 

2620 ---------- 

2621 start : `NodeKey` 

2622 A dataset type node key. May not be an init-output. 

2623 task_xgraph : `networkx.DiGraph` 

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

2625 inclusive : `bool` 

2626 Whether to include the producer of the ``start`` node in the 

2627 results. 

2628 

2629 Returns 

2630 ------- 

2631 selected : `set` [ `NodeKey` ] 

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

2633 or task-init nodes). 

2634 """ 

2635 result: set[NodeKey] = set() 

2636 for producer_key, _ in self._xgraph.in_edges(start): 

2637 if producer_key.node_type is NodeType.TASK_INIT: 

2638 raise InvalidExpressionError( 

2639 f"Init-output dataset type {start.name!r} cannot be used as the " 

2640 "starting point for an ancestor ('<' or '<=') search." 

2641 ) 

2642 result.update(networkx.dag.ancestors(task_xgraph, producer_key)) 

2643 if inclusive: 

2644 result.add(producer_key) 

2645 return result 

2646 

2647 def _select_dataset_type_descendants( 

2648 self, start: NodeKey, task_xgraph: networkx.DiGraph, inclusive: bool 

2649 ) -> set[NodeKey]: 

2650 """Return all task-node descendatns of the given dataset type node, as 

2651 defined by the `select` expression language. 

2652 

2653 Parameters 

2654 ---------- 

2655 start : `NodeKey` 

2656 A dataset type node key. May not be an init-output if 

2657 ``inclusive=True``. 

2658 task_xgraph : `networkx.DiGraph` 

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

2660 inclusive : `bool` 

2661 Whether to include the producer of the ``start`` node in the 

2662 results. 

2663 

2664 Returns 

2665 ------- 

2666 selected : `set` [ `NodeKey` ] 

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

2668 or task-init nodes). 

2669 """ 

2670 result: set[NodeKey] = set() 

2671 if inclusive: 

2672 for producer_key, _ in self._xgraph.in_edges(start): 

2673 if producer_key.node_type is NodeType.TASK_INIT: 

2674 raise InvalidExpressionError( 

2675 f"Init-output dataset type {start.name!r} cannot be used as the " 

2676 "starting point for an includsive descendant ('>=') search." 

2677 ) 

2678 result.add(producer_key) 

2679 # We also include tasks that consume a dataset type as an init-input, 

2680 # since that can affect their runtime behavior. 

2681 consumer_keys: set[NodeKey] = { 

2682 ( 

2683 consumer_key 

2684 if consumer_key.node_type is NodeType.TASK 

2685 else NodeKey(NodeType.TASK, consumer_key.name) 

2686 ) 

2687 for _, consumer_key in self._xgraph.out_edges(start) 

2688 } 

2689 for consumer_key in consumer_keys: 

2690 result.add(consumer_key) 

2691 result.update(networkx.dag.descendants(task_xgraph, consumer_key)) 

2692 return result 

2693 

2694 def _select_identifier( 

2695 self, qualifier: Literal["T", "D", "S"] | None, label: str 

2696 ) -> NodeKey | TaskSubset: 

2697 """Return the node key or task subset that corresponds to a `select` 

2698 expression identifier. 

2699 

2700 Parameters 

2701 ---------- 

2702 qualifier : `str` or `None` 

2703 Task, dataset type, or task subset qualifier included in the 

2704 identifier, if any. 

2705 label : `str` 

2706 Task label, dataset type name, or task subset label. 

2707 

2708 Returns 

2709 ------- 

2710 key_or_subset : `NodeKey` or `TaskSubset` 

2711 A `NodeKey` for a task or dataset type, or a `TaskSubset` for a 

2712 task subset. 

2713 """ 

2714 match qualifier: 

2715 case None: 

2716 task_key = NodeKey(NodeType.TASK, label) 

2717 dataset_type_key = NodeKey(NodeType.DATASET_TYPE, label) 

2718 if task_key in self._xgraph.nodes: 

2719 if dataset_type_key in self._xgraph.nodes: 

2720 raise InvalidExpressionError( 

2721 f"{label!r} is both a task label and a dataset type name; " 

2722 "prefix with 'T:' or 'D:' (respectively) to specify which." 

2723 ) 

2724 assert label not in self._task_subsets, "Should be prohibited at construction." 

2725 return task_key 

2726 elif dataset_type_key in self._xgraph.nodes: 

2727 if label in self._task_subsets: 

2728 raise InvalidExpressionError( 

2729 f"{label!r} is both a subset label and a dataset type name; " 

2730 "prefix with 'S:' or 'D:' (respectively) to specify which." 

2731 ) 

2732 return dataset_type_key 

2733 elif label in self._task_subsets: 

2734 return self._task_subsets[label] 

2735 else: 

2736 raise InvalidExpressionError( 

2737 f"{label!r} is not a task label, task subset label, or dataset type name." 

2738 ) 

2739 case "T": 

2740 task_key = NodeKey(NodeType.TASK, label) 

2741 if task_key not in self._xgraph.nodes: 

2742 raise InvalidExpressionError(f"Task with label {label!r} does not exist.") 

2743 return task_key 

2744 case "D": 

2745 dataset_type_key = NodeKey(NodeType.DATASET_TYPE, label) 

2746 if dataset_type_key not in self._xgraph.nodes: 

2747 raise InvalidExpressionError(f"Dataset type with name {label!r} does not exist.") 

2748 return dataset_type_key 

2749 case "S": 

2750 try: 

2751 return self._task_subsets[label] 

2752 except KeyError: 

2753 raise InvalidExpressionError(f"Task subset with label {label!r} does not exist.") 

2754 case _: # pragma: no cover 

2755 raise AssertionError("Unexpected identifier qualifier in expression.") 

2756 

2757 _xgraph: networkx.MultiDiGraph 

2758 _sorted_keys: Sequence[NodeKey] | None 

2759 _task_subsets: dict[str, TaskSubset] 

2760 _step_definitions: StepDefinitions 

2761 _description: str 

2762 _tasks: TaskMappingView 

2763 _dataset_types: DatasetTypeMappingView 

2764 _raw_data_id: dict[str, Any] 

2765 _universe: DimensionUniverse | None 

2766 

2767 

2768def log_config_mismatch(msg: str) -> None: 

2769 """Log messages about configuration mismatch. 

2770 

2771 Parameters 

2772 ---------- 

2773 msg : `str` 

2774 Log message to use. 

2775 """ 

2776 _LOG.fatal("Comparing configuration: %s", msg) 

2777 

2778 

2779def compare_packages(packages: Packages, new_packages: Packages) -> bool: 

2780 """Compare two versions of Packages. 

2781 

2782 Parameters 

2783 ---------- 

2784 packages : `Packages` 

2785 Previously recorded package versions. Updated in place to include 

2786 any new packages that weren't present before. 

2787 new_packages : `Packages` 

2788 New set of package versions. 

2789 

2790 Returns 

2791 ------- 

2792 updated : `bool` 

2793 `True` if ``packages`` was updated, `False` if not. 

2794 

2795 Raises 

2796 ------ 

2797 ConflictingDefinitionError 

2798 Raised if versions are inconsistent. 

2799 """ 

2800 diff = new_packages.difference(packages) 

2801 if diff: 

2802 versions_str = "; ".join(f"{pkg}: {diff[pkg][1]} vs {diff[pkg][0]}" for pkg in diff) 

2803 raise ConflictingDefinitionError(f"Package versions mismatch: ({versions_str})") 

2804 else: 

2805 _LOG.debug("new packages are consistent with old") 

2806 # Update the old set of packages in case we have more packages 

2807 # that haven't been persisted. 

2808 extra = new_packages.extra(packages) 

2809 if extra: 

2810 _LOG.debug("extra packages: %s", extra) 

2811 packages.update(new_packages) 

2812 return True 

2813 return False 

2814 

2815 

2816def _dimensions_compatible(dimensions: DimensionGroup, object_dimensions: DimensionGroup) -> bool: 

2817 if dimensions.issubset(object_dimensions): 

2818 # Easy typical case. 

2819 return True 

2820 # Hard case: if any sharding dimensions that are not in the object 

2821 # dimensions are related to something in the object dimensions by a 

2822 # many-to-many join table, this is okay too. The main use case here is to 

2823 # let {exposure, [detector]} satisfy sharding dimensions of 

2824 # {visit, [detector]}. 

2825 universe = dimensions.universe 

2826 unmatched_sharding = dimensions.required - object_dimensions.required 

2827 unmatched_object = object_dimensions.required - dimensions.required 

2828 unmatched_union_group = dimensions.union(object_dimensions) 

2829 for element_name in unmatched_union_group.elements: 

2830 if ( 

2831 (element := universe[element_name]).defines_relationships 

2832 and not element.dimensions.isdisjoint(unmatched_sharding) 

2833 and not element.dimensions.isdisjoint(unmatched_object) 

2834 ): 

2835 return True 

2836 return False