Coverage for python / lsst / pipe / base / pipeline.py: 21%

285 statements  

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

1# This file is part of pipe_base. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

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

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

7# for details of code ownership. 

8# 

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

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

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

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

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

14# 

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

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

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

18# (at your option) any later version. 

19# 

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

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

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

23# GNU General Public License for more details. 

24# 

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

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

27 

28"""Module defining Pipeline class and related methods.""" 

29 

30from __future__ import annotations 

31 

32__all__ = ["LabelSpecifier", "Pipeline", "TaskDef"] 

33 

34import copy 

35import logging 

36import re 

37import urllib.parse 

38 

39# ------------------------------- 

40# Imports of standard modules -- 

41# ------------------------------- 

42from collections.abc import Callable, Set 

43from dataclasses import dataclass 

44from types import MappingProxyType 

45from typing import TYPE_CHECKING, cast 

46 

47# ----------------------------- 

48# Imports for other modules -- 

49# ----------------------------- 

50from lsst.daf.butler import DataCoordinate, DimensionUniverse, Registry 

51from lsst.resources import ResourcePath, ResourcePathExpression 

52from lsst.utils import doImportType 

53from lsst.utils.introspection import get_full_type_name 

54 

55from . import automatic_connection_constants as acc 

56from . import pipeline_graph, pipelineIR 

57from ._instrument import Instrument as PipeBaseInstrument 

58from .config import PipelineTaskConfig 

59from .connections import PipelineTaskConnections 

60from .pipelineTask import PipelineTask 

61 

62if TYPE_CHECKING: # Imports needed only for type annotations; may be circular. 

63 from lsst.obs.base import Instrument 

64 from lsst.pex.config import Config 

65 

66# ---------------------------------- 

67# Local non-exported definitions -- 

68# ---------------------------------- 

69 

70_LOG = logging.getLogger(__name__) 

71 

72# ------------------------ 

73# Exported definitions -- 

74# ------------------------ 

75 

76 

77@dataclass 

78class LabelSpecifier: 

79 """A structure to specify a subset of labels to load. 

80 

81 This structure may contain a set of labels to be used in subsetting a 

82 pipeline, or a beginning and end point. Beginning or end may be empty, in 

83 which case the range will be a half open interval. Unlike python iteration 

84 bounds, end bounds are *INCLUDED*. 

85 

86 There are multiple potential definitions of range-based slicing for graphs 

87 that are not a simple linear sequence. The definition used here is the 

88 intersection of the tasks downstream of ``begin`` and the tasks upstream of 

89 ``end``, i.e. tasks with no dependency relationship to a bounding task are 

90 not included. 

91 """ 

92 

93 labels: set[str] | None = None 

94 begin: str | None = None 

95 end: str | None = None 

96 

97 def __post_init__(self) -> None: 

98 if self.labels is not None and (self.begin or self.end): 

99 raise ValueError( 

100 "This struct can only be initialized with a labels set or a begin (and/or) end specifier" 

101 ) 

102 

103 

104class TaskDef: 

105 """TaskDef is a collection of information about task needed by Pipeline. 

106 

107 The information includes task name, configuration object and optional 

108 task class. This class is just a collection of attributes and it exposes 

109 all of them so that attributes could potentially be modified in place 

110 (e.g. if configuration needs extra overrides). 

111 

112 Parameters 

113 ---------- 

114 taskName : `str`, optional 

115 The fully-qualified `PipelineTask` class name. If not provided, 

116 ``taskClass`` must be. 

117 config : `lsst.pipe.base.config.PipelineTaskConfig`, optional 

118 Instance of the configuration class corresponding to this task class, 

119 usually with all overrides applied. This config will be frozen. If 

120 not provided, ``taskClass`` must be provided and 

121 ``taskClass.ConfigClass()`` will be used. 

122 taskClass : `type`, optional 

123 `PipelineTask` class object; if provided and ``taskName`` is as well, 

124 the caller guarantees that they are consistent. If not provided, 

125 ``taskName`` is used to import the type. 

126 label : `str`, optional 

127 Task label, usually a short string unique in a pipeline. If not 

128 provided, ``taskClass`` must be, and ``taskClass._DefaultName`` will 

129 be used. 

130 connections : `PipelineTaskConnections`, optional 

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

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

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

134 and frozen. 

135 """ 

136 

137 def __init__( 

138 self, 

139 taskName: str | None = None, 

140 config: PipelineTaskConfig | None = None, 

141 taskClass: type[PipelineTask] | None = None, 

142 label: str | None = None, 

143 connections: PipelineTaskConnections | None = None, 

144 ): 

145 if taskName is None: 

146 if taskClass is None: 

147 raise ValueError("At least one of `taskName` and `taskClass` must be provided.") 

148 taskName = get_full_type_name(taskClass) 

149 elif taskClass is None: 

150 taskClass = doImportType(taskName) 

151 if config is None: 

152 if taskClass is None: 

153 raise ValueError("`taskClass` must be provided if `config` is not.") 

154 config = taskClass.ConfigClass() 

155 if label is None: 

156 if taskClass is None: 

157 raise ValueError("`taskClass` must be provided if `label` is not.") 

158 label = taskClass._DefaultName 

159 self.taskName = taskName 

160 if connections is None: 

161 # If we don't have connections yet, assume the config hasn't been 

162 # validated yet. 

163 try: 

164 config.validate() 

165 except Exception: 

166 _LOG.error("Configuration validation failed for task %s (%s)", label, taskName) 

167 raise 

168 config.freeze() 

169 connections = config.connections.ConnectionsClass(config=config) 

170 self.config = config 

171 self.taskClass = taskClass 

172 self.label = label 

173 self.connections = connections 

174 

175 @property 

176 def configDatasetName(self) -> str: 

177 """Name of a dataset type for configuration of this task (`str`).""" 

178 return acc.CONFIG_INIT_OUTPUT_TEMPLATE.format(label=self.label) 

179 

180 @property 

181 def metadataDatasetName(self) -> str: 

182 """Name of a dataset type for metadata of this task (`str`).""" 

183 return self.makeMetadataDatasetName(self.label) 

184 

185 @classmethod 

186 def makeMetadataDatasetName(cls, label: str) -> str: 

187 """Construct the name of the dataset type for metadata for a task. 

188 

189 Parameters 

190 ---------- 

191 label : `str` 

192 Label for the task within its pipeline. 

193 

194 Returns 

195 ------- 

196 name : `str` 

197 Name of the task's metadata dataset type. 

198 """ 

199 return acc.METADATA_OUTPUT_TEMPLATE.format(label=label) 

200 

201 @property 

202 def logOutputDatasetName(self) -> str | None: 

203 """Name of a dataset type for log output from this task, `None` if 

204 logs are not to be saved (`str`). 

205 """ 

206 if self.config.saveLogOutput: 

207 return acc.LOG_OUTPUT_TEMPLATE.format(label=self.label) 

208 else: 

209 return None 

210 

211 def __str__(self) -> str: 

212 rep = "TaskDef(" + self.taskName 

213 if self.label: 

214 rep += ", label=" + self.label 

215 rep += ")" 

216 return rep 

217 

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

219 if not isinstance(other, TaskDef): 

220 return False 

221 # This does not consider equality of configs when determining equality 

222 # as config equality is a difficult thing to define. Should be updated 

223 # after DM-27847 

224 return self.taskClass == other.taskClass and self.label == other.label 

225 

226 def __hash__(self) -> int: 

227 return hash((self.taskClass, self.label)) 

228 

229 @classmethod 

230 def _unreduce(cls, taskName: str, config: PipelineTaskConfig, label: str) -> TaskDef: 

231 """Unpickle pickle. Custom callable for unpickling. 

232 

233 All arguments are forwarded directly to the constructor; this 

234 trampoline is only needed because ``__reduce__`` callables can't be 

235 called with keyword arguments. 

236 """ 

237 return cls(taskName=taskName, config=config, label=label) 

238 

239 def __reduce__(self) -> tuple[Callable[[str, PipelineTaskConfig, str], TaskDef], tuple[str, Config, str]]: 

240 return (self._unreduce, (self.taskName, self.config, self.label)) 

241 

242 

243class Pipeline: 

244 """A `Pipeline` is a representation of a series of tasks to run, and the 

245 configuration for those tasks. 

246 

247 Parameters 

248 ---------- 

249 description : `str` 

250 A description of that this pipeline does. 

251 """ 

252 

253 PipelineSubsetCtrl = pipelineIR.PipelineSubsetCtrl 

254 

255 def __init__(self, description: str): 

256 pipeline_dict = {"description": description, "tasks": {}} 

257 self._pipelineIR = pipelineIR.PipelineIR(pipeline_dict) 

258 

259 @classmethod 

260 def fromFile(cls, filename: str) -> Pipeline: 

261 """Load a pipeline defined in a pipeline yaml file. 

262 

263 Parameters 

264 ---------- 

265 filename : `str` 

266 A path that points to a pipeline defined in yaml format. This 

267 filename may also supply additional labels to be used in 

268 subsetting the loaded Pipeline. These labels are separated from 

269 the path by a ``#``, and may be specified as a comma separated 

270 list, or a range denoted as beginning..end. Beginning or end may 

271 be empty, in which case the range will be a half open interval. 

272 Unlike python iteration bounds, end bounds are *INCLUDED*. Note 

273 that range based selection is not well defined for pipelines that 

274 are not linear in nature, and correct behavior is not guaranteed, 

275 or may vary from run to run. 

276 

277 Returns 

278 ------- 

279 pipeline: `Pipeline` 

280 The pipeline loaded from specified location with appropriate (if 

281 any) subsetting. 

282 

283 Notes 

284 ----- 

285 This method attempts to prune any contracts that contain labels which 

286 are not in the declared subset of labels. This pruning is done using a 

287 string based matching due to the nature of contracts and may prune more 

288 than it should. 

289 """ 

290 return cls.from_uri(filename) 

291 

292 @classmethod 

293 def from_uri(cls, uri: ResourcePathExpression) -> Pipeline: 

294 """Load a pipeline defined in a pipeline yaml file at a location 

295 specified by a URI. 

296 

297 Parameters 

298 ---------- 

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

300 If a string is supplied this should be a URI path that points to a 

301 pipeline defined in yaml format, either as a direct path to the 

302 yaml file, or as a directory containing a ``pipeline.yaml`` file 

303 the form used by `write_to_uri` with ``expand=True``). This uri may 

304 also supply additional labels to be used in subsetting the loaded 

305 `Pipeline`. These labels are separated from the path by a ``#``, 

306 and may be specified as a comma separated list, or a range denoted 

307 as beginning..end. Beginning or end may be empty, in which case the 

308 range will be a half open interval. Unlike python iteration bounds, 

309 end bounds are *INCLUDED*. Note that range based selection is not 

310 well defined for pipelines that are not linear in nature, and 

311 correct behavior is not guaranteed, or may vary from run to run. 

312 The same specifiers can be used with a 

313 `~lsst.resources.ResourcePath` object, by being the sole contents 

314 in the fragments attribute. 

315 

316 Returns 

317 ------- 

318 pipeline : `Pipeline` 

319 The pipeline loaded from specified location with appropriate (if 

320 any) subsetting. 

321 

322 Notes 

323 ----- 

324 This method attempts to prune any contracts that contain labels which 

325 are not in the declared subset of labels. This pruning is done using a 

326 string based matching due to the nature of contracts and may prune more 

327 than it should. 

328 """ 

329 # Split up the uri and any labels that were supplied 

330 uri, label_specifier = cls._parse_file_specifier(uri) 

331 pipeline: Pipeline = cls.fromIR(pipelineIR.PipelineIR.from_uri(uri)) 

332 

333 # If there are labels supplied, only keep those 

334 if label_specifier is not None: 

335 pipeline = pipeline.subsetFromLabels(label_specifier) 

336 return pipeline 

337 

338 def subsetFromLabels( 

339 self, 

340 labelSpecifier: LabelSpecifier, 

341 subsetCtrl: pipelineIR.PipelineSubsetCtrl = PipelineSubsetCtrl.DROP, 

342 ) -> Pipeline: 

343 """Subset a pipeline to contain only labels specified in 

344 ``labelSpecifier``. 

345 

346 Parameters 

347 ---------- 

348 labelSpecifier : `labelSpecifier` 

349 Object containing labels that describes how to subset a pipeline. 

350 subsetCtrl : `PipelineSubsetCtrl` 

351 Control object which decides how subsets with missing labels are 

352 handled. Setting to `PipelineSubsetCtrl.DROP` (the default) will 

353 cause any subsets that have labels which are not in the set of all 

354 task labels to be dropped. Setting to `PipelineSubsetCtrl.EDIT` 

355 will cause the subset to instead be edited to remove the 

356 nonexistent label. 

357 

358 Returns 

359 ------- 

360 pipeline : `Pipeline` 

361 A new pipeline object that is a subset of the old pipeline. 

362 

363 Raises 

364 ------ 

365 ValueError 

366 Raised if there is an issue with specified labels 

367 

368 Notes 

369 ----- 

370 This method attempts to prune any contracts that contain labels which 

371 are not in the declared subset of labels. This pruning is done using a 

372 string based matching due to the nature of contracts and may prune more 

373 than it should. 

374 """ 

375 # Labels supplied as a set 

376 if labelSpecifier.labels: 

377 labelSet = labelSpecifier.labels 

378 # Labels supplied as a range, first create a list of all the labels 

379 # in the pipeline sorted according to task dependency. Then only 

380 # keep labels that lie between the supplied bounds 

381 else: 

382 # Create a copy of the pipeline to use when assessing the label 

383 # ordering. Use a dict for fast searching while preserving order. 

384 # Remove contracts so they do not fail in the expansion step. This 

385 # is needed because a user may only configure the tasks they intend 

386 # to run, which may cause some contracts to fail if they will later 

387 # be dropped 

388 pipeline = copy.deepcopy(self) 

389 pipeline._pipelineIR.contracts = [] 

390 graph = pipeline.to_graph() 

391 

392 # Verify the bounds are in the labels 

393 if labelSpecifier.begin is not None and labelSpecifier.begin not in graph.tasks: 

394 raise ValueError( 

395 f"Beginning of range subset, {labelSpecifier.begin}, not found in pipeline definition" 

396 ) 

397 if labelSpecifier.end is not None and labelSpecifier.end not in graph.tasks: 

398 raise ValueError( 

399 f"End of range subset, {labelSpecifier.end}, not found in pipeline definition" 

400 ) 

401 

402 labelSet = set(graph.tasks.between(labelSpecifier.begin, labelSpecifier.end)) 

403 return Pipeline.fromIR(self._pipelineIR.subset_from_labels(labelSet, subsetCtrl)) 

404 

405 @staticmethod 

406 def _parse_file_specifier(uri: ResourcePathExpression) -> tuple[ResourcePath, LabelSpecifier | None]: 

407 """Split appart a uri and any possible label subsets""" 

408 if isinstance(uri, str): 

409 # This is to support legacy pipelines during transition 

410 uri, num_replace = re.subn("[:](?!\\/\\/)", "#", uri) 

411 if num_replace: 

412 raise ValueError( 

413 f"The pipeline file {uri} seems to use the legacy :" 

414 " to separate labels, please use # instead." 

415 ) 

416 if uri.count("#") > 1: 

417 raise ValueError("Only one set of labels is allowed when specifying a pipeline to load") 

418 # Everything else can be converted directly to ResourcePath. 

419 uri = ResourcePath(uri) 

420 label_subset = uri.fragment or None 

421 

422 specifier: LabelSpecifier | None 

423 if label_subset is not None: 

424 label_subset = urllib.parse.unquote(label_subset) 

425 args: dict[str, set[str] | str | None] 

426 # labels supplied as a list 

427 if "," in label_subset: 

428 if ".." in label_subset: 

429 raise ValueError( 

430 "Can only specify a list of labels or a range when loading a Pipeline, not both." 

431 ) 

432 args = {"labels": set(label_subset.split(","))} 

433 # labels supplied as a range 

434 elif ".." in label_subset: 

435 # Try to de-structure the labelSubset, this will fail if more 

436 # than one range is specified 

437 begin, end, *rest = label_subset.split("..") 

438 if rest: 

439 raise ValueError("Only one range can be specified when loading a pipeline") 

440 args = {"begin": begin if begin else None, "end": end if end else None} 

441 # Assume anything else is a single label 

442 else: 

443 args = {"labels": {label_subset}} 

444 

445 # MyPy doesn't like how cavalier kwarg construction is with types. 

446 specifier = LabelSpecifier(**args) # type: ignore 

447 else: 

448 specifier = None 

449 

450 return uri, specifier 

451 

452 @classmethod 

453 def fromString(cls, pipeline_string: str) -> Pipeline: 

454 """Create a pipeline from string formatted as a pipeline document. 

455 

456 Parameters 

457 ---------- 

458 pipeline_string : `str` 

459 A string that is formatted according like a pipeline document. 

460 

461 Returns 

462 ------- 

463 pipeline: `Pipeline` 

464 The new pipeline. 

465 """ 

466 pipeline = cls.fromIR(pipelineIR.PipelineIR.from_string(pipeline_string)) 

467 return pipeline 

468 

469 @classmethod 

470 def fromIR(cls, deserialized_pipeline: pipelineIR.PipelineIR) -> Pipeline: 

471 """Create a pipeline from an already created `PipelineIR` object. 

472 

473 Parameters 

474 ---------- 

475 deserialized_pipeline : `PipelineIR` 

476 An already created pipeline intermediate representation object. 

477 

478 Returns 

479 ------- 

480 pipeline: `Pipeline` 

481 The new pipeline. 

482 """ 

483 pipeline = cls.__new__(cls) 

484 pipeline._pipelineIR = deserialized_pipeline 

485 return pipeline 

486 

487 @classmethod 

488 def fromPipeline(cls, pipeline: Pipeline) -> Pipeline: 

489 """Create a new pipeline by copying an already existing `Pipeline`. 

490 

491 Parameters 

492 ---------- 

493 pipeline : `Pipeline` 

494 An already created pipeline intermediate representation object. 

495 

496 Returns 

497 ------- 

498 pipeline: `Pipeline` 

499 The new pipeline. 

500 """ 

501 return cls.fromIR(copy.deepcopy(pipeline._pipelineIR)) 

502 

503 def __str__(self) -> str: 

504 return str(self._pipelineIR) 

505 

506 def mergePipeline(self, pipeline: Pipeline) -> None: 

507 """Merge another in-memory `Pipeline` object into this one. 

508 

509 This merges another pipeline into this object, as if it were declared 

510 in the import block of the yaml definition of this pipeline. This 

511 modifies this pipeline in place. 

512 

513 Parameters 

514 ---------- 

515 pipeline : `Pipeline` 

516 The `Pipeline` object that is to be merged into this object. 

517 """ 

518 self._pipelineIR.merge_pipelines((pipeline._pipelineIR,)) 

519 

520 def addLabelToSubset(self, subset: str, label: str) -> None: 

521 """Add a task label from the specified subset. 

522 

523 Parameters 

524 ---------- 

525 subset : `str` 

526 The labeled subset to modify. 

527 label : `str` 

528 The task label to add to the specified subset. 

529 

530 Raises 

531 ------ 

532 ValueError 

533 Raised if the specified subset does not exist within the pipeline. 

534 Raised if the specified label does not exist within the pipeline. 

535 """ 

536 if label not in self._pipelineIR.tasks: 

537 raise ValueError(f"Label {label} does not appear within the pipeline") 

538 if subset not in self._pipelineIR.labeled_subsets: 

539 raise ValueError(f"Subset {subset} does not appear within the pipeline") 

540 self._pipelineIR.labeled_subsets[subset].subset.add(label) 

541 

542 def removeLabelFromSubset(self, subset: str, label: str) -> None: 

543 """Remove a task label from the specified subset. 

544 

545 Parameters 

546 ---------- 

547 subset : `str` 

548 The labeled subset to modify. 

549 label : `str` 

550 The task label to remove from the specified subset. 

551 

552 Raises 

553 ------ 

554 ValueError 

555 Raised if the specified subset does not exist in the pipeline. 

556 Raised if the specified label does not exist within the specified 

557 subset. 

558 """ 

559 if subset not in self._pipelineIR.labeled_subsets: 

560 raise ValueError(f"Subset {subset} does not appear within the pipeline") 

561 if label not in self._pipelineIR.labeled_subsets[subset].subset: 

562 raise ValueError(f"Label {label} does not appear within the pipeline") 

563 self._pipelineIR.labeled_subsets[subset].subset.remove(label) 

564 

565 def findSubsetsWithLabel(self, label: str) -> set[str]: 

566 """Find any subsets which may contain the specified label. 

567 

568 This function returns the name of subsets which return the specified 

569 label. May return an empty set if there are no subsets, or no subsets 

570 containing the specified label. 

571 

572 Parameters 

573 ---------- 

574 label : `str` 

575 The task label to use in membership check. 

576 

577 Returns 

578 ------- 

579 subsets : `set` of `str` 

580 Returns a set (possibly empty) of subsets names which contain the 

581 specified label. 

582 

583 Raises 

584 ------ 

585 ValueError 

586 Raised if the specified label does not exist within this pipeline. 

587 """ 

588 results = set() 

589 if label not in self._pipelineIR.tasks: 

590 raise ValueError(f"Label {label} does not appear within the pipeline") 

591 for subset in self._pipelineIR.labeled_subsets.values(): 

592 if label in subset.subset: 

593 results.add(subset.label) 

594 return results 

595 

596 @property 

597 def task_labels(self) -> Set[str]: 

598 """Labels of all tasks in the pipelines. 

599 

600 For simple pipelines with no imports, iteration over this set will 

601 match the order in which tasks are defined in the pipeline file. In 

602 all other cases the order is unspecified but deterministic. It is not 

603 dependency-ordered (use ``to_graph().tasks.keys()`` for that). 

604 """ 

605 return self._pipelineIR.tasks.keys() 

606 

607 @property 

608 def subsets(self) -> MappingProxyType[str, set]: 

609 """Returns a `MappingProxyType` where the keys are the labels of 

610 labeled subsets in the `Pipeline` and the values are the set of task 

611 labels contained within that subset. 

612 """ 

613 return MappingProxyType( 

614 {label: subsetIr.subset for label, subsetIr in self._pipelineIR.labeled_subsets.items()} 

615 ) 

616 

617 def addLabeledSubset(self, label: str, description: str, taskLabels: set[str]) -> None: 

618 """Add a new labeled subset to the `Pipeline`. 

619 

620 Parameters 

621 ---------- 

622 label : `str` 

623 The label to assign to the subset. 

624 description : `str` 

625 A description of what the subset is for. 

626 taskLabels : `set` [`str`] 

627 The set of task labels to be associated with the labeled subset. 

628 

629 Raises 

630 ------ 

631 ValueError 

632 Raised if label already exists in the `Pipeline`. 

633 Raised if a task label is not found within the `Pipeline`. 

634 """ 

635 if label in self._pipelineIR.labeled_subsets.keys(): 

636 raise ValueError(f"Subset label {label} is already found within the Pipeline") 

637 if extra := (taskLabels - self._pipelineIR.tasks.keys()): 

638 raise ValueError(f"Task labels {extra} were not found within the Pipeline") 

639 self._pipelineIR.labeled_subsets[label] = pipelineIR.LabeledSubset(label, taskLabels, description) 

640 

641 def removeLabeledSubset(self, label: str) -> None: 

642 """Remove a labeled subset from the `Pipeline`. 

643 

644 Parameters 

645 ---------- 

646 label : `str` 

647 The label of the subset to remove from the `Pipeline`. 

648 

649 Raises 

650 ------ 

651 ValueError 

652 Raised if the label is not found within the `Pipeline`. 

653 """ 

654 if label not in self._pipelineIR.labeled_subsets.keys(): 

655 raise ValueError(f"Subset label {label} was not found in the pipeline") 

656 self._pipelineIR.labeled_subsets.pop(label) 

657 

658 def addInstrument(self, instrument: Instrument | str) -> None: 

659 """Add an instrument to the pipeline, or replace an instrument that is 

660 already defined. 

661 

662 Parameters 

663 ---------- 

664 instrument : `~lsst.daf.butler.instrument.Instrument` or `str` 

665 Either a derived class object of a `lsst.daf.butler.instrument` or 

666 a string corresponding to a fully qualified 

667 `lsst.daf.butler.instrument` name. 

668 """ 

669 if isinstance(instrument, str): 

670 pass 

671 else: 

672 # TODO: assume that this is a subclass of Instrument, no type 

673 # checking 

674 instrument = get_full_type_name(instrument) 

675 self._pipelineIR.instrument = instrument 

676 

677 def getInstrument(self) -> str | None: 

678 """Get the instrument from the pipeline. 

679 

680 Returns 

681 ------- 

682 instrument : `str`, or None 

683 The fully qualified name of a `lsst.obs.base.Instrument` subclass, 

684 name, or None if the pipeline does not have an instrument. 

685 """ 

686 return self._pipelineIR.instrument 

687 

688 def get_data_id(self, universe: DimensionUniverse) -> DataCoordinate: 

689 """Return a data ID with all dimension constraints embedded in the 

690 pipeline. 

691 

692 Parameters 

693 ---------- 

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

695 Object that defines all dimensions. 

696 

697 Returns 

698 ------- 

699 data_id : `lsst.daf.butler.DataCoordinate` 

700 Data ID with all dimension constraints embedded in the 

701 pipeline. 

702 """ 

703 instrument_class_name = self._pipelineIR.instrument 

704 if instrument_class_name is not None: 

705 instrument_class = cast(PipeBaseInstrument, doImportType(instrument_class_name)) 

706 if instrument_class is not None: 

707 return DataCoordinate.standardize(instrument=instrument_class.getName(), universe=universe) 

708 return DataCoordinate.make_empty(universe) 

709 

710 def addTask(self, task: type[PipelineTask] | str, label: str) -> None: 

711 """Add a new task to the pipeline, or replace a task that is already 

712 associated with the supplied label. 

713 

714 Parameters 

715 ---------- 

716 task : `PipelineTask` or `str` 

717 Either a derived class object of a `PipelineTask` or a string 

718 corresponding to a fully qualified `PipelineTask` name. 

719 label : `str` 

720 A label that is used to identify the `PipelineTask` being added. 

721 """ 

722 if isinstance(task, str): 

723 taskName = task 

724 elif issubclass(task, PipelineTask): 

725 taskName = get_full_type_name(task) 

726 else: 

727 raise ValueError( 

728 "task must be either a child class of PipelineTask or a string containing" 

729 " a fully qualified name to one" 

730 ) 

731 if not label: 

732 # in some cases (with command line-generated pipeline) tasks can 

733 # be defined without label which is not acceptable, use task 

734 # _DefaultName in that case 

735 if isinstance(task, str): 

736 task_class = cast(PipelineTask, doImportType(task)) 

737 label = task_class._DefaultName 

738 self._pipelineIR.tasks[label] = pipelineIR.TaskIR(label, taskName) 

739 

740 def removeTask(self, label: str) -> None: 

741 """Remove a task from the pipeline. 

742 

743 Parameters 

744 ---------- 

745 label : `str` 

746 The label used to identify the task that is to be removed. 

747 

748 Raises 

749 ------ 

750 KeyError 

751 If no task with that label exists in the pipeline. 

752 """ 

753 self._pipelineIR.tasks.pop(label) 

754 

755 def addConfigOverride(self, label: str, key: str, value: object) -> None: 

756 """Apply single config override. 

757 

758 Parameters 

759 ---------- 

760 label : `str` 

761 Label of the task. 

762 key : `str` 

763 Fully-qualified field name. 

764 value : object 

765 Value to be given to a field. 

766 """ 

767 self._addConfigImpl(label, pipelineIR.ConfigIR(rest={key: value})) 

768 

769 def addConfigFile(self, label: str, filename: str) -> None: 

770 """Add overrides from a specified file. 

771 

772 Parameters 

773 ---------- 

774 label : `str` 

775 The label used to identify the task associated with config to 

776 modify. 

777 filename : `str` 

778 Path to the override file. 

779 """ 

780 self._addConfigImpl(label, pipelineIR.ConfigIR(file=[filename])) 

781 

782 def addConfigPython(self, label: str, pythonString: str) -> None: 

783 """Add Overrides by running a snippet of python code against a config. 

784 

785 Parameters 

786 ---------- 

787 label : `str` 

788 The label used to identity the task associated with config to 

789 modify. 

790 pythonString : `str` 

791 A string which is valid python code to be executed. This is done 

792 with config as the only local accessible value. 

793 """ 

794 self._addConfigImpl(label, pipelineIR.ConfigIR(python=pythonString)) 

795 

796 def _addConfigImpl(self, label: str, newConfig: pipelineIR.ConfigIR) -> None: 

797 if label == "parameters": 

798 self._pipelineIR.parameters.mapping.update(newConfig.rest) 

799 if newConfig.file: 

800 raise ValueError("Setting parameters section with config file is not supported") 

801 if newConfig.python: 

802 raise ValueError("Setting parameters section using python block in unsupported") 

803 return 

804 if label not in self._pipelineIR.tasks: 

805 raise LookupError(f"There are no tasks labeled '{label}' in the pipeline") 

806 self._pipelineIR.tasks[label].add_or_update_config(newConfig) 

807 

808 def write_to_uri(self, uri: ResourcePathExpression) -> None: 

809 """Write the pipeline to a file or directory. 

810 

811 Parameters 

812 ---------- 

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

814 URI to write to; may have any scheme with 

815 `~lsst.resources.ResourcePath` write support or no scheme for a 

816 local file/directory. Should have a ``.yaml`` extension. 

817 """ 

818 self._pipelineIR.write_to_uri(uri) 

819 

820 def to_graph( 

821 self, registry: Registry | None = None, visualization_only: bool = False 

822 ) -> pipeline_graph.PipelineGraph: 

823 """Construct a pipeline graph from this pipeline. 

824 

825 Constructing a graph applies all configuration overrides, freezes all 

826 configuration, checks all contracts, and checks for dataset type 

827 consistency between tasks (as much as possible without access to a data 

828 repository). It cannot be reversed. 

829 

830 Parameters 

831 ---------- 

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

833 Data repository client. If provided, the graph's dataset types 

834 and dimensions will be resolved (see `PipelineGraph.resolve`). 

835 visualization_only : `bool`, optional 

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

837 storage classes cannot really be determined. This can include 

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

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

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

841 itself is ever actually loaded). 

842 

843 Returns 

844 ------- 

845 graph : `pipeline_graph.PipelineGraph` 

846 Representation of the pipeline as a graph. 

847 """ 

848 instrument_class_name = self._pipelineIR.instrument 

849 data_id = {} 

850 if instrument_class_name is not None: 

851 instrument_class: type[Instrument] = doImportType(instrument_class_name) 

852 if instrument_class is not None: 

853 data_id["instrument"] = instrument_class.getName() 

854 graph = pipeline_graph.PipelineGraph(data_id=data_id) 

855 graph.description = self._pipelineIR.description 

856 for label in self._pipelineIR.tasks: 

857 self._add_task_to_graph(label, graph) 

858 if self._pipelineIR.contracts is not None: 

859 label_to_config = {x.label: x.config for x in graph.tasks.values()} 

860 for contract in self._pipelineIR.contracts: 

861 # execute this in its own line so it can raise a good error 

862 # message if there was problems with the eval 

863 success = eval(contract.contract, None, label_to_config) 

864 if not success: 

865 extra_info = f": {contract.msg}" if contract.msg is not None else "" 

866 raise pipelineIR.ContractError( 

867 f"Contract(s) '{contract.contract}' were not satisfied{extra_info}" 

868 ) 

869 for label, subset in self._pipelineIR.labeled_subsets.items(): 

870 graph.add_task_subset( 

871 label, subset.subset, subset.description if subset.description is not None else "" 

872 ) 

873 for step_ir in self._pipelineIR.steps: 

874 graph.steps.append(step_ir.label, step_ir.dimensions) 

875 if registry is not None or visualization_only: 

876 graph.resolve(registry=registry, visualization_only=visualization_only) 

877 else: 

878 graph.sort() 

879 return graph 

880 

881 def _add_task_to_graph(self, label: str, graph: pipeline_graph.PipelineGraph) -> None: 

882 """Add a single task from this pipeline to a pipeline graph that is 

883 under construction. 

884 

885 Parameters 

886 ---------- 

887 label : `str` 

888 Label for the task to be added. 

889 graph : `pipeline_graph.PipelineGraph` 

890 Graph to add the task to. 

891 """ 

892 if (taskIR := self._pipelineIR.tasks.get(label)) is None: 

893 raise NameError(f"Label {label} does not appear in this pipeline") 

894 taskClass: type[PipelineTask] = doImportType(taskIR.klass) 

895 config = taskClass.ConfigClass() 

896 instrument: PipeBaseInstrument | None = None 

897 if (instrumentName := self._pipelineIR.instrument) is not None: 

898 instrument_cls: type = doImportType(instrumentName) 

899 instrument = instrument_cls() 

900 config.applyConfigOverrides( 

901 instrument, 

902 getattr(taskClass, "_DefaultName", ""), 

903 taskIR.config, 

904 self._pipelineIR.parameters, 

905 label, 

906 ) 

907 graph.add_task(label, taskClass, config) 

908 

909 def __len__(self) -> int: 

910 return len(self._pipelineIR.tasks) 

911 

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

913 if not isinstance(other, Pipeline): 

914 return False 

915 elif self._pipelineIR == other._pipelineIR: 

916 # Shortcut: if the IR is the same, the expanded pipeline must be 

917 # the same as well. But the converse is not true. 

918 return True 

919 else: 

920 # Compare as much as we can (task classes and their edges). 

921 if self.to_graph().diff_tasks(other.to_graph()): 

922 return False 

923 # After DM-27847, we should compare configuration here. 

924 raise NotImplementedError( 

925 "Pipelines cannot be compared because config instances cannot be compared; see DM-27847." 

926 )