Coverage for python / lsst / pipe / base / pipeline_graph / _edges.py: 28%

211 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-12 01:14 -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__ = ("Edge", "ReadEdge", "WriteEdge") 

30 

31from abc import ABC, abstractmethod 

32from collections.abc import Callable, Mapping, Sequence 

33from typing import Any, ClassVar, Self, TypeVar 

34 

35from lsst.daf.butler import DatasetRef, DatasetType, DimensionUniverse, StorageClassFactory 

36from lsst.daf.butler.registry import MissingDatasetTypeError 

37from lsst.utils.classes import immutable 

38 

39from ..connectionTypes import BaseConnection 

40from ._exceptions import ConnectionTypeConsistencyError, IncompatibleDatasetTypeError 

41from ._nodes import NodeKey, NodeType 

42 

43_S = TypeVar("_S", bound="Edge") 

44 

45 

46@immutable 

47class Edge(ABC): 

48 """Base class for edges in a pipeline graph. 

49 

50 This represents the link between a task node and an input or output dataset 

51 type. 

52 

53 Parameters 

54 ---------- 

55 task_key : `NodeKey` 

56 Key for the task node this edge is connected to. 

57 dataset_type_key : `NodeKey` 

58 Key for the dataset type node this edge is connected to. 

59 storage_class_name : `str` 

60 Name of the dataset type's storage class as seen by the task. 

61 connection_name : `str` 

62 Internal name for the connection as seen by the task. 

63 is_calibration : `bool` 

64 Whether this dataset type can be included in 

65 `~lsst.daf.butler.CollectionType.CALIBRATION` collections. 

66 raw_dimensions : `frozenset` [ `str` ] 

67 Raw dimensions from the connection definition. 

68 """ 

69 

70 def __init__( 

71 self, 

72 *, 

73 task_key: NodeKey, 

74 dataset_type_key: NodeKey, 

75 storage_class_name: str, 

76 connection_name: str, 

77 is_calibration: bool, 

78 raw_dimensions: frozenset[str], 

79 ): 

80 self.task_key = task_key 

81 self.dataset_type_key = dataset_type_key 

82 self.connection_name = connection_name 

83 self.storage_class_name = storage_class_name 

84 self.is_calibration = is_calibration 

85 self.raw_dimensions = raw_dimensions 

86 

87 INIT_TO_TASK_NAME: ClassVar[str] = "INIT" 

88 """Edge key for the special edge that connects a task init node to the 

89 task node itself (for regular edges, this would be the connection name). 

90 """ 

91 

92 task_key: NodeKey 

93 """Task part of the key for this edge in networkx graphs (`NodeKey`).""" 

94 

95 dataset_type_key: NodeKey 

96 """Task part of the key for this edge in networkx graphs (`NodeKey`).""" 

97 

98 connection_name: str 

99 """Name used by the task to refer to this dataset type (`str`).""" 

100 

101 storage_class_name: str 

102 """Storage class expected by this task (`str`). 

103 

104 If `ReadEdge.component` is not `None`, this is the component storage class, 

105 not the parent storage class. 

106 """ 

107 

108 is_calibration: bool 

109 """Whether this dataset type can be included in 

110 `~lsst.daf.butler.CollectionType.CALIBRATION` collections. 

111 """ 

112 

113 raw_dimensions: frozenset[str] 

114 """Raw dimensions in the task declaration (`frozenset` [`str`]). 

115 

116 This can only be used safely for partial comparisons: two edges with the 

117 same ``raw_dimensions`` (and the same parent dataset type name) always have 

118 the same resolved dimensions, but edges with different ``raw_dimensions`` 

119 may also have the same resolvd dimensions. 

120 """ 

121 

122 @property 

123 def is_init(self) -> bool: 

124 """Whether this dataset is read or written when the task is 

125 constructed, not when it is run. 

126 """ 

127 return self.task_key.node_type is NodeType.TASK_INIT 

128 

129 @property 

130 def task_label(self) -> str: 

131 """Label of the task (`str`).""" 

132 return str(self.task_key) 

133 

134 @property 

135 def parent_dataset_type_name(self) -> str: 

136 """Name of the parent dataset type (`str`). 

137 

138 All dataset type nodes in a pipeline graph are for parent dataset 

139 types; components are represented by additional `ReadEdge` state. 

140 """ 

141 return str(self.dataset_type_key) 

142 

143 @property 

144 @abstractmethod 

145 def nodes(self) -> tuple[NodeKey, NodeKey]: 

146 """The directed pair of `NodeKey` instances this edge connects. 

147 

148 This tuple is ordered in the same direction as the pipeline flow: 

149 `task_key` precedes `dataset_type_key` for writes, and the 

150 reverse is true for reads. 

151 """ 

152 raise NotImplementedError() 

153 

154 @property 

155 def key(self) -> tuple[NodeKey, NodeKey, str]: 

156 """Ordered tuple of node keys and connection name that uniquely 

157 identifies this edge in a pipeline graph (`NodeKey`, `NodeKey`, `str`). 

158 

159 The nodes are ordered in the same sense as for `nodes`. 

160 """ 

161 return self.nodes + (self.connection_name,) 

162 

163 def __repr__(self) -> str: 

164 return f"{self.nodes[0]} -> {self.nodes[1]} ({self.connection_name})" 

165 

166 @property 

167 def dataset_type_name(self) -> str: 

168 """Dataset type name seen by the task (`str`). 

169 

170 This defaults to the parent dataset type name, which is appropriate 

171 for all writes and most reads. 

172 """ 

173 return self.parent_dataset_type_name 

174 

175 def diff(self: _S, other: _S, connection_type: str = "connection") -> list[str]: 

176 """Compare this edge to another one from a possibly-different 

177 configuration of the same task label. 

178 

179 Parameters 

180 ---------- 

181 other : `Edge` 

182 Another edge of the same type to compare to. 

183 connection_type : `str` 

184 Human-readable name of the connection type of this edge (e.g. 

185 "init input", "output") for use in returned messages. 

186 

187 Returns 

188 ------- 

189 differences : `list` [ `str` ] 

190 List of string messages describing differences between ``self`` and 

191 ``other``. Will be empty if ``self == other`` or if the only 

192 difference is in the task label or connection name (which are not 

193 checked). Messages will use 'A' to refer to ``self`` and 'B' to 

194 refer to ``other``. 

195 """ 

196 result = [] 

197 if self.dataset_type_name != other.dataset_type_name: 

198 result.append( 

199 f"{connection_type.capitalize()} {self.task_label}.{self.connection_name} has dataset type " 

200 f"{self.dataset_type_name!r} in A, but {other.dataset_type_name!r} in B." 

201 ) 

202 if self.storage_class_name != other.storage_class_name: 

203 result.append( 

204 f"{connection_type.capitalize()} {self.task_label}.{self.connection_name} has storage class " 

205 f"{self.storage_class_name!r} in A, but {other.storage_class_name!r} in B." 

206 ) 

207 if self.raw_dimensions != other.raw_dimensions: 

208 result.append( 

209 f"{connection_type.capitalize()} {self.task_label}.{self.connection_name} has raw dimensions " 

210 f"{set(self.raw_dimensions)} in A, but {set(other.raw_dimensions)} in B " 

211 "(differences in raw dimensions may not lead to differences in resolved dimensions, " 

212 "but this cannot be checked without re-resolving the dataset type)." 

213 ) 

214 if self.is_calibration != other.is_calibration: 

215 result.append( 

216 f"{connection_type.capitalize()} {self.task_label}.{self.connection_name} is marked as a " 

217 f"calibration {'in A but not in B' if self.is_calibration else 'in B but not in A'}." 

218 ) 

219 return result 

220 

221 @abstractmethod 

222 def adapt_dataset_type(self, dataset_type: DatasetType) -> DatasetType: 

223 """Transform the graph's definition of a dataset type (parent, with the 

224 registry or producer's storage class) to the one seen by this task. 

225 

226 Parameters 

227 ---------- 

228 dataset_type : `~lsst.daf.butler.DatasetType` 

229 Graph's definition of dataset type. 

230 

231 Returns 

232 ------- 

233 out_dataset_type : `~lsst.daf.butler.DatasetType` 

234 Dataset type seen by this task. 

235 """ 

236 raise NotImplementedError() 

237 

238 @abstractmethod 

239 def adapt_dataset_ref(self, ref: DatasetRef) -> DatasetRef: 

240 """Transform the graph's definition of a dataset reference (parent 

241 dataset type, with the registry or producer's storage class) to the one 

242 seen by this task. 

243 

244 Parameters 

245 ---------- 

246 ref : `~lsst.daf.butler.DatasetRef` 

247 Graph's definition of the dataset reference. 

248 

249 Returns 

250 ------- 

251 out_dataset_ref : `~lsst.daf.butler.DatasetRef` 

252 Dataset reference seen by this task. 

253 """ 

254 raise NotImplementedError() 

255 

256 def _to_xgraph_state(self) -> dict[str, Any]: 

257 """Convert this edges's attributes into a dictionary suitable for use 

258 in exported networkx graphs. 

259 """ 

260 return { 

261 "connection_name": self.connection_name, 

262 "parent_dataset_type_name": self.parent_dataset_type_name, 

263 "storage_class_name": self.storage_class_name, 

264 "is_init": bool, 

265 } 

266 

267 @classmethod 

268 def _unreduce(cls, kwargs: dict[str, Any]) -> Self: 

269 """Unpickle an `Edge` instance.""" 

270 return cls(**kwargs) 

271 

272 def __reduce__(self) -> tuple[Callable[[dict[str, Any]], Edge], tuple[dict[str, Any]]]: 

273 return ( 

274 self._unreduce, 

275 ( 

276 dict( 

277 task_key=self.task_key, 

278 dataset_type_key=self.dataset_type_key, 

279 storage_class_name=self.storage_class_name, 

280 connection_name=self.connection_name, 

281 is_calibration=self.is_calibration, 

282 raw_dimensions=self.raw_dimensions, 

283 ), 

284 ), 

285 ) 

286 

287 

288class ReadEdge(Edge): 

289 """Representation of an input connection (including init-inputs and 

290 prerequisites) in a pipeline graph. 

291 

292 Parameters 

293 ---------- 

294 dataset_type_key : `NodeKey` 

295 Key for the dataset type node this edge is connected to. This should 

296 hold the parent dataset type name for component dataset types. 

297 task_key : `NodeKey` 

298 Key for the task node this edge is connected to. 

299 storage_class_name : `str` 

300 Name of the dataset type's storage class as seen by the task. 

301 connection_name : `str` 

302 Internal name for the connection as seen by the task. 

303 is_calibration : `bool` 

304 Whether this dataset type can be included in 

305 `~lsst.daf.butler.CollectionType.CALIBRATION` collections. 

306 raw_dimensions : `frozenset` [ `str` ] 

307 Raw dimensions from the connection definition. 

308 is_prerequisite : `bool` 

309 Whether this dataset must be present in the data repository prior to 

310 `QuantumGraph` generation. 

311 component : `str` or `None` 

312 Component of the dataset type requested by the task. 

313 defer_query_constraint : `bool` 

314 If `True`, by default do not include this dataset type's existence as a 

315 constraint on the initial data ID query in QuantumGraph generation. 

316 

317 Notes 

318 ----- 

319 When included in an exported `networkx` graph (e.g. 

320 `PipelineGraph.make_xgraph`), read edges set the following edge attributes: 

321 

322 - ``parent_dataset_type_name`` 

323 - ``storage_class_name`` 

324 - ``is_init`` 

325 - ``component`` 

326 - ``is_prerequisite`` 

327 

328 As with `ReadEdge` instance attributes, these descriptions of dataset types 

329 are those specific to a task, and may differ from the graph's resolved 

330 dataset type or (if `PipelineGraph.resolve` has not been called) there may 

331 not even be a consistent definition of the dataset type. 

332 """ 

333 

334 def __init__( 

335 self, 

336 dataset_type_key: NodeKey, 

337 task_key: NodeKey, 

338 *, 

339 storage_class_name: str, 

340 connection_name: str, 

341 is_calibration: bool, 

342 raw_dimensions: frozenset[str], 

343 is_prerequisite: bool, 

344 component: str | None, 

345 defer_query_constraint: bool, 

346 ): 

347 super().__init__( 

348 task_key=task_key, 

349 dataset_type_key=dataset_type_key, 

350 storage_class_name=storage_class_name, 

351 connection_name=connection_name, 

352 raw_dimensions=raw_dimensions, 

353 is_calibration=is_calibration, 

354 ) 

355 self.is_prerequisite = is_prerequisite 

356 self.component = component 

357 self.defer_query_constraint = defer_query_constraint 

358 

359 component: str | None 

360 """Component to add to `parent_dataset_type_name` to form the dataset type 

361 name seen by this task (`str` or `None`). 

362 """ 

363 

364 is_prerequisite: bool 

365 """Whether this dataset must be present in the data repository prior to 

366 `QuantumGraph` generation. 

367 """ 

368 

369 defer_query_constraint: bool 

370 """If `True`, by default do not include this dataset type's existence as a 

371 constraint on the initial data ID query in QuantumGraph generation. 

372 

373 This can be `True` either because the connection class had 

374 ``deferQueryConstraint=True`` or because it had ``minimum=0``. 

375 """ 

376 

377 @property 

378 def nodes(self) -> tuple[NodeKey, NodeKey]: 

379 # Docstring inherited. 

380 return (self.dataset_type_key, self.task_key) 

381 

382 @property 

383 def dataset_type_name(self) -> str: 

384 """Complete dataset type name, as seen by the task (`str`).""" 

385 if self.component is not None: 

386 return f"{self.parent_dataset_type_name}.{self.component}" 

387 return self.parent_dataset_type_name 

388 

389 def diff(self: ReadEdge, other: ReadEdge, connection_type: str = "connection") -> list[str]: 

390 # Docstring inherited. 

391 result = super().diff(other, connection_type) 

392 if self.defer_query_constraint != other.defer_query_constraint: 

393 result.append( 

394 f"{connection_type.capitalize()} {self.connection_name!r} is marked as a deferred query " 

395 f"constraint {'in A but not in B' if self.defer_query_constraint else 'in B but not in A'}." 

396 ) 

397 return result 

398 

399 def adapt_dataset_type(self, dataset_type: DatasetType) -> DatasetType: 

400 # Docstring inherited. 

401 if self.component is not None: 

402 dataset_type = dataset_type.makeComponentDatasetType(self.component) 

403 if self.storage_class_name != dataset_type.storageClass_name: 

404 return dataset_type.overrideStorageClass(self.storage_class_name) 

405 return dataset_type 

406 

407 def adapt_dataset_ref(self, ref: DatasetRef) -> DatasetRef: 

408 # Docstring inherited. 

409 if self.component is not None: 

410 ref = ref.makeComponentRef(self.component) 

411 if self.storage_class_name != ref.datasetType.storageClass_name: 

412 return ref.overrideStorageClass(self.storage_class_name) 

413 return ref 

414 

415 @classmethod 

416 def _from_connection_map( 

417 cls, 

418 task_key: NodeKey, 

419 connection_name: str, 

420 connection_map: Mapping[str, BaseConnection], 

421 is_prerequisite: bool = False, 

422 ) -> ReadEdge: 

423 """Construct a `ReadEdge` instance from a `.BaseConnection` object. 

424 

425 Parameters 

426 ---------- 

427 task_key : `NodeKey` 

428 Key for the associated task node or task init node. 

429 connection_name : `str` 

430 Internal name for the connection as seen by the task,. 

431 connection_map : Mapping [ `str`, `.BaseConnection` ] 

432 Mapping of post-configuration object to draw dataset type 

433 information from, keyed by connection name. 

434 is_prerequisite : `bool`, optional 

435 Whether this dataset must be present in the data repository prior 

436 to `QuantumGraph` generation. 

437 

438 Returns 

439 ------- 

440 edge : `ReadEdge` 

441 New edge instance. 

442 """ 

443 connection = connection_map[connection_name] 

444 parent_dataset_type_name, component = DatasetType.splitDatasetTypeName(connection.name) 

445 return cls( 

446 dataset_type_key=NodeKey(NodeType.DATASET_TYPE, parent_dataset_type_name), 

447 task_key=task_key, 

448 component=component, 

449 storage_class_name=connection.storageClass, 

450 # InitInput connections don't have .isCalibration. 

451 is_calibration=getattr(connection, "isCalibration", False), 

452 is_prerequisite=is_prerequisite, 

453 connection_name=connection_name, 

454 # InitInput connections don't have a .dimensions because they 

455 # always have empty dimensions. 

456 raw_dimensions=frozenset(getattr(connection, "dimensions", frozenset())), 

457 # PrerequisiteInput and InitInput connections don't have a 

458 # .deferGraphConstraint, because they never constrain the initial 

459 # data ID query. 

460 defer_query_constraint=( 

461 getattr(connection, "deferGraphConstraint", False) or getattr(connection, "minimum", 1) == 0 

462 ), 

463 ) 

464 

465 def _resolve_dataset_type( 

466 self, 

467 *, 

468 current: DatasetType | None, 

469 is_initial_query_constraint: bool, 

470 is_prerequisite: bool | None, 

471 universe: DimensionUniverse, 

472 producer: str | None, 

473 consumers: Sequence[str], 

474 is_registered: bool, 

475 visualization_only: bool, 

476 ) -> tuple[DatasetType, bool, bool]: 

477 """Participate in the construction of the `DatasetTypeNode` object 

478 associated with this edge. 

479 

480 Parameters 

481 ---------- 

482 current : `lsst.daf.butler.DatasetType` or `None` 

483 The current graph-wide `DatasetType`, or `None`. This will always 

484 be the registry's definition of the parent dataset type, if one 

485 exists. If not, it will be the dataset type definition from the 

486 task in the graph that writes it, if there is one. If there is no 

487 such task, this will be `None`. 

488 is_initial_query_constraint : `bool` 

489 Whether this dataset type is currently marked as a constraint on 

490 the initial data ID query in QuantumGraph generation. 

491 is_prerequisite : `bool` | None` 

492 Whether this dataset type is marked as a prerequisite input in all 

493 edges processed so far. `None` if this is the first edge. 

494 universe : `lsst.daf.butler.DimensionUniverse` 

495 Object that holds all dimension definitions. 

496 producer : `str` or `None` 

497 The label of the task that produces this dataset type in the 

498 pipeline, or `None` if it is an overall input. 

499 consumers : `Sequence` [ `str` ] 

500 Labels for other consuming tasks that have already participated in 

501 this dataset type's resolution. 

502 is_registered : `bool` 

503 Whether a registration for this dataset type was found in the 

504 data repository. 

505 visualization_only : `bool` 

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

507 storage classes cannot really be determined. This can include 

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

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

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

511 itself is ever actually loaded). 

512 

513 Returns 

514 ------- 

515 dataset_type : `DatasetType` 

516 The updated graph-wide dataset type. If ``current`` was provided, 

517 this must be equal to it. 

518 is_initial_query_constraint : `bool` 

519 If `True`, this dataset type should be included as a constraint in 

520 the initial data ID query during QuantumGraph generation; this 

521 requires that ``is_initial_query_constraint`` also be `True` on 

522 input. 

523 is_prerequisite : `bool` 

524 Whether this dataset type is marked as a prerequisite input in this 

525 task and all other edges processed so far. 

526 

527 Raises 

528 ------ 

529 MissingDatasetTypeError 

530 Raised if ``current is None`` and this edge cannot define one on 

531 its own. 

532 IncompatibleDatasetTypeError 

533 Raised if ``current is not None`` and this edge's definition is not 

534 compatible with it. 

535 ConnectionTypeConsistencyError 

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

537 kind of connection in any other task. 

538 """ 

539 if "skypix" in self.raw_dimensions: 

540 if current is None: 

541 if visualization_only: 

542 dimensions = universe.conform( 

543 [d if d != "skypix" else universe.commonSkyPix.name for d in self.raw_dimensions] 

544 ) 

545 else: 

546 raise MissingDatasetTypeError( 

547 f"DatasetType '{self.dataset_type_name}' referenced by " 

548 f"{self.task_label!r} uses 'skypix' as a dimension " 

549 f"placeholder, but has not been registered with the data repository. " 

550 f"Note that reference catalog names are now used as the dataset " 

551 f"type name instead of 'ref_cat'." 

552 ) 

553 else: 

554 rest1 = set(universe.conform(self.raw_dimensions - {"skypix"}).names) 

555 rest2 = current.dimensions.names - current.dimensions.skypix 

556 if rest1 != rest2: 

557 raise IncompatibleDatasetTypeError( 

558 f"Non-skypix dimensions for dataset type {self.dataset_type_name} declared in " 

559 f"connections ({rest1}) are inconsistent with those in " 

560 f"registry's version of this dataset ({rest2})." 

561 ) 

562 dimensions = current.dimensions 

563 else: 

564 dimensions = universe.conform(self.raw_dimensions) 

565 is_initial_query_constraint = is_initial_query_constraint and not self.defer_query_constraint 

566 if is_prerequisite is None: 

567 is_prerequisite = self.is_prerequisite 

568 elif is_prerequisite and not self.is_prerequisite: 

569 raise ConnectionTypeConsistencyError( 

570 f"Dataset type {self.parent_dataset_type_name!r} is a prerequisite input to {consumers}, " 

571 f"but it is not a prerequisite to {self.task_label!r}." 

572 ) 

573 elif not is_prerequisite and self.is_prerequisite: 

574 if producer is not None: 

575 raise ConnectionTypeConsistencyError( 

576 f"Dataset type {self.parent_dataset_type_name!r} is a prerequisite input to " 

577 f"{self.task_label}, but it is produced by {producer!r}." 

578 ) 

579 else: 

580 raise ConnectionTypeConsistencyError( 

581 f"Dataset type {self.parent_dataset_type_name!r} is a prerequisite input to " 

582 f"{self.task_label}, but it is a regular input to {consumers!r}." 

583 ) 

584 

585 def report_current_origin() -> str: 

586 if is_registered: 

587 return "data repository" 

588 elif producer is not None: 

589 return f"producing task {producer!r}" 

590 else: 

591 return f"consuming task(s) {consumers!r}" 

592 

593 if self.component is not None: 

594 if current is None: 

595 if visualization_only: 

596 current = DatasetType( 

597 self.parent_dataset_type_name, 

598 dimensions, 

599 storageClass="<UNKNOWN>", 

600 isCalibration=self.is_calibration, 

601 ) 

602 else: 

603 raise MissingDatasetTypeError( 

604 f"Dataset type {self.parent_dataset_type_name!r} is not registered and not produced " 

605 f"by this pipeline, but it is used by task {self.task_label!r}, via component " 

606 f"{self.component!r}. This pipeline cannot be resolved until the parent dataset " 

607 "type is registered." 

608 ) 

609 else: 

610 try: 

611 all_current_components = current.storageClass.allComponents() 

612 except (KeyError, ImportError): 

613 if visualization_only: 

614 current = DatasetType( 

615 self.parent_dataset_type_name, 

616 dimensions, 

617 storageClass="<UNKNOWN>", 

618 isCalibration=self.is_calibration, 

619 ) 

620 return current, is_initial_query_constraint, is_prerequisite 

621 raise 

622 if self.component not in all_current_components: 

623 raise IncompatibleDatasetTypeError( 

624 f"Dataset type {self.parent_dataset_type_name!r} has storage class " 

625 f"{current.storageClass_name!r} (from {report_current_origin()}), " 

626 f"which does not include component {self.component!r} " 

627 f"as requested by task {self.task_label!r}." 

628 ) 

629 # Note that we can't actually make a fully-correct DatasetType 

630 # for the component the task wants, because we don't have the 

631 # parent storage class. 

632 current_component = all_current_components[self.component] 

633 

634 if ( 

635 not visualization_only 

636 and current_component.name != self.storage_class_name 

637 and not StorageClassFactory() 

638 .getStorageClass(self.storage_class_name) 

639 .can_convert(current_component) 

640 ): 

641 raise IncompatibleDatasetTypeError( 

642 f"Dataset type '{self.parent_dataset_type_name}.{self.component}' has storage class " 

643 f"{all_current_components[self.component].name!r} " 

644 f"(from {report_current_origin()}), which cannot be converted to " 

645 f"{self.storage_class_name!r}, as requested by task {self.task_label!r}." 

646 ) 

647 return current, is_initial_query_constraint, is_prerequisite 

648 else: 

649 dataset_type = DatasetType( 

650 self.parent_dataset_type_name, 

651 dimensions, 

652 storageClass=self.storage_class_name, 

653 isCalibration=self.is_calibration, 

654 ) 

655 if current is not None: 

656 if not is_registered and producer is None: 

657 # Current definition comes from another consumer; we 

658 # require the dataset types to be exactly equal (not just 

659 # compatible), since neither connection should take 

660 # precedence. 

661 if dataset_type != current: 

662 raise MissingDatasetTypeError( 

663 f"Definitions differ for input dataset type {self.parent_dataset_type_name!r}; " 

664 f"task {self.task_label!r} has {dataset_type}, but the definition " 

665 f"from {report_current_origin()} is {current}. If the storage classes are " 

666 "compatible but different, registering the dataset type in the data repository " 

667 "in advance will avoid this error." 

668 ) 

669 elif not visualization_only and not dataset_type.is_compatible_with(current): 

670 raise IncompatibleDatasetTypeError( 

671 f"Incompatible definition for input dataset type {self.parent_dataset_type_name!r}; " 

672 f"task {self.task_label!r} has {dataset_type}, but the definition " 

673 f"from {report_current_origin()} is {current}." 

674 ) 

675 return current, is_initial_query_constraint, is_prerequisite 

676 else: 

677 return dataset_type, is_initial_query_constraint, is_prerequisite 

678 

679 def _to_xgraph_state(self) -> dict[str, Any]: 

680 # Docstring inherited. 

681 result = super()._to_xgraph_state() 

682 result["component"] = self.component 

683 result["is_prerequisite"] = self.is_prerequisite 

684 return result 

685 

686 def __reduce__(self) -> tuple[Callable[[dict[str, Any]], Edge], tuple[dict[str, Any]]]: 

687 return ( 

688 self._unreduce, 

689 ( 

690 dict( 

691 dataset_type_key=self.dataset_type_key, 

692 task_key=self.task_key, 

693 storage_class_name=self.storage_class_name, 

694 connection_name=self.connection_name, 

695 is_calibration=self.is_calibration, 

696 raw_dimensions=self.raw_dimensions, 

697 is_prerequisite=self.is_prerequisite, 

698 component=self.component, 

699 defer_query_constraint=self.defer_query_constraint, 

700 ), 

701 ), 

702 ) 

703 

704 

705class WriteEdge(Edge): 

706 """Representation of an output connection (including init-outputs) in a 

707 pipeline graph. 

708 

709 Notes 

710 ----- 

711 When included in an exported `networkx` graph (e.g. 

712 `PipelineGraph.make_xgraph`), write edges set the following edge 

713 attributes: 

714 

715 - ``parent_dataset_type_name`` 

716 - ``storage_class_name`` 

717 - ``is_init`` 

718 

719 As with `WRiteEdge` instance attributes, these descriptions of dataset 

720 types are those specific to a task, and may differ from the graph's 

721 resolved dataset type or (if `PipelineGraph.resolve` has not been called) 

722 there may not even be a consistent definition of the dataset type. 

723 """ 

724 

725 @property 

726 def nodes(self) -> tuple[NodeKey, NodeKey]: 

727 # Docstring inherited. 

728 return (self.task_key, self.dataset_type_key) 

729 

730 def adapt_dataset_type(self, dataset_type: DatasetType) -> DatasetType: 

731 # Docstring inherited. 

732 if self.storage_class_name != dataset_type.storageClass_name: 

733 return dataset_type.overrideStorageClass(self.storage_class_name) 

734 return dataset_type 

735 

736 def adapt_dataset_ref(self, ref: DatasetRef) -> DatasetRef: 

737 # Docstring inherited. 

738 if self.storage_class_name != ref.datasetType.storageClass_name: 

739 return ref.overrideStorageClass(self.storage_class_name) 

740 return ref 

741 

742 @classmethod 

743 def _from_connection_map( 

744 cls, 

745 task_key: NodeKey, 

746 connection_name: str, 

747 connection_map: Mapping[str, BaseConnection], 

748 ) -> WriteEdge: 

749 """Construct a `WriteEdge` instance from a `.BaseConnection` object. 

750 

751 Parameters 

752 ---------- 

753 task_key : `NodeKey` 

754 Key for the associated task node or task init node. 

755 connection_name : `str` 

756 Internal name for the connection as seen by the task,. 

757 connection_map : Mapping [ `str`, `.BaseConnection` ] 

758 Mapping of post-configuration object to draw dataset type 

759 information from, keyed by connection name. 

760 

761 Returns 

762 ------- 

763 edge : `WriteEdge` 

764 New edge instance. 

765 """ 

766 connection = connection_map[connection_name] 

767 parent_dataset_type_name, component = DatasetType.splitDatasetTypeName(connection.name) 

768 if component is not None: 

769 raise ValueError( 

770 f"Illegal output component dataset {connection.name!r} in task {task_key.name!r}." 

771 ) 

772 return cls( 

773 task_key=task_key, 

774 dataset_type_key=NodeKey(NodeType.DATASET_TYPE, parent_dataset_type_name), 

775 storage_class_name=connection.storageClass, 

776 connection_name=connection_name, 

777 # InitOutput connections don't have .isCalibration. 

778 is_calibration=getattr(connection, "isCalibration", False), 

779 # InitOutput connections don't have a .dimensions because they 

780 # always have empty dimensions. 

781 raw_dimensions=frozenset(getattr(connection, "dimensions", frozenset())), 

782 ) 

783 

784 def _resolve_dataset_type(self, current: DatasetType | None, universe: DimensionUniverse) -> DatasetType: 

785 """Participate in the construction of the `DatasetTypeNode` object 

786 associated with this edge. 

787 

788 Parameters 

789 ---------- 

790 current : `lsst.daf.butler.DatasetType` or `None` 

791 The current graph-wide `DatasetType`, or `None`. This will always 

792 be the registry's definition of the parent dataset type, if one 

793 exists. 

794 universe : `lsst.daf.butler.DimensionUniverse` 

795 Object that holds all dimension definitions. 

796 

797 Returns 

798 ------- 

799 dataset_type : `DatasetType` 

800 A dataset type compatible with this edge. If ``current`` was 

801 provided, this must be equal to it. 

802 

803 Raises 

804 ------ 

805 IncompatibleDatasetTypeError 

806 Raised if ``current is not None`` and this edge's definition is not 

807 compatible with it. 

808 """ 

809 try: 

810 dimensions = universe.conform(self.raw_dimensions) 

811 dataset_type = DatasetType( 

812 self.parent_dataset_type_name, 

813 dimensions, 

814 storageClass=self.storage_class_name, 

815 isCalibration=self.is_calibration, 

816 ) 

817 except Exception as err: 

818 err.add_note(f"In connection {self.connection_name!r} of task {self.task_label!r}.") 

819 raise 

820 if current is not None: 

821 if not current.is_compatible_with(dataset_type): 

822 raise IncompatibleDatasetTypeError( 

823 f"Incompatible definition for output dataset type {self.parent_dataset_type_name!r}: " 

824 f"task {self.task_label!r} has {dataset_type}, but data repository has {current}." 

825 ) 

826 return current 

827 else: 

828 return dataset_type