Coverage for python/lsst/pipe/base/connections.py: 39%

382 statements  

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

1# This file is part of pipe_base. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

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

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

7# for details of code ownership. 

8# 

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

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

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

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

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

14# 

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

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

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

18# (at your option) any later version. 

19# 

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

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

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

23# GNU General Public License for more details. 

24# 

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

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

27 

28"""Module defining connection classes for PipelineTask.""" 

29 

30from __future__ import annotations 

31 

32__all__ = [ 

33 "AdjustQuantumHelper", 

34 "DeferredDatasetRef", 

35 "InputQuantizedConnection", 

36 "OutputQuantizedConnection", 

37 "PipelineTaskConnections", 

38 "QuantaAdjuster", 

39 "QuantizedConnection", 

40 "ScalarError", 

41 "ScalarError", 

42 "iterConnections", 

43] 

44 

45import dataclasses 

46import itertools 

47import string 

48import uuid 

49import warnings 

50from collections import UserDict, defaultdict 

51from collections.abc import Collection, Generator, Iterable, Iterator, Mapping, Sequence, Set 

52from dataclasses import dataclass 

53from types import MappingProxyType, SimpleNamespace 

54from typing import TYPE_CHECKING, Any 

55 

56from lsst.daf.butler import ( 

57 Butler, 

58 DataCoordinate, 

59 DatasetRef, 

60 DatasetType, 

61 NamedKeyDict, 

62 NamedKeyMapping, 

63 Quantum, 

64) 

65 

66from ._status import NoWorkFound 

67from .connectionTypes import BaseConnection, BaseInput, Output, PrerequisiteInput 

68 

69if TYPE_CHECKING: 

70 from .config import PipelineTaskConfig 

71 from .pipeline_graph import PipelineGraph, TaskNode 

72 from .quantum_graph_skeleton import QuantumGraphSkeleton 

73 

74 

75class ScalarError(TypeError): 

76 """Exception raised when dataset type is configured as scalar 

77 but there are multiple data IDs in a Quantum for that dataset. 

78 """ 

79 

80 

81class PipelineTaskConnectionDict(UserDict): 

82 """A special dict class used by `PipelineTaskConnectionMetaclass`. 

83 

84 This dict is used in `PipelineTaskConnection` class creation, as the 

85 dictionary that is initially used as ``__dict__``. It exists to 

86 intercept connection fields declared in a `PipelineTaskConnection`, and 

87 what name is used to identify them. The names are then added to class 

88 level list according to the connection type of the class attribute. The 

89 names are also used as keys in a class level dictionary associated with 

90 the corresponding class attribute. This information is a duplicate of 

91 what exists in ``__dict__``, but provides a simple place to lookup and 

92 iterate on only these variables. 

93 

94 Parameters 

95 ---------- 

96 *args : `~typing.Any` 

97 Passed to `dict` constructor. 

98 **kwargs : `~typing.Any` 

99 Passed to `dict` constructor. 

100 """ 

101 

102 def __init__(self, *args: Any, **kwargs: Any): 

103 super().__init__(*args, **kwargs) 

104 # Initialize class level variables used to track any declared 

105 # class level variables that are instances of 

106 # connectionTypes.BaseConnection 

107 self.data["inputs"] = set() 

108 self.data["prerequisiteInputs"] = set() 

109 self.data["outputs"] = set() 

110 self.data["initInputs"] = set() 

111 self.data["initOutputs"] = set() 

112 self.data["allConnections"] = {} 

113 

114 def __setitem__(self, name: str, value: Any) -> None: 

115 if isinstance(value, BaseConnection): 

116 if name in { 116 ↛ 126line 116 didn't jump to line 126 because the condition on line 116 was never true

117 "dimensions", 

118 "inputs", 

119 "prerequisiteInputs", 

120 "outputs", 

121 "initInputs", 

122 "initOutputs", 

123 "allConnections", 

124 }: 

125 # Guard against connections whose names are reserved. 

126 raise AttributeError(f"Connection name {name!r} is reserved for internal use.") 

127 if (previous := self.data.get(name)) is not None: 127 ↛ 130line 127 didn't jump to line 130 because the condition on line 127 was never true

128 # Guard against changing the type of an in inherited connection 

129 # by first removing it from the set it's current in. 

130 self.data[previous._connection_type_set].discard(name) 

131 object.__setattr__(value, "varName", name) 

132 self.data["allConnections"][name] = value 

133 self.data[value._connection_type_set].add(name) 

134 # defer to the default behavior 

135 super().__setitem__(name, value) 

136 

137 

138class PipelineTaskConnectionsMetaclass(type): 

139 """Metaclass used in the declaration of PipelineTaskConnections classes. 

140 

141 Parameters 

142 ---------- 

143 name : `str` 

144 Name of connection. 

145 bases : `~collections.abc.Collection` 

146 Base classes. 

147 dct : `~collections.abc.Mapping` 

148 Connections dict. 

149 **kwargs : `~typing.Any` 

150 Additional parameters. 

151 """ 

152 

153 # We can annotate these attributes as `collections.abc.Set` to discourage 

154 # undesirable modifications in type-checked code, since the internal code 

155 # modifying them is in `PipelineTaskConnectionDict` and that doesn't see 

156 # these annotations anyway. 

157 

158 dimensions: Set[str] 

159 """Set of dimension names that define the unit of work for this task. 

160 

161 Required and implied dependencies will automatically be expanded later and 

162 need not be provided. 

163 

164 This is shadowed by an instance-level attribute on 

165 `PipelineTaskConnections` instances. 

166 """ 

167 

168 inputs: Set[str] 

169 """Set with the names of all `~connectionTypes.Input` connection 

170 attributes. 

171 

172 This is updated automatically as class attributes are added. Note that 

173 this attribute is shadowed by an instance-level attribute on 

174 `PipelineTaskConnections` instances. 

175 """ 

176 

177 prerequisiteInputs: Set[str] 

178 """Set with the names of all `~connectionTypes.PrerequisiteInput` 

179 connection attributes. 

180 

181 See `inputs` for additional information. 

182 """ 

183 

184 outputs: Set[str] 

185 """Set with the names of all `~connectionTypes.Output` connection 

186 attributes. 

187 

188 See `inputs` for additional information. 

189 """ 

190 

191 initInputs: Set[str] 

192 """Set with the names of all `~connectionTypes.InitInput` connection 

193 attributes. 

194 

195 See `inputs` for additional information. 

196 """ 

197 

198 initOutputs: Set[str] 

199 """Set with the names of all `~connectionTypes.InitOutput` connection 

200 attributes. 

201 

202 See `inputs` for additional information. 

203 """ 

204 

205 allConnections: Mapping[str, BaseConnection] 

206 """Mapping containing all connection attributes. 

207 

208 See `inputs` for additional information. 

209 """ 

210 

211 def __prepare__(name, bases, **kwargs): # noqa: N804 

212 # Create an instance of our special dict to catch and track all 

213 # variables that are instances of connectionTypes.BaseConnection 

214 # Copy any existing connections from a parent class 

215 dct = PipelineTaskConnectionDict() 

216 for base in bases: 

217 if isinstance(base, PipelineTaskConnectionsMetaclass): 217 ↛ 216line 217 didn't jump to line 216 because the condition on line 217 was always true

218 for name, value in base.allConnections.items(): 218 ↛ 219line 218 didn't jump to line 219 because the loop on line 218 never started

219 dct[name] = value 

220 return dct 

221 

222 def __new__(cls, name, bases, dct, **kwargs): 

223 dimensionsValueError = TypeError( 

224 "PipelineTaskConnections class must be created with a dimensions " 

225 "attribute which is an iterable of dimension names" 

226 ) 

227 

228 if name != "PipelineTaskConnections": 

229 # Verify that dimensions are passed as a keyword in class 

230 # declaration 

231 if "dimensions" not in kwargs: 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true

232 for base in bases: 

233 if hasattr(base, "dimensions"): 

234 kwargs["dimensions"] = base.dimensions 

235 break 

236 if "dimensions" not in kwargs: 

237 raise dimensionsValueError 

238 try: 

239 if isinstance(kwargs["dimensions"], str): 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true

240 raise TypeError( 

241 "Dimensions must be iterable of dimensions, got str,possibly omitted trailing comma" 

242 ) 

243 if not isinstance(kwargs["dimensions"], Iterable): 243 ↛ 244line 243 didn't jump to line 244 because the condition on line 243 was never true

244 raise TypeError("Dimensions must be iterable of dimensions") 

245 dct["dimensions"] = set(kwargs["dimensions"]) 

246 except TypeError as exc: 

247 raise dimensionsValueError from exc 

248 # Lookup any python string templates that may have been used in the 

249 # declaration of the name field of a class connection attribute 

250 allTemplates = set() 

251 stringFormatter = string.Formatter() 

252 # Loop over all connections 

253 for obj in dct["allConnections"].values(): 

254 nameValue = obj.name 

255 # add all the parameters to the set of templates 

256 for param in stringFormatter.parse(nameValue): 

257 if param[1] is not None: 

258 allTemplates.add(param[1]) 

259 

260 # look up any template from base classes and merge them all 

261 # together 

262 mergeDict = {} 

263 mergeDeprecationsDict = {} 

264 for base in bases[::-1]: 

265 if hasattr(base, "defaultTemplates"): 

266 mergeDict.update(base.defaultTemplates) 

267 if hasattr(base, "deprecatedTemplates"): 

268 mergeDeprecationsDict.update(base.deprecatedTemplates) 

269 if "defaultTemplates" in kwargs: 

270 mergeDict.update(kwargs["defaultTemplates"]) 

271 if "deprecatedTemplates" in kwargs: 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true

272 mergeDeprecationsDict.update(kwargs["deprecatedTemplates"]) 

273 if len(mergeDict) > 0: 

274 kwargs["defaultTemplates"] = mergeDict 

275 if len(mergeDeprecationsDict) > 0: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true

276 kwargs["deprecatedTemplates"] = mergeDeprecationsDict 

277 

278 # Verify that if templated strings were used, defaults were 

279 # supplied as an argument in the declaration of the connection 

280 # class 

281 if len(allTemplates) > 0 and "defaultTemplates" not in kwargs: 281 ↛ 282line 281 didn't jump to line 282 because the condition on line 281 was never true

282 raise TypeError( 

283 "PipelineTaskConnection class contains templated attribute names, but no " 

284 "defaut templates were provided, add a dictionary attribute named " 

285 "defaultTemplates which contains the mapping between template key and value" 

286 ) 

287 if len(allTemplates) > 0: 

288 # Verify all templates have a default, and throw if they do not 

289 defaultTemplateKeys = set(kwargs["defaultTemplates"].keys()) 

290 templateDifference = allTemplates.difference(defaultTemplateKeys) 

291 if templateDifference: 291 ↛ 292line 291 didn't jump to line 292 because the condition on line 291 was never true

292 raise TypeError(f"Default template keys were not provided for {templateDifference}") 

293 # Verify that templates do not share names with variable names 

294 # used for a connection, this is needed because of how 

295 # templates are specified in an associated config class. 

296 nameTemplateIntersection = allTemplates.intersection(set(dct["allConnections"].keys())) 

297 if len(nameTemplateIntersection) > 0: 297 ↛ 298line 297 didn't jump to line 298 because the condition on line 297 was never true

298 raise TypeError( 

299 "Template parameters cannot share names with Class attributes" 

300 f" (conflicts are {nameTemplateIntersection})." 

301 ) 

302 dct["defaultTemplates"] = kwargs.get("defaultTemplates", {}) 

303 dct["deprecatedTemplates"] = kwargs.get("deprecatedTemplates", {}) 

304 

305 # Convert all the connection containers into frozensets so they cannot 

306 # be modified at the class scope 

307 for connectionName in ("inputs", "prerequisiteInputs", "outputs", "initInputs", "initOutputs"): 

308 dct[connectionName] = frozenset(dct[connectionName]) 

309 # our custom dict type must be turned into an actual dict to be used in 

310 # type.__new__ 

311 return super().__new__(cls, name, bases, dict(dct)) 

312 

313 def __init__(cls, name, bases, dct, **kwargs): 

314 # This overrides the default init to drop the kwargs argument. Python 

315 # metaclasses will have this argument set if any kwargs are passes at 

316 # class construction time, but should be consumed before calling 

317 # __init__ on the type metaclass. This is in accordance with python 

318 # documentation on metaclasses 

319 super().__init__(name, bases, dct) 

320 

321 def __call__(cls, *, config: PipelineTaskConfig | None = None) -> PipelineTaskConnections: 

322 # MyPy appears not to really understand metaclass.__call__ at all, so 

323 # we need to tell it to ignore __new__ and __init__ calls here. 

324 instance: PipelineTaskConnections = cls.__new__(cls) # type: ignore 

325 

326 # Make mutable copies of all set-like class attributes so derived 

327 # __init__ implementations can modify them in place. 

328 instance.dimensions = set(cls.dimensions) 

329 instance.inputs = set(cls.inputs) 

330 instance.prerequisiteInputs = set(cls.prerequisiteInputs) 

331 instance.outputs = set(cls.outputs) 

332 instance.initInputs = set(cls.initInputs) 

333 instance.initOutputs = set(cls.initOutputs) 

334 

335 # Set self.config. It's a bit strange that we claim to accept None but 

336 # really just raise here, but it's not worth changing now. 

337 from .config import PipelineTaskConfig # local import to avoid cycle 

338 

339 if config is None or not isinstance(config, PipelineTaskConfig): 

340 raise ValueError( 

341 "PipelineTaskConnections must be instantiated with a PipelineTaskConfig instance" 

342 ) 

343 instance.config = config 

344 

345 # Extract the template names that were defined in the config instance 

346 # by looping over the keys of the defaultTemplates dict specified at 

347 # class declaration time. 

348 templateValues = { 

349 name: getattr(config.connections, name) 

350 for name in cls.defaultTemplates # type: ignore 

351 } 

352 

353 # We now assemble a mapping of all connection instances keyed by 

354 # internal name, applying the configuration and templates to make new 

355 # configurations from the class-attribute defaults. This will be 

356 # private, but with a public read-only view. This mapping is what the 

357 # descriptor interface of the class-level attributes will return when 

358 # they are accessed on an instance. This is better than just assigning 

359 # regular instance attributes as it makes it so removed connections 

360 # cannot be accessed on instances, instead of having access to them 

361 # silent fall through to the not-removed class connection instance. 

362 instance._allConnections = {} 

363 instance.allConnections = MappingProxyType(instance._allConnections) 

364 for internal_name, connection in cls.allConnections.items(): 

365 dataset_type_name = getattr(config.connections, internal_name).format(**templateValues) 

366 instance_connection = dataclasses.replace( 

367 connection, 

368 name=dataset_type_name, 

369 doc=( 

370 connection.doc 

371 if connection.deprecated is None 

372 else f"{connection.doc}\n{connection.deprecated}" 

373 ), 

374 _deprecation_context=connection._deprecation_context, 

375 ) 

376 instance._allConnections[internal_name] = instance_connection 

377 

378 # Finally call __init__. The base class implementation does nothing; 

379 # we could have left some of the above implementation there (where it 

380 # originated), but putting it here instead makes it hard for derived 

381 # class implementors to get things into a weird state by delegating to 

382 # super().__init__ in the wrong place, or by forgetting to do that 

383 # entirely. 

384 instance.__init__(config=config) # type: ignore 

385 

386 # Derived-class implementations may have changed the contents of the 

387 # various kinds-of-connection sets; update allConnections to have keys 

388 # that are a union of all those. We get values for the new 

389 # allConnections from the attributes, since any dynamically added new 

390 # ones will not be present in the old allConnections. Typically those 

391 # getattrs will invoke the descriptors and get things from the old 

392 # allConnections anyway. After processing each set we replace it with 

393 # a frozenset. 

394 updated_all_connections = {} 

395 for attrName in ("initInputs", "prerequisiteInputs", "inputs", "initOutputs", "outputs"): 

396 updated_connection_names = getattr(instance, attrName) 

397 updated_all_connections.update( 

398 {name: getattr(instance, name) for name in updated_connection_names} 

399 ) 

400 # Setting these to frozenset is at odds with the type annotation, 

401 # but MyPy can't tell because we're using setattr, and we want to 

402 # lie to it anyway to get runtime guards against post-__init__ 

403 # mutation. 

404 setattr(instance, attrName, frozenset(updated_connection_names)) 

405 # Update the existing dict in place, since we already have a view of 

406 # that. 

407 instance._allConnections.clear() 

408 instance._allConnections.update(updated_all_connections) 

409 

410 for connection_name, obj in instance._allConnections.items(): 

411 if obj.deprecated is not None: 

412 warnings.warn( 

413 f"Connection {connection_name} with datasetType {obj.name} " 

414 f"(from {obj._deprecation_context}): {obj.deprecated}", 

415 FutureWarning, 

416 stacklevel=1, # Report from this location. 

417 ) 

418 

419 # Freeze the connection instance dimensions now. This at odds with the 

420 # type annotation, which says [mutable] `set`, just like the connection 

421 # type attributes (e.g. `inputs`, `outputs`, etc.), though MyPy can't 

422 # tell with those since we're using setattr for them. 

423 instance.dimensions = frozenset(instance.dimensions) # type: ignore 

424 

425 return instance 

426 

427 

428class QuantizedConnection(SimpleNamespace): 

429 r"""A Namespace to map defined variable names of connections to the 

430 associated `lsst.daf.butler.DatasetRef` objects. 

431 

432 This class maps the names used to define a connection on a 

433 `PipelineTaskConnections` class to the corresponding 

434 `~lsst.daf.butler.DatasetRef`\s provided by a `~lsst.daf.butler.Quantum` 

435 instance. This will be a quantum of execution based on the graph created 

436 by examining all the connections defined on the 

437 `PipelineTaskConnections` class. 

438 

439 Parameters 

440 ---------- 

441 **kwargs : `~typing.Any` 

442 Not used. 

443 """ 

444 

445 def __init__(self, **kwargs): 

446 # Create a variable to track what attributes are added. This is used 

447 # later when iterating over this QuantizedConnection instance 

448 object.__setattr__(self, "_attributes", set()) 

449 

450 def __setattr__(self, name: str, value: DatasetRef | list[DatasetRef]) -> None: 

451 # Capture the attribute name as it is added to this object 

452 self._attributes.add(name) 

453 super().__setattr__(name, value) 

454 

455 def __delattr__(self, name): 

456 object.__delattr__(self, name) 

457 self._attributes.remove(name) 

458 

459 def __len__(self) -> int: 

460 return len(self._attributes) 

461 

462 def __iter__( 

463 self, 

464 ) -> Generator[tuple[str, DatasetRef | list[DatasetRef]], None, None]: 

465 """Make an iterator for this `QuantizedConnection`. 

466 

467 Iterating over a `QuantizedConnection` will yield a tuple with the name 

468 of an attribute and the value associated with that name. This is 

469 similar to dict.items() but is on the namespace attributes rather than 

470 dict keys. 

471 """ 

472 yield from ((name, getattr(self, name)) for name in self._attributes) 

473 

474 def keys(self) -> Generator[str, None, None]: 

475 """Return an iterator over all the attributes added to a 

476 `QuantizedConnection` class. 

477 """ 

478 yield from self._attributes 

479 

480 

481class InputQuantizedConnection(QuantizedConnection): 

482 """Input variant of a `QuantizedConnection`.""" 

483 

484 pass 

485 

486 

487class OutputQuantizedConnection(QuantizedConnection): 

488 """Output variant of a `QuantizedConnection`.""" 

489 

490 pass 

491 

492 

493@dataclass(frozen=True) 

494class DeferredDatasetRef: 

495 """A wrapper class for `~lsst.daf.butler.DatasetRef` that indicates that a 

496 `PipelineTask` should receive a `~lsst.daf.butler.DeferredDatasetHandle` 

497 instead of an in-memory dataset. 

498 

499 Attributes 

500 ---------- 

501 datasetRef : `lsst.daf.butler.DatasetRef` 

502 The `lsst.daf.butler.DatasetRef` that will be eventually used to 

503 resolve a dataset. 

504 """ 

505 

506 datasetRef: DatasetRef 

507 

508 def __getattr__(self, name: str) -> Any: 

509 return getattr(self.datasetRef, name) 

510 

511 

512class PipelineTaskConnections(metaclass=PipelineTaskConnectionsMetaclass): 

513 """PipelineTaskConnections is a class used to declare desired IO when a 

514 PipelineTask is run by an activator. 

515 

516 Parameters 

517 ---------- 

518 config : `PipelineTaskConfig` 

519 A `PipelineTaskConfig` class instance whose class has been configured 

520 to use this `PipelineTaskConnections` class. 

521 

522 See Also 

523 -------- 

524 iterConnections : Iterator over selected connections. 

525 

526 Notes 

527 ----- 

528 ``PipelineTaskConnection`` classes are created by declaring class 

529 attributes of types defined in `lsst.pipe.base.connectionTypes` and are 

530 listed as follows: 

531 

532 * ``InitInput`` - Defines connections in a quantum graph which are used as 

533 inputs to the ``__init__`` function of the `PipelineTask` corresponding 

534 to this class 

535 * ``InitOuput`` - Defines connections in a quantum graph which are to be 

536 persisted using a butler at the end of the ``__init__`` function of the 

537 `PipelineTask` corresponding to this class. The variable name used to 

538 define this connection should be the same as an attribute name on the 

539 `PipelineTask` instance. E.g. if an ``InitOutput`` is declared with 

540 the name ``outputSchema`` in a ``PipelineTaskConnections`` class, then 

541 a `PipelineTask` instance should have an attribute 

542 ``self.outputSchema`` defined. Its value is what will be saved by the 

543 activator framework. 

544 * ``PrerequisiteInput`` - An input connection type that defines a 

545 `lsst.daf.butler.DatasetType` that must be present at execution time, 

546 but that will not be used during the course of creating the quantum 

547 graph to be executed. These most often are things produced outside the 

548 processing pipeline, such as reference catalogs. 

549 * ``Input`` - Input `lsst.daf.butler.DatasetType` objects that will be used 

550 in the ``run`` method of a `PipelineTask`. The name used to declare 

551 class attribute must match a function argument name in the ``run`` 

552 method of a `PipelineTask`. E.g. If the ``PipelineTaskConnections`` 

553 defines an ``Input`` with the name ``calexp``, then the corresponding 

554 signature should be ``PipelineTask.run(calexp, ...)`` 

555 * ``Output`` - A `lsst.daf.butler.DatasetType` that will be produced by an 

556 execution of a `PipelineTask`. The name used to declare the connection 

557 must correspond to an attribute of a `Struct` that is returned by a 

558 `PipelineTask` ``run`` method. E.g. if an output connection is 

559 defined with the name ``measCat``, then the corresponding 

560 ``PipelineTask.run`` method must return ``Struct(measCat=X,..)`` where 

561 X matches the ``storageClass`` type defined on the output connection. 

562 

563 Attributes of these types can also be created, replaced, or deleted on the 

564 `PipelineTaskConnections` instance in the ``__init__`` method, if more than 

565 just the name depends on the configuration. It is preferred to define them 

566 in the class when possible (even if configuration may cause the connection 

567 to be removed from the instance). 

568 

569 The process of declaring a ``PipelineTaskConnection`` class involves 

570 parameters passed in the declaration statement. 

571 

572 The first parameter is ``dimensions`` which is an iterable of strings which 

573 defines the unit of processing the run method of a corresponding 

574 `PipelineTask` will operate on. These dimensions must match dimensions that 

575 exist in the butler registry which will be used in executing the 

576 corresponding `PipelineTask`. The dimensions may be also modified in 

577 subclass ``__init__`` methods if they need to depend on configuration. 

578 

579 The second parameter is labeled ``defaultTemplates`` and is conditionally 

580 optional. The name attributes of connections can be specified as python 

581 format strings, with named format arguments. If any of the name parameters 

582 on connections defined in a `PipelineTaskConnections` class contain a 

583 template, then a default template value must be specified in the 

584 ``defaultTemplates`` argument. This is done by passing a dictionary with 

585 keys corresponding to a template identifier, and values corresponding to 

586 the value to use as a default when formatting the string. For example if 

587 ``ConnectionsClass.calexp.name = '{input}Coadd_calexp'`` then 

588 ``defaultTemplates`` = {'input': 'deep'}. 

589 

590 Once a `PipelineTaskConnections` class is created, it is used in the 

591 creation of a `PipelineTaskConfig`. This is further documented in the 

592 documentation of `PipelineTaskConfig`. For the purposes of this 

593 documentation, the relevant information is that the config class allows 

594 configuration of connection names by users when running a pipeline. 

595 

596 Instances of a `PipelineTaskConnections` class are used by the pipeline 

597 task execution framework to introspect what a corresponding `PipelineTask` 

598 will require, and what it will produce. 

599 

600 Examples 

601 -------- 

602 >>> from lsst.pipe.base import connectionTypes as cT 

603 >>> from lsst.pipe.base import PipelineTaskConnections 

604 >>> from lsst.pipe.base import PipelineTaskConfig 

605 >>> class ExampleConnections( 

606 ... PipelineTaskConnections, 

607 ... dimensions=("A", "B"), 

608 ... defaultTemplates={"foo": "Example"}, 

609 ... ): 

610 ... inputConnection = cT.Input( 

611 ... doc="Example input", 

612 ... dimensions=("A", "B"), 

613 ... storageClass=Exposure, 

614 ... name="{foo}Dataset", 

615 ... ) 

616 ... outputConnection = cT.Output( 

617 ... doc="Example output", 

618 ... dimensions=("A", "B"), 

619 ... storageClass=Exposure, 

620 ... name="{foo}output", 

621 ... ) 

622 >>> class ExampleConfig( 

623 ... PipelineTaskConfig, pipelineConnections=ExampleConnections 

624 ... ): 

625 ... pass 

626 >>> config = ExampleConfig() 

627 >>> config.connections.foo = Modified 

628 >>> config.connections.outputConnection = "TotallyDifferent" 

629 >>> connections = ExampleConnections(config=config) 

630 >>> assert connections.inputConnection.name == "ModifiedDataset" 

631 >>> assert connections.outputConnection.name == "TotallyDifferent" 

632 """ 

633 

634 # We annotate these attributes as mutable sets because that's what they are 

635 # inside derived ``__init__`` implementations and that's what matters most 

636 # After that's done, the metaclass __call__ makes them into frozensets, but 

637 # relatively little code interacts with them then, and that code knows not 

638 # to try to modify them without having to be told that by mypy. 

639 

640 dimensions: set[str] 

641 """Set of dimension names that define the unit of work for this task. 

642 

643 Required and implied dependencies will automatically be expanded later and 

644 need not be provided. 

645 

646 This may be replaced or modified in ``__init__`` to change the dimensions 

647 of the task. After ``__init__`` it will be a `frozenset` and may not be 

648 replaced. 

649 """ 

650 

651 inputs: set[str] 

652 """Set with the names of all `connectionTypes.Input` connection attributes. 

653 

654 This is updated automatically as class attributes are added, removed, or 

655 replaced in ``__init__``. Removing entries from this set will cause those 

656 connections to be removed after ``__init__`` completes, but this is 

657 supported only for backwards compatibility; new code should instead just 

658 delete the collection attributed directly. After ``__init__`` this will be 

659 a `frozenset` and may not be replaced. 

660 """ 

661 

662 prerequisiteInputs: set[str] 

663 """Set with the names of all `~connectionTypes.PrerequisiteInput` 

664 connection attributes. 

665 

666 See `inputs` for additional information. 

667 """ 

668 

669 outputs: set[str] 

670 """Set with the names of all `~connectionTypes.Output` connection 

671 attributes. 

672 

673 See `inputs` for additional information. 

674 """ 

675 

676 initInputs: set[str] 

677 """Set with the names of all `~connectionTypes.InitInput` connection 

678 attributes. 

679 

680 See `inputs` for additional information. 

681 """ 

682 

683 initOutputs: set[str] 

684 """Set with the names of all `~connectionTypes.InitOutput` connection 

685 attributes. 

686 

687 See `inputs` for additional information. 

688 """ 

689 

690 allConnections: Mapping[str, BaseConnection] 

691 """Mapping holding all connection attributes. 

692 

693 This is a read-only view that is automatically updated when connection 

694 attributes are added, removed, or replaced in ``__init__``. It is also 

695 updated after ``__init__`` completes to reflect changes in `inputs`, 

696 `prerequisiteInputs`, `outputs`, `initInputs`, and `initOutputs`. 

697 """ 

698 

699 _allConnections: dict[str, BaseConnection] 

700 

701 def __init__(self, *, config: PipelineTaskConfig | None = None): 

702 pass 

703 

704 def __setattr__(self, name: str, value: Any) -> None: 

705 if isinstance(value, BaseConnection): 

706 previous = self._allConnections.get(name) 

707 try: 

708 getattr(self, value._connection_type_set).add(name) 

709 except AttributeError: 

710 # Attempt to call add on a frozenset, which is what these sets 

711 # are after __init__ is done. 

712 raise TypeError("Connections objects are frozen after construction.") from None 

713 if previous is not None and value._connection_type_set != previous._connection_type_set: 

714 # Connection has changed type, e.g. Input to PrerequisiteInput; 

715 # update the sets accordingly. To be extra defensive about 

716 # multiple assignments we use the type of the previous instance 

717 # instead of assuming that's the same as the type of the self, 

718 # which is just the default. Use discard instead of remove so 

719 # manually removing from these sets first is never an error. 

720 getattr(self, previous._connection_type_set).discard(name) 

721 self._allConnections[name] = value 

722 if hasattr(self.__class__, name): 

723 # Don't actually set the attribute if this was a connection 

724 # declared in the class; in that case we let the descriptor 

725 # return the value we just added to allConnections. 

726 return 

727 # Actually add the attribute. 

728 super().__setattr__(name, value) 

729 

730 def __delattr__(self, name): 

731 """Descriptor delete method.""" 

732 previous = self._allConnections.get(name) 

733 if previous is not None: 

734 # Delete this connection's name from the appropriate set, which we 

735 # have to get from the previous instance instead of assuming it's 

736 # the same set that was appropriate for the class-level default. 

737 # Use discard instead of remove so manually removing from these 

738 # sets first is never an error. 

739 try: 

740 getattr(self, previous._connection_type_set).discard(name) 

741 except AttributeError: 

742 # Attempt to call discard on a frozenset, which is what these 

743 # sets are after __init__ is done. 

744 raise TypeError("Connections objects are frozen after construction.") from None 

745 del self._allConnections[name] 

746 if hasattr(self.__class__, name): 

747 # Don't actually delete the attribute if this was a connection 

748 # declared in the class; in that case we let the descriptor 

749 # see that it's no longer present in allConnections. 

750 return 

751 # Actually delete the attribute. 

752 super().__delattr__(name) 

753 

754 def buildDatasetRefs( 

755 self, quantum: Quantum 

756 ) -> tuple[InputQuantizedConnection, OutputQuantizedConnection]: 

757 """Build `QuantizedConnection` corresponding to input 

758 `~lsst.daf.butler.Quantum`. 

759 

760 Parameters 

761 ---------- 

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

763 Quantum object which defines the inputs and outputs for a given 

764 unit of processing. 

765 

766 Returns 

767 ------- 

768 retVal : `tuple` of (`InputQuantizedConnection`, \ 

769 `OutputQuantizedConnection`) 

770 Namespaces mapping attribute names 

771 (identifiers of connections) to butler references defined in the 

772 input `~lsst.daf.butler.Quantum`. 

773 """ 

774 inputDatasetRefs = InputQuantizedConnection() 

775 outputDatasetRefs = OutputQuantizedConnection() 

776 

777 # populate inputDatasetRefs from quantum inputs 

778 for attributeName in itertools.chain(self.inputs, self.prerequisiteInputs): 

779 # get the attribute identified by name 

780 attribute = getattr(self, attributeName) 

781 # if the dataset is marked to load deferred, wrap it in a 

782 # DeferredDatasetRef 

783 quantumInputRefs: list[DatasetRef] | list[DeferredDatasetRef] 

784 if attribute.deferLoad: 

785 quantumInputRefs = [ 

786 DeferredDatasetRef(datasetRef=ref) for ref in quantum.inputs[attribute.name] 

787 ] 

788 else: 

789 quantumInputRefs = list(quantum.inputs[attribute.name]) 

790 # Unpack arguments that are not marked multiples (list of 

791 # length one) 

792 if not attribute.multiple: 

793 if len(quantumInputRefs) > 1: 

794 raise ScalarError( 

795 "Received multiple datasets " 

796 f"{', '.join(str(r.dataId) for r in quantumInputRefs)} " 

797 f"for scalar connection {attributeName} " 

798 f"({quantumInputRefs[0].datasetType.name}) " 

799 f"of quantum for {quantum.taskName} with data ID {quantum.dataId}." 

800 ) 

801 if len(quantumInputRefs) == 0: 

802 continue 

803 setattr(inputDatasetRefs, attributeName, quantumInputRefs[0]) 

804 else: 

805 # Add to the QuantizedConnection identifier 

806 setattr(inputDatasetRefs, attributeName, quantumInputRefs) 

807 

808 # populate outputDatasetRefs from quantum outputs 

809 for attributeName in self.outputs: 

810 # get the attribute identified by name 

811 attribute = getattr(self, attributeName) 

812 value = quantum.outputs[attribute.name] 

813 # Unpack arguments that are not marked multiples (list of 

814 # length one) 

815 if not attribute.multiple: 

816 setattr(outputDatasetRefs, attributeName, value[0]) 

817 else: 

818 setattr(outputDatasetRefs, attributeName, value) 

819 

820 return inputDatasetRefs, outputDatasetRefs 

821 

822 def adjustQuantum( 

823 self, 

824 inputs: dict[str, tuple[BaseInput, Collection[DatasetRef]]], 

825 outputs: dict[str, tuple[Output, Collection[DatasetRef]]], 

826 label: str, 

827 data_id: DataCoordinate, 

828 ) -> tuple[ 

829 Mapping[str, tuple[BaseInput, Collection[DatasetRef]]], 

830 Mapping[str, tuple[Output, Collection[DatasetRef]]], 

831 ]: 

832 """Override to make adjustments to `lsst.daf.butler.DatasetRef` objects 

833 in the `lsst.daf.butler.Quantum` during the graph generation stage 

834 of the activator. 

835 

836 Parameters 

837 ---------- 

838 inputs : `dict` 

839 Dictionary whose keys are an input (regular or prerequisite) 

840 connection name and whose values are a tuple of the connection 

841 instance and a collection of associated 

842 `~lsst.daf.butler.DatasetRef` objects. 

843 The exact type of the nested collections is unspecified; it can be 

844 assumed to be multi-pass iterable and support `len` and ``in``, but 

845 it should not be mutated in place. In contrast, the outer 

846 dictionaries are guaranteed to be temporary copies that are true 

847 `dict` instances, and hence may be modified and even returned; this 

848 is especially useful for delegating to `super` (see notes below). 

849 outputs : `~collections.abc.Mapping` 

850 Mapping of output datasets, with the same structure as ``inputs``. 

851 label : `str` 

852 Label for this task in the pipeline (should be used in all 

853 diagnostic messages). 

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

855 Data ID for this quantum in the pipeline (should be used in all 

856 diagnostic messages). 

857 

858 Returns 

859 ------- 

860 adjusted_inputs : `~collections.abc.Mapping` 

861 Mapping of the same form as ``inputs`` with updated containers of 

862 input `~lsst.daf.butler.DatasetRef` objects. Connections that are 

863 not changed should not be returned at all. Datasets may only be 

864 removed, not added. Nested collections may be of any multi-pass 

865 iterable type, and the order of iteration will set the order of 

866 iteration within `PipelineTask.runQuantum`. 

867 adjusted_outputs : `~collections.abc.Mapping` 

868 Mapping of updated output datasets, with the same structure and 

869 interpretation as ``adjusted_inputs``. 

870 

871 Raises 

872 ------ 

873 ScalarError 

874 Raised if any `Input` or `PrerequisiteInput` connection has 

875 ``multiple`` set to `False`, but multiple datasets. 

876 NoWorkFound 

877 Raised to indicate that this quantum should not be run; not enough 

878 datasets were found for a regular `Input` connection, and the 

879 quantum should be pruned or skipped. 

880 FileNotFoundError 

881 Raised to cause QuantumGraph generation to fail (with the message 

882 included in this exception); not enough datasets were found for a 

883 `PrerequisiteInput` connection. 

884 

885 Notes 

886 ----- 

887 The base class implementation performs important checks. It always 

888 returns an empty mapping (i.e. makes no adjustments). It should 

889 always called be via `super` by custom implementations, ideally at the 

890 end of the custom implementation with already-adjusted mappings when 

891 any datasets are actually dropped, e.g.: 

892 

893 .. code-block:: python 

894 

895 def adjustQuantum(self, inputs, outputs, label, data_id): 

896 # Filter out some dataset refs for one connection. 

897 connection, old_refs = inputs["my_input"] 

898 new_refs = [ref for ref in old_refs if ...] 

899 adjusted_inputs = {"my_input", (connection, new_refs)} 

900 # Update the original inputs so we can pass them to super. 

901 inputs.update(adjusted_inputs) 

902 # Can ignore outputs from super because they are guaranteed 

903 # to be empty. 

904 super().adjustQuantum(inputs, outputs, label_data_id) 

905 # Return only the connections we modified. 

906 return adjusted_inputs, {} 

907 

908 Removing outputs here is guaranteed to affect what is actually 

909 passed to `PipelineTask.runQuantum`, but its effect on the larger 

910 graph may be deferred to execution, depending on the context in 

911 which `adjustQuantum` is being run: if one quantum removes an output 

912 that is needed by a second quantum as input, the second quantum may not 

913 be adjusted (and hence pruned or skipped) until that output is actually 

914 found to be missing at execution time. 

915 

916 Tasks that desire zip-iteration consistency between any combinations of 

917 connections that have the same data ID should generally implement 

918 `adjustQuantum` to achieve this, even if they could also run that 

919 logic during execution; this allows the system to see outputs that will 

920 not be produced because the corresponding input is missing as early as 

921 possible. 

922 """ 

923 for name, (input_connection, refs) in inputs.items(): 

924 dataset_type_name = input_connection.name 

925 if not input_connection.multiple and len(refs) > 1: 

926 raise ScalarError( 

927 f"Found multiple datasets {', '.join(str(r.dataId) for r in refs)} " 

928 f"for non-multiple input connection {label}.{name} ({dataset_type_name}) " 

929 f"for quantum data ID {data_id}." 

930 ) 

931 if len(refs) < input_connection.minimum: 

932 if isinstance(input_connection, PrerequisiteInput): 

933 # This branch should only be possible during QG generation, 

934 # or if someone deleted the dataset between making the QG 

935 # and trying to run it. Either one should be a hard error. 

936 raise FileNotFoundError( 

937 f"Not enough datasets ({len(refs)}) found for non-optional connection {label}.{name} " 

938 f"({dataset_type_name}) with minimum={input_connection.minimum} for quantum data ID " 

939 f"{data_id}." 

940 ) 

941 else: 

942 raise NoWorkFound(label, name, input_connection) 

943 for name, (output_connection, refs) in outputs.items(): 

944 dataset_type_name = output_connection.name 

945 if not output_connection.multiple and len(refs) > 1: 

946 raise ScalarError( 

947 f"Found multiple datasets {', '.join(str(r.dataId) for r in refs)} " 

948 f"for non-multiple output connection {label}.{name} ({dataset_type_name}) " 

949 f"for quantum data ID {data_id}." 

950 ) 

951 return {}, {} 

952 

953 def getSpatialBoundsConnections(self) -> Iterable[str]: 

954 """Return the names of regular input and output connections whose data 

955 IDs should be used to compute the spatial bounds of this task's quanta. 

956 

957 The spatial bound for a quantum is defined as the union of the regions 

958 of all data IDs of all connections returned here, along with the region 

959 of the quantum data ID (if the task has spatial dimensions). 

960 

961 Returns 

962 ------- 

963 connection_names : `collections.abc.Iterable` [ `str` ] 

964 Names of collections with spatial dimensions. These are the 

965 task-internal connection names, not butler dataset type names. 

966 

967 Notes 

968 ----- 

969 The spatial bound is used to search for prerequisite inputs that have 

970 skypix dimensions. The default implementation returns an empty 

971 iterable, which is usually sufficient for tasks with spatial 

972 dimensions, but if a task's inputs or outputs are associated with 

973 spatial regions that extend beyond the quantum data ID's region, this 

974 method may need to be overridden to expand the set of prerequisite 

975 inputs found. 

976 

977 Tasks that do not have spatial dimensions that have skypix prerequisite 

978 inputs should always override this method, as the default spatial 

979 bounds otherwise cover the full sky. 

980 """ 

981 return () 

982 

983 def getTemporalBoundsConnections(self) -> Iterable[str]: 

984 """Return the names of regular input and output connections whose data 

985 IDs should be used to compute the temporal bounds of this task's 

986 quanta. 

987 

988 The temporal bound for a quantum is defined as the union of the 

989 timespans of all data IDs of all connections returned here, along with 

990 the timespan of the quantum data ID (if the task has temporal 

991 dimensions). 

992 

993 Returns 

994 ------- 

995 connection_names : `collections.abc.Iterable` [ `str` ] 

996 Names of collections with temporal dimensions. These are the 

997 task-internal connection names, not butler dataset type names. 

998 

999 Notes 

1000 ----- 

1001 The temporal bound is used to search for prerequisite inputs that are 

1002 calibration datasets. The default implementation returns an empty 

1003 iterable, which is usually sufficient for tasks with temporal 

1004 dimensions, but if a task's inputs or outputs are associated with 

1005 timespans that extend beyond the quantum data ID's timespan, this 

1006 method may need to be overridden to expand the set of prerequisite 

1007 inputs found. 

1008 

1009 Tasks that do not have temporal dimensions that do not implement this 

1010 method will use an infinite timespan for any calibration lookups. 

1011 """ 

1012 return () 

1013 

1014 def adjust_all_quanta(self, adjuster: QuantaAdjuster) -> None: 

1015 """Customize the set of quanta predicted for this task during quantum 

1016 graph generation. 

1017 

1018 Parameters 

1019 ---------- 

1020 adjuster : `QuantaAdjuster` 

1021 A helper object that implementations can use to modify the 

1022 under-construction quantum graph. 

1023 

1024 Notes 

1025 ----- 

1026 This hook is called before `adjustQuantum`, which is where built-in 

1027 checks for `NoWorkFound` cases and missing prerequisites are handled. 

1028 This means that the set of preliminary quanta seen by this method could 

1029 include some that would normally be dropped later. 

1030 """ 

1031 pass 

1032 

1033 

1034def iterConnections( 

1035 connections: PipelineTaskConnections, connectionType: str | Iterable[str] 

1036) -> Generator[BaseConnection, None, None]: 

1037 """Create an iterator over the selected connections type which yields 

1038 all the defined connections of that type. 

1039 

1040 Parameters 

1041 ---------- 

1042 connections : `PipelineTaskConnections` 

1043 An instance of a `PipelineTaskConnections` object that will be iterated 

1044 over. 

1045 connectionType : `str` 

1046 The type of connections to iterate over, valid values are inputs, 

1047 outputs, prerequisiteInputs, initInputs, initOutputs. 

1048 

1049 Yields 

1050 ------ 

1051 connection: `~.connectionTypes.BaseConnection` 

1052 A connection defined on the input connections object of the type 

1053 supplied. The yielded value Will be an derived type of 

1054 `~.connectionTypes.BaseConnection`. 

1055 """ 

1056 if isinstance(connectionType, str): 

1057 connectionType = (connectionType,) 

1058 for name in itertools.chain.from_iterable(getattr(connections, ct) for ct in connectionType): 

1059 yield getattr(connections, name) 

1060 

1061 

1062@dataclass 

1063class AdjustQuantumHelper: 

1064 """Helper class for calling `PipelineTaskConnections.adjustQuantum`. 

1065 

1066 This class holds `inputs` and `outputs` mappings in the form used by 

1067 `lsst.daf.butler.Quantum` and execution harness code, i.e. with 

1068 `~lsst.daf.butler.DatasetType` keys, translating them to and from the 

1069 connection-oriented mappings used inside `PipelineTaskConnections`. 

1070 """ 

1071 

1072 inputs: NamedKeyMapping[DatasetType, Sequence[DatasetRef]] 

1073 """Mapping of regular input and prerequisite input datasets, grouped by 

1074 `~lsst.daf.butler.DatasetType`. 

1075 """ 

1076 

1077 outputs: NamedKeyMapping[DatasetType, Sequence[DatasetRef]] 

1078 """Mapping of output datasets, grouped by `~lsst.daf.butler.DatasetType`. 

1079 """ 

1080 

1081 inputs_adjusted: bool = False 

1082 """Whether any inputs were removed in the last call to `adjust_in_place`. 

1083 """ 

1084 

1085 outputs_adjusted: bool = False 

1086 """Whether any outputs were removed in the last call to `adjust_in_place`. 

1087 """ 

1088 

1089 def adjust_in_place( 

1090 self, 

1091 connections: PipelineTaskConnections, 

1092 label: str, 

1093 data_id: DataCoordinate, 

1094 ) -> None: 

1095 """Call `~PipelineTaskConnections.adjustQuantum` and update ``self`` 

1096 with its results. 

1097 

1098 Parameters 

1099 ---------- 

1100 connections : `PipelineTaskConnections` 

1101 Instance on which to call `~PipelineTaskConnections.adjustQuantum`. 

1102 label : `str` 

1103 Label for this task in the pipeline (should be used in all 

1104 diagnostic messages). 

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

1106 Data ID for this quantum in the pipeline (should be used in all 

1107 diagnostic messages). 

1108 """ 

1109 # Translate self's DatasetType-keyed, Quantum-oriented mappings into 

1110 # connection-keyed, PipelineTask-oriented mappings. 

1111 inputs_by_connection: dict[str, tuple[BaseInput, tuple[DatasetRef, ...]]] = {} 

1112 outputs_by_connection: dict[str, tuple[Output, tuple[DatasetRef, ...]]] = {} 

1113 for name in itertools.chain(connections.inputs, connections.prerequisiteInputs): 

1114 connection = getattr(connections, name) 

1115 dataset_type_name = connection.name 

1116 inputs_by_connection[name] = (connection, tuple(self.inputs.get(dataset_type_name, ()))) 

1117 for name in itertools.chain(connections.outputs): 

1118 connection = getattr(connections, name) 

1119 dataset_type_name = connection.name 

1120 outputs_by_connection[name] = (connection, tuple(self.outputs.get(dataset_type_name, ()))) 

1121 # Actually call adjustQuantum. 

1122 # MyPy correctly complains that this call is not quite legal, but the 

1123 # method docs explain exactly what's expected and it's the behavior we 

1124 # want. It'd be nice to avoid this if we ever have to change the 

1125 # interface anyway, but not an immediate problem. 

1126 adjusted_inputs_by_connection, adjusted_outputs_by_connection = connections.adjustQuantum( 

1127 inputs_by_connection, # type: ignore 

1128 outputs_by_connection, # type: ignore 

1129 label, 

1130 data_id, 

1131 ) 

1132 # Translate adjustments to DatasetType-keyed, Quantum-oriented form, 

1133 # installing new mappings in self if necessary. 

1134 if adjusted_inputs_by_connection: 

1135 adjusted_inputs = NamedKeyDict[DatasetType, tuple[DatasetRef, ...]](self.inputs) 

1136 for name, (connection, updated_refs) in adjusted_inputs_by_connection.items(): 

1137 dataset_type_name = connection.name 

1138 if not set(updated_refs).issubset(self.inputs[dataset_type_name]): 

1139 raise RuntimeError( 

1140 f"adjustQuantum implementation for task with label {label} returned {name} " 

1141 f"({dataset_type_name}) input datasets that are not a subset of those " 

1142 f"it was given for data ID {data_id}." 

1143 ) 

1144 adjusted_inputs[dataset_type_name] = tuple(updated_refs) 

1145 self.inputs = adjusted_inputs.freeze() 

1146 self.inputs_adjusted = True 

1147 else: 

1148 self.inputs_adjusted = False 

1149 if adjusted_outputs_by_connection: 

1150 adjusted_outputs = NamedKeyDict[DatasetType, tuple[DatasetRef, ...]](self.outputs) 

1151 for name, (connection, updated_refs) in adjusted_outputs_by_connection.items(): 

1152 dataset_type_name = connection.name 

1153 if not set(updated_refs).issubset(self.outputs[dataset_type_name]): 

1154 raise RuntimeError( 

1155 f"adjustQuantum implementation for task with label {label} returned {name} " 

1156 f"({dataset_type_name}) output datasets that are not a subset of those " 

1157 f"it was given for data ID {data_id}." 

1158 ) 

1159 adjusted_outputs[dataset_type_name] = tuple(updated_refs) 

1160 self.outputs = adjusted_outputs.freeze() 

1161 self.outputs_adjusted = True 

1162 else: 

1163 self.outputs_adjusted = False 

1164 

1165 

1166class QuantaAdjuster: 

1167 """A helper class for the `PipelineTaskConnections.adjust_all_quanta` hook. 

1168 

1169 Parameters 

1170 ---------- 

1171 task_label : `str` 

1172 Label of the task whose quanta are being adjusted. 

1173 pipeline_graph : `pipeline_graph.PipelineGraph` 

1174 Pipeline graph the quantum graph is being built from. 

1175 skeleton : `quantum_graph_skeleton.QuantumGraphSkeleton` 

1176 Under-construction quantum graph that will be modified in place. 

1177 butler : `lsst.daf.butler.Butler` 

1178 Read-only instance with its default collection search path set to the 

1179 input collections passed to the quantum-graph builder. 

1180 """ 

1181 

1182 def __init__( 

1183 self, task_label: str, pipeline_graph: PipelineGraph, skeleton: QuantumGraphSkeleton, butler: Butler 

1184 ): 

1185 self._task_node = pipeline_graph.tasks[task_label] 

1186 self._pipeline_graph = pipeline_graph 

1187 self._skeleton = skeleton 

1188 self._n_removed = 0 

1189 self._butler = butler 

1190 

1191 @property 

1192 def task_label(self) -> str: 

1193 """The label this task has been configured with.""" 

1194 return self._task_node.label 

1195 

1196 @property 

1197 def task_node(self) -> TaskNode: 

1198 """The node for this task in the pipeline graph.""" 

1199 return self._task_node 

1200 

1201 @property 

1202 def butler(self) -> Butler: 

1203 """Read-only instance with its default collection search path set to 

1204 the input collections passed to the quantum-graph builder. 

1205 """ 

1206 return self._butler 

1207 

1208 def iter_data_ids(self) -> Iterator[DataCoordinate]: 

1209 """Iterate over the data IDs of all quanta for this task. 

1210 

1211 Returns 

1212 ------- 

1213 data_ids : `~collections.abc.Iterator` [ \ 

1214 `~lsst.daf.butler.DataCoordinate` ] 

1215 Data IDs. These are minimal data IDs without dimension records or 

1216 implied values; use `expand_quantum_data_id` to get a full data ID 

1217 when needed. 

1218 """ 

1219 for key in self._skeleton.get_quanta(self._task_node.label): 

1220 yield DataCoordinate.from_required_values(self._task_node.dimensions, key.data_id_values) 

1221 

1222 def remove_quantum(self, data_id: DataCoordinate) -> None: 

1223 """Remove a quantum from the graph. 

1224 

1225 Parameters 

1226 ---------- 

1227 data_id : `~lsst.daf.butler.DataCoordinate` 

1228 Data ID of the quantum to remove. All outputs will be removed as 

1229 well. 

1230 """ 

1231 from .quantum_graph_skeleton import QuantumKey 

1232 

1233 self._skeleton.remove_quantum_node( 

1234 QuantumKey(self._task_node.label, data_id.required_values), remove_outputs=True 

1235 ) 

1236 self._n_removed += 1 

1237 

1238 def get_inputs(self, quantum_data_id: DataCoordinate) -> dict[str, list[DataCoordinate]]: 

1239 """Return the data IDs of all regular inputs to a quantum. 

1240 

1241 Parameters 

1242 ---------- 

1243 quantum_data_id : `~lsst.daf.butler.DataCoordinate` 

1244 Data ID of the quantum to get the inputs of. 

1245 

1246 Returns 

1247 ------- 

1248 inputs : `dict` [ `str`, `list` [ `~lsst.daf.butler.DataCoordinate` ] ] 

1249 Data IDs of inputs, keyed by the connection name (the internal task 

1250 name, not the dataset type name). This only contains regular 

1251 inputs, not init-inputs or prerequisite inputs. 

1252 

1253 Notes 

1254 ----- 

1255 If two connections have the same dataset type, the current 

1256 implementation assumes the set of datasets is the same for the two 

1257 connections. This limitation may be removed in the future. 

1258 """ 

1259 from .quantum_graph_skeleton import DatasetKey, QuantumKey 

1260 

1261 by_dataset_type_name: defaultdict[str, list[DataCoordinate]] = defaultdict(list) 

1262 quantum_key = QuantumKey(self._task_node.label, quantum_data_id.required_values) 

1263 for dataset_key in self._skeleton.iter_inputs_of(quantum_key): 

1264 if not isinstance(dataset_key, DatasetKey): 

1265 continue 

1266 dataset_type_node = self._pipeline_graph.dataset_types[dataset_key.parent_dataset_type_name] 

1267 by_dataset_type_name[dataset_key.parent_dataset_type_name].append( 

1268 DataCoordinate.from_required_values(dataset_type_node.dimensions, dataset_key.data_id_values) 

1269 ) 

1270 return { 

1271 edge.connection_name: by_dataset_type_name[edge.parent_dataset_type_name] 

1272 for edge in self._task_node.iter_all_inputs() 

1273 } 

1274 

1275 def get_prerequisite_inputs( 

1276 self, 

1277 quantum_data_id: DataCoordinate, 

1278 ) -> dict[str, dict[uuid.UUID, DataCoordinate]]: 

1279 """Return the data IDs of all prerequisite inputs to a quantum. 

1280 

1281 Parameters 

1282 ---------- 

1283 quantum_data_id : `~lsst.daf.butler.DataCoordinate` 

1284 Data ID of the quantum to get the inputs of. 

1285 

1286 Returns 

1287 ------- 

1288 inputs : `dict` [ `str`, \ 

1289 `dict` [ `uuid.UUID`, `~lsst.daf.butler.DataCoordinate` ] ] 

1290 Dataset IDs and and data IDs of prerequisite inputs, keyed by the 

1291 connection name (the internal task name, not the dataset type 

1292 name). This only contains prerequisite inputs, not init-inputs or 

1293 regular inputs. 

1294 

1295 Notes 

1296 ----- 

1297 If two connections have the same dataset type, the current 

1298 implementation assumes the set of datasets is the same for the two 

1299 connections. This limitation may be removed in the future. 

1300 

1301 Unlike regular inputs, prerequisite inputs are not looked up from input 

1302 collections or indexed by data ID. Instead, they are uniquely 

1303 identified by dataset UUID and reused directly between quanta. 

1304 """ 

1305 from .quantum_graph_skeleton import PrerequisiteDatasetKey, QuantumKey 

1306 

1307 by_dataset_type_name: defaultdict[str, dict[uuid.UUID, DataCoordinate]] = defaultdict(dict) 

1308 quantum_key = QuantumKey(self._task_node.label, quantum_data_id.required_values) 

1309 

1310 for dataset_key in self._skeleton.iter_inputs_of(quantum_key): 

1311 if not isinstance(dataset_key, PrerequisiteDatasetKey): 

1312 continue 

1313 by_dataset_type_name[dataset_key.parent_dataset_type_name][ 

1314 uuid.UUID(bytes=dataset_key.dataset_id_bytes) 

1315 ] = self._skeleton.get_data_id(dataset_key) 

1316 return { 

1317 edge.connection_name: by_dataset_type_name[edge.parent_dataset_type_name] 

1318 for edge in self._task_node.iter_all_inputs() 

1319 } 

1320 

1321 def get_outputs(self, quantum_data_id: DataCoordinate) -> dict[str, list[DataCoordinate]]: 

1322 """Return the data IDs of all regular outputs to a quantum. 

1323 

1324 Parameters 

1325 ---------- 

1326 quantum_data_id : `~lsst.daf.butler.DataCoordinate` 

1327 Data ID of the quantum to get the outputs of. 

1328 

1329 Returns 

1330 ------- 

1331 inputs : `dict` [ `str`, `list` [ `~lsst.daf.butler.DataCoordinate` ] ] 

1332 Data IDs of inputs, keyed by the connection name (the internal task 

1333 name, not the dataset type name). This only contains regular 

1334 outputs, not init-outputs or log or metadata outputs. 

1335 

1336 Notes 

1337 ----- 

1338 If two connections have the same dataset type, the current 

1339 implementation assumes the set of datasets is the same for the two 

1340 connections. This limitation may be removed in the future. 

1341 """ 

1342 from .quantum_graph_skeleton import QuantumKey 

1343 

1344 by_dataset_type_name: defaultdict[str, list[DataCoordinate]] = defaultdict(list) 

1345 quantum_key = QuantumKey(self._task_node.label, quantum_data_id.required_values) 

1346 for dataset_key in self._skeleton.iter_outputs_of(quantum_key): 

1347 dataset_type_node = self._pipeline_graph.dataset_types[dataset_key.parent_dataset_type_name] 

1348 by_dataset_type_name[dataset_key.parent_dataset_type_name].append( 

1349 DataCoordinate.from_required_values(dataset_type_node.dimensions, dataset_key.data_id_values) 

1350 ) 

1351 return { 

1352 edge.connection_name: by_dataset_type_name[edge.parent_dataset_type_name] 

1353 for edge in self._task_node.outputs.values() 

1354 } 

1355 

1356 def add_input( 

1357 self, quantum_data_id: DataCoordinate, connection_name: str, dataset_data_id: DataCoordinate 

1358 ) -> None: 

1359 """Add a new input to a quantum. 

1360 

1361 Parameters 

1362 ---------- 

1363 quantum_data_id : `~lsst.daf.butler.DataCoordinate` 

1364 Data ID of the quantum to add an input to. 

1365 connection_name : `str` 

1366 Name of the connection (the task-internal name, not the butler 

1367 dataset type name). 

1368 dataset_data_id : `~lsst.daf.butler.DataCoordinate` 

1369 Data ID of the input dataset. Must already exist in the graph 

1370 as an input to a different quantum of this task, and must be a 

1371 regular input, not a prerequisite input or init-input. 

1372 

1373 Notes 

1374 ----- 

1375 If two connections have the same dataset type, the current 

1376 implementation assumes the set of datasets is the same for the two 

1377 connections. This limitation may be removed in the future. 

1378 """ 

1379 from .quantum_graph_skeleton import DatasetKey, QuantumKey 

1380 

1381 quantum_key = QuantumKey(self._task_node.label, quantum_data_id.required_values) 

1382 read_edge = self._task_node.inputs[connection_name] 

1383 dataset_key = DatasetKey(read_edge.parent_dataset_type_name, dataset_data_id.required_values) 

1384 if dataset_key not in self._skeleton: 

1385 raise LookupError( 

1386 f"Dataset {read_edge.parent_dataset_type_name}@{dataset_data_id} is not already in the graph." 

1387 ) 

1388 self._skeleton.add_input_edge(quantum_key, dataset_key) 

1389 

1390 def add_prerequisite_input( 

1391 self, quantum_data_id: DataCoordinate, connection_name: str, dataset_uuid: uuid.UUID 

1392 ) -> None: 

1393 """Add a new prerequisite input to a quantum. 

1394 

1395 Parameters 

1396 ---------- 

1397 quantum_data_id : `~lsst.daf.butler.DataCoordinate` 

1398 Data ID of the quantum to add an input to. 

1399 connection_name : `str` 

1400 Name of the connection (the task-internal name, not the butler 

1401 dataset type name). 

1402 dataset_uuid : `uuid.UUID` 

1403 UUID of the prerequisite input dataset. Must already exist in the 

1404 graph as an input to a different quantum of this task, and must be 

1405 a prerequisite input, not a regular input or init-input. 

1406 

1407 Notes 

1408 ----- 

1409 If two connections have the same dataset type, the current 

1410 implementation assumes the set of datasets is the same for the two 

1411 connections. This limitation may be removed in the future. 

1412 

1413 Unlike regular inputs, prerequisite inputs are not looked up from input 

1414 collections or indexed by data ID. Instead, they are uniquely 

1415 identified by dataset UUID and reused directly between quanta. 

1416 """ 

1417 from .quantum_graph_skeleton import PrerequisiteDatasetKey, QuantumKey 

1418 

1419 quantum_key = QuantumKey(self._task_node.label, quantum_data_id.required_values) 

1420 read_edge = self._task_node.prerequisite_inputs[connection_name] 

1421 dataset_key = PrerequisiteDatasetKey(read_edge.parent_dataset_type_name, dataset_uuid.bytes) 

1422 if dataset_key not in self._skeleton: 

1423 raise LookupError( 

1424 f"Prerequisite Dataset {read_edge.parent_dataset_type_name}@{dataset_uuid} " 

1425 "is not already in the graph." 

1426 ) 

1427 self._skeleton.add_input_edge(quantum_key, dataset_key) 

1428 

1429 def move_output( 

1430 self, quantum_data_id: DataCoordinate, connection_name: str, dataset_data_id: DataCoordinate 

1431 ) -> None: 

1432 """Remove an output of one quantum and make it a new output of another. 

1433 

1434 Parameters 

1435 ---------- 

1436 quantum_data_id : `~lsst.daf.butler.DataCoordinate` 

1437 Data ID of the quantum to move the output to. 

1438 connection_name : `str` 

1439 Name of the connection (the task-internal name, not the butler 

1440 dataset type name). 

1441 dataset_data_id : `~lsst.daf.butler.DataCoordinate` 

1442 Data ID of the output dataset. Must already exist in the graph 

1443 as an output of a different quantum of this task. 

1444 """ 

1445 from .quantum_graph_skeleton import DatasetKey, QuantumKey 

1446 

1447 quantum_key = QuantumKey(self._task_node.label, quantum_data_id.required_values) 

1448 write_edge = self._task_node.outputs[connection_name] 

1449 dataset_key = DatasetKey(write_edge.parent_dataset_type_name, dataset_data_id.required_values) 

1450 if dataset_key not in self._skeleton: 

1451 raise LookupError( 

1452 f"Dataset {write_edge.parent_dataset_type_name}@{dataset_data_id} is " 

1453 "not already in the graph." 

1454 ) 

1455 self._skeleton.remove_output_edge(dataset_key) 

1456 self._skeleton.add_output_edge(quantum_key, dataset_key) 

1457 

1458 def expand_quantum_data_id(self, data_id: DataCoordinate) -> DataCoordinate: 

1459 """Expand a quantum data ID to include implied values and dimension 

1460 records. 

1461 

1462 Parameters 

1463 ---------- 

1464 data_id : `~lsst.daf.butler.DataCoordinate` 

1465 A data ID of a quantum already in the graph. 

1466 

1467 Returns 

1468 ------- 

1469 expanded_data_id : `~lsst.daf.butler.DataCoordinate` 

1470 The same data ID, with implied values included and dimension 

1471 records attached. 

1472 """ 

1473 from .quantum_graph_skeleton import QuantumKey 

1474 

1475 return self._skeleton.get_data_id(QuantumKey(self._task_node.label, data_id.required_values)) 

1476 

1477 @property 

1478 def n_removed(self) -> int: 

1479 """The number of quanta that have been removed by this helper.""" 

1480 return self._n_removed