Coverage for python / lsst / pipe / base / quantum_graph / _provenance.py: 40%

305 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-12 01:14 -0800

1# This file is part of pipe_base. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

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

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

7# for details of code ownership. 

8# 

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

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

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

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

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

14# 

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

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

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

18# (at your option) any later version. 

19# 

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

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

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

23# GNU General Public License for more details. 

24# 

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

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

27 

28from __future__ import annotations 

29 

30__all__ = ( 

31 "ProvenanceDatasetInfo", 

32 "ProvenanceDatasetModel", 

33 "ProvenanceInitQuantumInfo", 

34 "ProvenanceInitQuantumModel", 

35 "ProvenanceLogRecordsModel", 

36 "ProvenanceQuantumGraph", 

37 "ProvenanceQuantumGraphReader", 

38 "ProvenanceQuantumInfo", 

39 "ProvenanceQuantumModel", 

40 "ProvenanceTaskMetadataModel", 

41) 

42 

43 

44import dataclasses 

45import sys 

46import uuid 

47from collections import Counter 

48from collections.abc import Iterable, Iterator, Mapping 

49from contextlib import contextmanager 

50from typing import TYPE_CHECKING, Any, TypedDict, TypeVar 

51 

52import astropy.table 

53import networkx 

54import numpy as np 

55import pydantic 

56 

57from lsst.daf.butler import DataCoordinate 

58from lsst.daf.butler.logging import ButlerLogRecord, ButlerLogRecords 

59from lsst.resources import ResourcePathExpression 

60from lsst.utils.packages import Packages 

61 

62from .._status import ExceptionInfo, QuantumAttemptStatus, QuantumSuccessCaveats 

63from .._task_metadata import TaskMetadata 

64from ..pipeline_graph import PipelineGraph, TaskImportMode, TaskInitNode 

65from ..resource_usage import QuantumResourceUsage 

66from ._common import ( 

67 BaseQuantumGraph, 

68 BaseQuantumGraphReader, 

69 ConnectionName, 

70 DataCoordinateValues, 

71 DatasetInfo, 

72 DatasetTypeName, 

73 HeaderModel, 

74 QuantumInfo, 

75 TaskLabel, 

76) 

77from ._multiblock import MultiblockReader 

78from ._predicted import PredictedDatasetModel, PredictedQuantumDatasetsModel 

79 

80DATASET_ADDRESS_INDEX = 0 

81QUANTUM_ADDRESS_INDEX = 1 

82LOG_ADDRESS_INDEX = 2 

83METADATA_ADDRESS_INDEX = 3 

84 

85DATASET_MB_NAME = "datasets" 

86QUANTUM_MB_NAME = "quanta" 

87LOG_MB_NAME = "logs" 

88METADATA_MB_NAME = "metadata" 

89 

90_I = TypeVar("_I", bound=uuid.UUID | int) 

91 

92 

93class ProvenanceDatasetInfo(DatasetInfo): 

94 """A typed dictionary that annotates the attributes of the NetworkX graph 

95 node data for a provenance dataset. 

96 

97 Since NetworkX types are not generic over their node mapping type, this has 

98 to be used explicitly, e.g.:: 

99 

100 node_data: ProvenanceDatasetInfo = xgraph.nodes[dataset_id] 

101 

102 where ``xgraph`` is `ProvenanceQuantumGraph.bipartite_xgraph`. 

103 """ 

104 

105 dataset_id: uuid.UUID 

106 """Unique identifier for the dataset.""" 

107 

108 produced: bool 

109 """Whether this dataset was produced (vs. only predicted). 

110 

111 This is always `True` for overall input datasets. It is also `True` for 

112 datasets that were produced and then removed before/during transfer back to 

113 the central butler repository, so it may not reflect the continued 

114 existence of the dataset. 

115 """ 

116 

117 

118class ProvenanceQuantumInfo(QuantumInfo): 

119 """A typed dictionary that annotates the attributes of the NetworkX graph 

120 node data for a provenance quantum. 

121 

122 Since NetworkX types are not generic over their node mapping type, this has 

123 to be used explicitly, e.g.:: 

124 

125 node_data: ProvenanceQuantumInfo = xgraph.nodes[quantum_id] 

126 

127 where ``xgraph`` is `ProvenanceQuantumGraph.bipartite_xgraph` or 

128 `ProvenanceQuantumGraph.quantum_only_xgraph` 

129 """ 

130 

131 status: QuantumAttemptStatus 

132 """Enumerated status for the quantum. 

133 

134 This corresponds to the last attempt to run this quantum, or 

135 `QuantumAttemptStatus.BLOCKED` if there were no attempts. 

136 """ 

137 

138 caveats: QuantumSuccessCaveats | None 

139 """Flags indicating caveats on successful quanta. 

140 

141 This corresponds to the last attempt to run this quantum. 

142 """ 

143 

144 exception: ExceptionInfo | None 

145 """Information about an exception raised when the quantum was executing. 

146 

147 This corresponds to the last attempt to run this quantum. 

148 """ 

149 

150 resource_usage: QuantumResourceUsage | None 

151 """Resource usage information (timing, memory use) for this quantum. 

152 

153 This corresponds to the last attempt to run this quantum. 

154 """ 

155 

156 attempts: list[ProvenanceQuantumAttemptModel] 

157 """Information about each attempt to run this quantum. 

158 

159 An entry is added merely if the quantum *should* have been attempted; an 

160 empty `list` is used only for quanta that were blocked by an upstream 

161 failure. 

162 """ 

163 

164 

165class ProvenanceInitQuantumInfo(TypedDict): 

166 """A typed dictionary that annotates the attributes of the NetworkX graph 

167 node data for a provenance init quantum. 

168 

169 Since NetworkX types are not generic over their node mapping type, this has 

170 to be used explicitly, e.g.:: 

171 

172 node_data: ProvenanceInitQuantumInfo = xgraph.nodes[quantum_id] 

173 

174 where ``xgraph`` is `ProvenanceQuantumGraph.bipartite_xgraph`. 

175 """ 

176 

177 data_id: DataCoordinate 

178 """Data ID of the quantum. 

179 

180 This is always an empty ID; this key exists to allow init-quanta and 

181 regular quanta to be treated more similarly. 

182 """ 

183 

184 task_label: str 

185 """Label of the task for this quantum.""" 

186 

187 pipeline_node: TaskInitNode 

188 """Node in the pipeline graph for this task's init-only step.""" 

189 

190 

191class ProvenanceDatasetModel(PredictedDatasetModel): 

192 """Data model for the datasets in a provenance quantum graph file.""" 

193 

194 produced: bool 

195 """Whether this dataset was produced (vs. only predicted). 

196 

197 This is always `True` for overall input datasets. It is also `True` for 

198 datasets that were produced and then removed before/during transfer back to 

199 the central butler repository, so it may not reflect the continued 

200 existence of the dataset. 

201 """ 

202 

203 producer: uuid.UUID | None = None 

204 """ID of the quantum that produced this dataset. 

205 

206 This is `None` for overall inputs to the graph. 

207 """ 

208 

209 consumers: list[uuid.UUID] = pydantic.Field(default_factory=list) 

210 """IDs of quanta that were predicted to consume this dataset.""" 

211 

212 @property 

213 def node_id(self) -> uuid.UUID: 

214 """Alias for the dataset ID.""" 

215 return self.dataset_id 

216 

217 @classmethod 

218 def from_predicted( 

219 cls, 

220 predicted: PredictedDatasetModel, 

221 producer: uuid.UUID | None = None, 

222 consumers: Iterable[uuid.UUID] = (), 

223 ) -> ProvenanceDatasetModel: 

224 """Construct from a predicted dataset model. 

225 

226 Parameters 

227 ---------- 

228 predicted : `PredictedDatasetModel` 

229 Information about the dataset from the predicted graph. 

230 producer : `uuid.UUID` or `None`, optional 

231 ID of the quantum that was predicted to produce this dataset. 

232 consumers : `~collections.abc.Iterable` [`uuid.UUID`], optional 

233 IDs of the quanta that were predicted to consume this dataset. 

234 

235 Returns 

236 ------- 

237 provenance : `ProvenanceDatasetModel` 

238 Provenance dataset model. 

239 

240 Notes 

241 ----- 

242 This initializes `produced` to `True` when ``producer is None`` and 

243 `False` otherwise, on the assumption that it will be updated later. 

244 """ 

245 return cls.model_construct( 

246 dataset_id=predicted.dataset_id, 

247 dataset_type_name=predicted.dataset_type_name, 

248 data_coordinate=predicted.data_coordinate, 

249 run=predicted.run, 

250 produced=(producer is None), # if it's not produced by this QG, it's an overall input 

251 producer=producer, 

252 consumers=list(consumers), 

253 ) 

254 

255 def _add_to_graph(self, graph: ProvenanceQuantumGraph) -> None: 

256 """Add this dataset and its edges to quanta to a provenance graph. 

257 

258 Parameters 

259 ---------- 

260 graph : `ProvenanceQuantumGraph` 

261 Graph to update in place. 

262 

263 Notes 

264 ----- 

265 This method adds: 

266 

267 - a ``bipartite_xgraph`` dataset node with full attributes; 

268 - ``bipartite_xgraph`` edges to adjacent quanta (which adds quantum 

269 nodes with no attributes), without populating edge attributes; 

270 - ``quantum_only_xgraph`` edges for each pair of quanta in which one 

271 produces this dataset and another consumes it (this also adds quantum 

272 nodes with no attributes). 

273 """ 

274 dataset_type_node = graph.pipeline_graph.dataset_types[self.dataset_type_name] 

275 data_id = DataCoordinate.from_full_values(dataset_type_node.dimensions, tuple(self.data_coordinate)) 

276 graph._bipartite_xgraph.add_node( 

277 self.dataset_id, 

278 data_id=data_id, 

279 dataset_type_name=self.dataset_type_name, 

280 pipeline_node=dataset_type_node, 

281 run=self.run, 

282 produced=self.produced, 

283 ) 

284 if self.producer is not None: 

285 graph._bipartite_xgraph.add_edge(self.producer, self.dataset_id) 

286 for consumer_id in self.consumers: 

287 graph._bipartite_xgraph.add_edge(self.dataset_id, consumer_id) 

288 if self.producer is not None: 

289 graph._quantum_only_xgraph.add_edge(self.producer, consumer_id) 

290 graph._datasets_by_type[self.dataset_type_name][data_id] = self.dataset_id 

291 

292 # Work around the fact that Sphinx chokes on Pydantic docstring formatting, 

293 # when we inherit those docstrings in our public classes. 

294 if "sphinx" in sys.modules and not TYPE_CHECKING: 

295 

296 def copy(self, *args: Any, **kwargs: Any) -> Any: 

297 """See `pydantic.BaseModel.copy`.""" 

298 return super().copy(*args, **kwargs) 

299 

300 def model_dump(self, *args: Any, **kwargs: Any) -> Any: 

301 """See `pydantic.BaseModel.model_dump`.""" 

302 return super().model_dump(*args, **kwargs) 

303 

304 def model_dump_json(self, *args: Any, **kwargs: Any) -> Any: 

305 """See `pydantic.BaseModel.model_dump_json`.""" 

306 return super().model_dump(*args, **kwargs) 

307 

308 def model_copy(self, *args: Any, **kwargs: Any) -> Any: 

309 """See `pydantic.BaseModel.model_copy`.""" 

310 return super().model_copy(*args, **kwargs) 

311 

312 @classmethod 

313 def model_construct(cls, *args: Any, **kwargs: Any) -> Any: # type: ignore[misc, override] 

314 """See `pydantic.BaseModel.model_construct`.""" 

315 return super().model_construct(*args, **kwargs) 

316 

317 @classmethod 

318 def model_json_schema(cls, *args: Any, **kwargs: Any) -> Any: 

319 """See `pydantic.BaseModel.model_json_schema`.""" 

320 return super().model_json_schema(*args, **kwargs) 

321 

322 @classmethod 

323 def model_validate(cls, *args: Any, **kwargs: Any) -> Any: 

324 """See `pydantic.BaseModel.model_validate`.""" 

325 return super().model_validate(*args, **kwargs) 

326 

327 @classmethod 

328 def model_validate_json(cls, *args: Any, **kwargs: Any) -> Any: 

329 """See `pydantic.BaseModel.model_validate_json`.""" 

330 return super().model_validate_json(*args, **kwargs) 

331 

332 @classmethod 

333 def model_validate_strings(cls, *args: Any, **kwargs: Any) -> Any: 

334 """See `pydantic.BaseModel.model_validate_strings`.""" 

335 return super().model_validate_strings(*args, **kwargs) 

336 

337 

338class ProvenanceQuantumAttemptModel(pydantic.BaseModel): 

339 """Data model for a now-superseded attempt to run a quantum in a 

340 provenance quantum graph file. 

341 """ 

342 

343 attempt: int = 0 

344 """Counter incremented for every attempt to execute this quantum.""" 

345 

346 status: QuantumAttemptStatus = QuantumAttemptStatus.UNKNOWN 

347 """Enumerated status for the quantum.""" 

348 

349 caveats: QuantumSuccessCaveats | None = None 

350 """Flags indicating caveats on successful quanta.""" 

351 

352 exception: ExceptionInfo | None = None 

353 """Information about an exception raised when the quantum was executing.""" 

354 

355 resource_usage: QuantumResourceUsage | None = None 

356 """Resource usage information (timing, memory use) for this quantum.""" 

357 

358 previous_process_quanta: list[uuid.UUID] = pydantic.Field(default_factory=list) 

359 """The IDs of other quanta previously executed in the same process as this 

360 one. 

361 """ 

362 

363 # Work around the fact that Sphinx chokes on Pydantic docstring formatting, 

364 # when we inherit those docstrings in our public classes. 

365 if "sphinx" in sys.modules and not TYPE_CHECKING: 

366 

367 def copy(self, *args: Any, **kwargs: Any) -> Any: 

368 """See `pydantic.BaseModel.copy`.""" 

369 return super().copy(*args, **kwargs) 

370 

371 def model_dump(self, *args: Any, **kwargs: Any) -> Any: 

372 """See `pydantic.BaseModel.model_dump`.""" 

373 return super().model_dump(*args, **kwargs) 

374 

375 def model_dump_json(self, *args: Any, **kwargs: Any) -> Any: 

376 """See `pydantic.BaseModel.model_dump_json`.""" 

377 return super().model_dump(*args, **kwargs) 

378 

379 def model_copy(self, *args: Any, **kwargs: Any) -> Any: 

380 """See `pydantic.BaseModel.model_copy`.""" 

381 return super().model_copy(*args, **kwargs) 

382 

383 @classmethod 

384 def model_construct(cls, *args: Any, **kwargs: Any) -> Any: # type: ignore[misc, override] 

385 """See `pydantic.BaseModel.model_construct`.""" 

386 return super().model_construct(*args, **kwargs) 

387 

388 @classmethod 

389 def model_json_schema(cls, *args: Any, **kwargs: Any) -> Any: 

390 """See `pydantic.BaseModel.model_json_schema`.""" 

391 return super().model_json_schema(*args, **kwargs) 

392 

393 @classmethod 

394 def model_validate(cls, *args: Any, **kwargs: Any) -> Any: 

395 """See `pydantic.BaseModel.model_validate`.""" 

396 return super().model_validate(*args, **kwargs) 

397 

398 @classmethod 

399 def model_validate_json(cls, *args: Any, **kwargs: Any) -> Any: 

400 """See `pydantic.BaseModel.model_validate_json`.""" 

401 return super().model_validate_json(*args, **kwargs) 

402 

403 @classmethod 

404 def model_validate_strings(cls, *args: Any, **kwargs: Any) -> Any: 

405 """See `pydantic.BaseModel.model_validate_strings`.""" 

406 return super().model_validate_strings(*args, **kwargs) 

407 

408 

409class ProvenanceLogRecordsModel(pydantic.BaseModel): 

410 """Data model for storing execution logs in a provenance quantum graph 

411 file. 

412 """ 

413 

414 attempts: list[list[ButlerLogRecord] | None] = pydantic.Field(default_factory=list) 

415 """Logs from attempts to run this task, ordered chronologically from first 

416 to last. 

417 """ 

418 

419 # Work around the fact that Sphinx chokes on Pydantic docstring formatting, 

420 # when we inherit those docstrings in our public classes. 

421 if "sphinx" in sys.modules and not TYPE_CHECKING: 

422 

423 def copy(self, *args: Any, **kwargs: Any) -> Any: 

424 """See `pydantic.BaseModel.copy`.""" 

425 return super().copy(*args, **kwargs) 

426 

427 def model_dump(self, *args: Any, **kwargs: Any) -> Any: 

428 """See `pydantic.BaseModel.model_dump`.""" 

429 return super().model_dump(*args, **kwargs) 

430 

431 def model_dump_json(self, *args: Any, **kwargs: Any) -> Any: 

432 """See `pydantic.BaseModel.model_dump_json`.""" 

433 return super().model_dump(*args, **kwargs) 

434 

435 def model_copy(self, *args: Any, **kwargs: Any) -> Any: 

436 """See `pydantic.BaseModel.model_copy`.""" 

437 return super().model_copy(*args, **kwargs) 

438 

439 @classmethod 

440 def model_construct(cls, *args: Any, **kwargs: Any) -> Any: # type: ignore[misc, override] 

441 """See `pydantic.BaseModel.model_construct`.""" 

442 return super().model_construct(*args, **kwargs) 

443 

444 @classmethod 

445 def model_json_schema(cls, *args: Any, **kwargs: Any) -> Any: 

446 """See `pydantic.BaseModel.model_json_schema`.""" 

447 return super().model_json_schema(*args, **kwargs) 

448 

449 @classmethod 

450 def model_validate(cls, *args: Any, **kwargs: Any) -> Any: 

451 """See `pydantic.BaseModel.model_validate`.""" 

452 return super().model_validate(*args, **kwargs) 

453 

454 @classmethod 

455 def model_validate_json(cls, *args: Any, **kwargs: Any) -> Any: 

456 """See `pydantic.BaseModel.model_validate_json`.""" 

457 return super().model_validate_json(*args, **kwargs) 

458 

459 @classmethod 

460 def model_validate_strings(cls, *args: Any, **kwargs: Any) -> Any: 

461 """See `pydantic.BaseModel.model_validate_strings`.""" 

462 return super().model_validate_strings(*args, **kwargs) 

463 

464 

465class ProvenanceTaskMetadataModel(pydantic.BaseModel): 

466 """Data model for storing task metadata in a provenance quantum graph 

467 file. 

468 """ 

469 

470 attempts: list[TaskMetadata | None] = pydantic.Field(default_factory=list) 

471 """Metadata from attempts to run this task, ordered chronologically from 

472 first to last. 

473 """ 

474 

475 # Work around the fact that Sphinx chokes on Pydantic docstring formatting, 

476 # when we inherit those docstrings in our public classes. 

477 if "sphinx" in sys.modules and not TYPE_CHECKING: 

478 

479 def copy(self, *args: Any, **kwargs: Any) -> Any: 

480 """See `pydantic.BaseModel.copy`.""" 

481 return super().copy(*args, **kwargs) 

482 

483 def model_dump(self, *args: Any, **kwargs: Any) -> Any: 

484 """See `pydantic.BaseModel.model_dump`.""" 

485 return super().model_dump(*args, **kwargs) 

486 

487 def model_dump_json(self, *args: Any, **kwargs: Any) -> Any: 

488 """See `pydantic.BaseModel.model_dump_json`.""" 

489 return super().model_dump(*args, **kwargs) 

490 

491 def model_copy(self, *args: Any, **kwargs: Any) -> Any: 

492 """See `pydantic.BaseModel.model_copy`.""" 

493 return super().model_copy(*args, **kwargs) 

494 

495 @classmethod 

496 def model_construct(cls, *args: Any, **kwargs: Any) -> Any: # type: ignore[misc, override] 

497 """See `pydantic.BaseModel.model_construct`.""" 

498 return super().model_construct(*args, **kwargs) 

499 

500 @classmethod 

501 def model_json_schema(cls, *args: Any, **kwargs: Any) -> Any: 

502 """See `pydantic.BaseModel.model_json_schema`.""" 

503 return super().model_json_schema(*args, **kwargs) 

504 

505 @classmethod 

506 def model_validate(cls, *args: Any, **kwargs: Any) -> Any: 

507 """See `pydantic.BaseModel.model_validate`.""" 

508 return super().model_validate(*args, **kwargs) 

509 

510 @classmethod 

511 def model_validate_json(cls, *args: Any, **kwargs: Any) -> Any: 

512 """See `pydantic.BaseModel.model_validate_json`.""" 

513 return super().model_validate_json(*args, **kwargs) 

514 

515 @classmethod 

516 def model_validate_strings(cls, *args: Any, **kwargs: Any) -> Any: 

517 """See `pydantic.BaseModel.model_validate_strings`.""" 

518 return super().model_validate_strings(*args, **kwargs) 

519 

520 

521class ProvenanceQuantumModel(pydantic.BaseModel): 

522 """Data model for the quanta in a provenance quantum graph file.""" 

523 

524 quantum_id: uuid.UUID 

525 """Unique identifier for the quantum.""" 

526 

527 task_label: TaskLabel 

528 """Name of the type of this dataset.""" 

529 

530 data_coordinate: DataCoordinateValues = pydantic.Field(default_factory=list) 

531 """The full values (required and implied) of this dataset's data ID.""" 

532 

533 inputs: dict[ConnectionName, list[uuid.UUID]] = pydantic.Field(default_factory=dict) 

534 """IDs of the datasets predicted to be consumed by this quantum, grouped by 

535 connection name. 

536 """ 

537 

538 outputs: dict[ConnectionName, list[uuid.UUID]] = pydantic.Field(default_factory=dict) 

539 """IDs of the datasets predicted to be produced by this quantum, grouped by 

540 connection name. 

541 """ 

542 

543 attempts: list[ProvenanceQuantumAttemptModel] = pydantic.Field(default_factory=list) 

544 """Provenance for all attempts to execute this quantum, ordered 

545 chronologically from first to last. 

546 

547 An entry is added merely if the quantum *should* have been attempted; an 

548 empty `list` is used only for quanta that were blocked by an upstream 

549 failure. 

550 """ 

551 

552 @property 

553 def node_id(self) -> uuid.UUID: 

554 """Alias for the quantum ID.""" 

555 return self.quantum_id 

556 

557 @classmethod 

558 def from_predicted(cls, predicted: PredictedQuantumDatasetsModel) -> ProvenanceQuantumModel: 

559 """Construct from a predicted quantum model. 

560 

561 Parameters 

562 ---------- 

563 predicted : `PredictedQuantumDatasetsModel` 

564 Information about the quantum from the predicted graph. 

565 

566 Returns 

567 ------- 

568 provenance : `ProvenanceQuantumModel` 

569 Provenance quantum model. 

570 """ 

571 inputs = { 

572 connection_name: [d.dataset_id for d in predicted_inputs] 

573 for connection_name, predicted_inputs in predicted.inputs.items() 

574 } 

575 outputs = { 

576 connection_name: [d.dataset_id for d in predicted_outputs] 

577 for connection_name, predicted_outputs in predicted.outputs.items() 

578 } 

579 return cls( 

580 quantum_id=predicted.quantum_id, 

581 task_label=predicted.task_label, 

582 data_coordinate=predicted.data_coordinate, 

583 inputs=inputs, 

584 outputs=outputs, 

585 ) 

586 

587 def _add_to_graph(self, graph: ProvenanceQuantumGraph) -> None: 

588 """Add this quantum and its edges to datasets to a provenance graph. 

589 

590 Parameters 

591 ---------- 

592 graph : `ProvenanceQuantumGraph` 

593 Graph to update in place. 

594 

595 Notes 

596 ----- 

597 This method adds: 

598 

599 - a ``bipartite_xgraph`` quantum node with full attributes; 

600 - a ``quantum_only_xgraph`` quantum node with full attributes; 

601 - ``bipartite_xgraph`` edges to adjacent datasets (which adds datasets 

602 nodes with no attributes), while populating those edge attributes; 

603 - ``quantum_only_xgraph`` edges to any adjacent quantum that has also 

604 already been loaded. 

605 """ 

606 task_node = graph.pipeline_graph.tasks[self.task_label] 

607 data_id = DataCoordinate.from_full_values(task_node.dimensions, tuple(self.data_coordinate)) 

608 last_attempt = ( 

609 self.attempts[-1] 

610 if self.attempts 

611 else ProvenanceQuantumAttemptModel(status=QuantumAttemptStatus.BLOCKED) 

612 ) 

613 graph._bipartite_xgraph.add_node( 

614 self.quantum_id, 

615 data_id=data_id, 

616 task_label=self.task_label, 

617 pipeline_node=task_node, 

618 status=last_attempt.status, 

619 caveats=last_attempt.caveats, 

620 exception=last_attempt.exception, 

621 resource_usage=last_attempt.resource_usage, 

622 attempts=self.attempts, 

623 ) 

624 for connection_name, dataset_ids in self.inputs.items(): 

625 read_edge = task_node.get_input_edge(connection_name) 

626 for dataset_id in dataset_ids: 

627 graph._bipartite_xgraph.add_edge(dataset_id, self.quantum_id, is_read=True) 

628 graph._bipartite_xgraph.edges[dataset_id, self.quantum_id].setdefault( 

629 "pipeline_edges", [] 

630 ).append(read_edge) 

631 for connection_name, dataset_ids in self.outputs.items(): 

632 write_edge = task_node.get_output_edge(connection_name) 

633 for dataset_id in dataset_ids: 

634 graph._bipartite_xgraph.add_edge( 

635 self.quantum_id, 

636 dataset_id, 

637 is_read=False, 

638 # There can only be one pipeline edge for an output. 

639 pipeline_edges=[write_edge], 

640 ) 

641 graph._quanta_by_task_label[self.task_label][data_id] = self.quantum_id 

642 graph._quantum_only_xgraph.add_node(self.quantum_id, **graph._bipartite_xgraph.nodes[self.quantum_id]) 

643 for dataset_id in graph._bipartite_xgraph.predecessors(self.quantum_id): 

644 for upstream_quantum_id in graph._bipartite_xgraph.predecessors(dataset_id): 

645 graph._quantum_only_xgraph.add_edge(upstream_quantum_id, self.quantum_id) 

646 for dataset_id in graph._bipartite_xgraph.successors(self.quantum_id): 

647 for downstream_quantum_id in graph._bipartite_xgraph.successors(dataset_id): 

648 graph._quantum_only_xgraph.add_edge(self.quantum_id, downstream_quantum_id) 

649 

650 # Work around the fact that Sphinx chokes on Pydantic docstring formatting, 

651 # when we inherit those docstrings in our public classes. 

652 if "sphinx" in sys.modules and not TYPE_CHECKING: 

653 

654 def copy(self, *args: Any, **kwargs: Any) -> Any: 

655 """See `pydantic.BaseModel.copy`.""" 

656 return super().copy(*args, **kwargs) 

657 

658 def model_dump(self, *args: Any, **kwargs: Any) -> Any: 

659 """See `pydantic.BaseModel.model_dump`.""" 

660 return super().model_dump(*args, **kwargs) 

661 

662 def model_dump_json(self, *args: Any, **kwargs: Any) -> Any: 

663 """See `pydantic.BaseModel.model_dump_json`.""" 

664 return super().model_dump(*args, **kwargs) 

665 

666 def model_copy(self, *args: Any, **kwargs: Any) -> Any: 

667 """See `pydantic.BaseModel.model_copy`.""" 

668 return super().model_copy(*args, **kwargs) 

669 

670 @classmethod 

671 def model_construct(cls, *args: Any, **kwargs: Any) -> Any: # type: ignore[misc, override] 

672 """See `pydantic.BaseModel.model_construct`.""" 

673 return super().model_construct(*args, **kwargs) 

674 

675 @classmethod 

676 def model_json_schema(cls, *args: Any, **kwargs: Any) -> Any: 

677 """See `pydantic.BaseModel.model_json_schema`.""" 

678 return super().model_json_schema(*args, **kwargs) 

679 

680 @classmethod 

681 def model_validate(cls, *args: Any, **kwargs: Any) -> Any: 

682 """See `pydantic.BaseModel.model_validate`.""" 

683 return super().model_validate(*args, **kwargs) 

684 

685 @classmethod 

686 def model_validate_json(cls, *args: Any, **kwargs: Any) -> Any: 

687 """See `pydantic.BaseModel.model_validate_json`.""" 

688 return super().model_validate_json(*args, **kwargs) 

689 

690 @classmethod 

691 def model_validate_strings(cls, *args: Any, **kwargs: Any) -> Any: 

692 """See `pydantic.BaseModel.model_validate_strings`.""" 

693 return super().model_validate_strings(*args, **kwargs) 

694 

695 

696class ProvenanceInitQuantumModel(pydantic.BaseModel): 

697 """Data model for the special "init" quanta in a provenance quantum graph 

698 file. 

699 """ 

700 

701 quantum_id: uuid.UUID 

702 """Unique identifier for the quantum.""" 

703 

704 task_label: TaskLabel 

705 """Name of the type of this dataset. 

706 

707 This is always a parent dataset type name, not a component. 

708 

709 Note that full dataset type definitions are stored in the pipeline graph. 

710 """ 

711 

712 inputs: dict[ConnectionName, uuid.UUID] = pydantic.Field(default_factory=dict) 

713 """IDs of the datasets predicted to be consumed by this quantum, grouped by 

714 connection name. 

715 """ 

716 

717 outputs: dict[ConnectionName, uuid.UUID] = pydantic.Field(default_factory=dict) 

718 """IDs of the datasets predicted to be produced by this quantum, grouped by 

719 connection name. 

720 """ 

721 

722 @classmethod 

723 def from_predicted(cls, predicted: PredictedQuantumDatasetsModel) -> ProvenanceInitQuantumModel: 

724 """Construct from a predicted quantum model. 

725 

726 Parameters 

727 ---------- 

728 predicted : `PredictedQuantumDatasetsModel` 

729 Information about the quantum from the predicted graph. 

730 

731 Returns 

732 ------- 

733 provenance : `ProvenanceInitQuantumModel` 

734 Provenance init quantum model. 

735 """ 

736 inputs = { 

737 connection_name: predicted_inputs[0].dataset_id 

738 for connection_name, predicted_inputs in predicted.inputs.items() 

739 } 

740 outputs = { 

741 connection_name: predicted_outputs[0].dataset_id 

742 for connection_name, predicted_outputs in predicted.outputs.items() 

743 } 

744 return cls( 

745 quantum_id=predicted.quantum_id, 

746 task_label=predicted.task_label, 

747 inputs=inputs, 

748 outputs=outputs, 

749 ) 

750 

751 def _add_to_graph(self, graph: ProvenanceQuantumGraph, empty_data_id: DataCoordinate) -> None: 

752 """Add this quantum and its edges to datasets to a provenance graph. 

753 

754 Parameters 

755 ---------- 

756 graph : `ProvenanceQuantumGraph` 

757 Graph to update in place. 

758 empty_data_id : `lsst.daf.butler.DataCoordinate` 

759 The empty data ID for the appropriate dimension universe. 

760 

761 Notes 

762 ----- 

763 This method adds: 

764 

765 - a ``bipartite_xgraph`` quantum node with full attributes; 

766 - ``bipartite_xgraph`` edges to adjacent datasets (which adds datasets 

767 nodes with no attributes), while populating those edge attributes; 

768 """ 

769 task_init_node = graph.pipeline_graph.tasks[self.task_label].init 

770 graph._bipartite_xgraph.add_node( 

771 self.quantum_id, data_id=empty_data_id, task_label=self.task_label, pipeline_node=task_init_node 

772 ) 

773 for connection_name, dataset_id in self.inputs.items(): 

774 read_edge = task_init_node.get_input_edge(connection_name) 

775 graph._bipartite_xgraph.add_edge(dataset_id, self.quantum_id, is_read=True) 

776 graph._bipartite_xgraph.edges[dataset_id, self.quantum_id].setdefault( 

777 "pipeline_edges", [] 

778 ).append(read_edge) 

779 for connection_name, dataset_id in self.outputs.items(): 

780 write_edge = task_init_node.get_output_edge(connection_name) 

781 graph._bipartite_xgraph.add_edge( 

782 self.quantum_id, 

783 dataset_id, 

784 is_read=False, 

785 # There can only be one pipeline edge for an output. 

786 pipeline_edges=[write_edge], 

787 ) 

788 graph._init_quanta[self.task_label] = self.quantum_id 

789 

790 # Work around the fact that Sphinx chokes on Pydantic docstring formatting, 

791 # when we inherit those docstrings in our public classes. 

792 if "sphinx" in sys.modules and not TYPE_CHECKING: 

793 

794 def copy(self, *args: Any, **kwargs: Any) -> Any: 

795 """See `pydantic.BaseModel.copy`.""" 

796 return super().copy(*args, **kwargs) 

797 

798 def model_dump(self, *args: Any, **kwargs: Any) -> Any: 

799 """See `pydantic.BaseModel.model_dump`.""" 

800 return super().model_dump(*args, **kwargs) 

801 

802 def model_dump_json(self, *args: Any, **kwargs: Any) -> Any: 

803 """See `pydantic.BaseModel.model_dump_json`.""" 

804 return super().model_dump(*args, **kwargs) 

805 

806 def model_copy(self, *args: Any, **kwargs: Any) -> Any: 

807 """See `pydantic.BaseModel.model_copy`.""" 

808 return super().model_copy(*args, **kwargs) 

809 

810 @classmethod 

811 def model_construct(cls, *args: Any, **kwargs: Any) -> Any: # type: ignore[misc, override] 

812 """See `pydantic.BaseModel.model_construct`.""" 

813 return super().model_construct(*args, **kwargs) 

814 

815 @classmethod 

816 def model_json_schema(cls, *args: Any, **kwargs: Any) -> Any: 

817 """See `pydantic.BaseModel.model_json_schema`.""" 

818 return super().model_json_schema(*args, **kwargs) 

819 

820 @classmethod 

821 def model_validate(cls, *args: Any, **kwargs: Any) -> Any: 

822 """See `pydantic.BaseModel.model_validate`.""" 

823 return super().model_validate(*args, **kwargs) 

824 

825 @classmethod 

826 def model_validate_json(cls, *args: Any, **kwargs: Any) -> Any: 

827 """See `pydantic.BaseModel.model_validate_json`.""" 

828 return super().model_validate_json(*args, **kwargs) 

829 

830 @classmethod 

831 def model_validate_strings(cls, *args: Any, **kwargs: Any) -> Any: 

832 """See `pydantic.BaseModel.model_validate_strings`.""" 

833 return super().model_validate_strings(*args, **kwargs) 

834 

835 

836class ProvenanceInitQuantaModel(pydantic.RootModel): 

837 """Data model for the init quanta in a provenance graph.""" 

838 

839 root: list[ProvenanceInitQuantumModel] = pydantic.Field(default_factory=list) 

840 """List of special "init" quanta, one for each task.""" 

841 

842 def _add_to_graph(self, graph: ProvenanceQuantumGraph) -> None: 

843 """Add this quantum and its edges to datasets to a provenance graph. 

844 

845 Parameters 

846 ---------- 

847 graph : `ProvenanceQuantumGraph` 

848 Graph to update in place. 

849 """ 

850 empty_data_id = DataCoordinate.make_empty(graph.pipeline_graph.universe) 

851 for init_quantum in self.root: 

852 init_quantum._add_to_graph(graph, empty_data_id=empty_data_id) 

853 

854 # Work around the fact that Sphinx chokes on Pydantic docstring formatting, 

855 # when we inherit those docstrings in our public classes. 

856 if "sphinx" in sys.modules and not TYPE_CHECKING: 

857 

858 def copy(self, *args: Any, **kwargs: Any) -> Any: 

859 """See `pydantic.BaseModel.copy`.""" 

860 return super().copy(*args, **kwargs) 

861 

862 def model_dump(self, *args: Any, **kwargs: Any) -> Any: 

863 """See `pydantic.BaseModel.model_dump`.""" 

864 return super().model_dump(*args, **kwargs) 

865 

866 def model_dump_json(self, *args: Any, **kwargs: Any) -> Any: 

867 """See `pydantic.BaseModel.model_dump_json`.""" 

868 return super().model_dump(*args, **kwargs) 

869 

870 def model_copy(self, *args: Any, **kwargs: Any) -> Any: 

871 """See `pydantic.BaseModel.model_copy`.""" 

872 return super().model_copy(*args, **kwargs) 

873 

874 @classmethod 

875 def model_construct(cls, *args: Any, **kwargs: Any) -> Any: # type: ignore[misc, override] 

876 """See `pydantic.BaseModel.model_construct`.""" 

877 return super().model_construct(*args, **kwargs) 

878 

879 @classmethod 

880 def model_json_schema(cls, *args: Any, **kwargs: Any) -> Any: 

881 """See `pydantic.BaseModel.model_json_schema`.""" 

882 return super().model_json_schema(*args, **kwargs) 

883 

884 @classmethod 

885 def model_validate(cls, *args: Any, **kwargs: Any) -> Any: 

886 """See `pydantic.BaseModel.model_validate`.""" 

887 return super().model_validate(*args, **kwargs) 

888 

889 @classmethod 

890 def model_validate_json(cls, *args: Any, **kwargs: Any) -> Any: 

891 """See `pydantic.BaseModel.model_validate_json`.""" 

892 return super().model_validate_json(*args, **kwargs) 

893 

894 @classmethod 

895 def model_validate_strings(cls, *args: Any, **kwargs: Any) -> Any: 

896 """See `pydantic.BaseModel.model_validate_strings`.""" 

897 return super().model_validate_strings(*args, **kwargs) 

898 

899 

900class ProvenanceQuantumGraph(BaseQuantumGraph): 

901 """A quantum graph that represents processing that has already been 

902 executed. 

903 

904 Parameters 

905 ---------- 

906 header : `HeaderModel` 

907 General metadata shared with other quantum graph types. 

908 pipeline_graph : `.pipeline_graph.PipelineGraph` 

909 Graph of tasks and dataset types. May contain a superset of the tasks 

910 and dataset types that actually have quanta and datasets in the quantum 

911 graph. 

912 

913 Notes 

914 ----- 

915 A provenance quantum graph is generally obtained via the 

916 `ProvenanceQuantumGraphReader.graph` attribute, which is updated in-place 

917 as information is read from disk. 

918 """ 

919 

920 def __init__(self, header: HeaderModel, pipeline_graph: PipelineGraph) -> None: 

921 super().__init__(header, pipeline_graph) 

922 self._init_quanta: dict[TaskLabel, uuid.UUID] = {} 

923 self._quantum_only_xgraph = networkx.DiGraph() 

924 self._bipartite_xgraph = networkx.DiGraph() 

925 self._quanta_by_task_label: dict[str, dict[DataCoordinate, uuid.UUID]] = { 

926 task_label: {} for task_label in self.pipeline_graph.tasks.keys() 

927 } 

928 self._datasets_by_type: dict[str, dict[DataCoordinate, uuid.UUID]] = { 

929 dataset_type_name: {} for dataset_type_name in self.pipeline_graph.dataset_types.keys() 

930 } 

931 

932 @property 

933 def init_quanta(self) -> Mapping[TaskLabel, uuid.UUID]: 

934 """A mapping from task label to the ID of the special init quantum for 

935 that task. 

936 

937 This is populated by the ``init_quanta`` component. Additional 

938 information about each init quantum can be found by using the ID to 

939 look up node attributes in the `bipartite_xgraph`, i.e.:: 

940 

941 info: ProvenanceInitQuantumInfo = qg.bipartite_xgraph.nodes[id] 

942 """ 

943 return self._init_quanta 

944 

945 @property 

946 def quanta_by_task(self) -> Mapping[TaskLabel, Mapping[DataCoordinate, uuid.UUID]]: 

947 """A nested mapping of all quanta, keyed first by task name and then by 

948 data ID. 

949 

950 Notes 

951 ----- 

952 This is populated one quantum at a time as they are read. All tasks in 

953 the pipeline graph are included, even if none of their quanta were 

954 loaded (i.e. nested mappings may be empty). 

955 

956 The returned object may be an internal dictionary; as the type 

957 annotation indicates, it should not be modified in place. 

958 """ 

959 return self._quanta_by_task_label 

960 

961 @property 

962 def datasets_by_type(self) -> Mapping[DatasetTypeName, Mapping[DataCoordinate, uuid.UUID]]: 

963 """A nested mapping of all datasets, keyed first by dataset type name 

964 and then by data ID. 

965 

966 Notes 

967 ----- 

968 This is populated one dataset at a time as they are read. All dataset 

969 types in the pipeline graph are included, even if none of their 

970 datasets were loaded (i.e. nested mappings may be empty). 

971 

972 The returned object may be an internal dictionary; as the type 

973 annotation indicates, it should not be modified in place. 

974 """ 

975 return self._datasets_by_type 

976 

977 @property 

978 def quantum_only_xgraph(self) -> networkx.DiGraph: 

979 """A directed acyclic graph with quanta as nodes (and datasets elided). 

980 

981 Notes 

982 ----- 

983 Node keys are quantum UUIDs, and are populated one quantum at a time as 

984 they are loaded. Loading quanta (via 

985 `ProvenanceQuantumGraphReader.read_quanta`) will add the loaded nodes 

986 with full attributes and add edges to adjacent nodes with no 

987 attributes. Loading datasets (via 

988 `ProvenanceQuantumGraphReader.read_datasets`) will also add edges and 

989 nodes with no attributes. 

990 

991 Node attributes are described by the `ProvenanceQuantumInfo` types. 

992 

993 This graph does not include special "init" quanta. 

994 

995 The returned object is a read-only view of an internal one. 

996 """ 

997 return self._quantum_only_xgraph.copy(as_view=True) 

998 

999 @property 

1000 def bipartite_xgraph(self) -> networkx.DiGraph: 

1001 """A directed acyclic graph with quantum and dataset nodes. 

1002 

1003 Notes 

1004 ----- 

1005 Node keys are quantum or dataset UUIDs, and are populated one quantum 

1006 or dataset at a time as they are loaded. Loading quanta (via 

1007 `ProvenanceQuantumGraphReader.read_quanta`) or datasets (via 

1008 `ProvenanceQuantumGraphReader.read_datasets`) will load those nodes 

1009 with full attributes and edges to adjacent nodes with no attributes. 

1010 Loading quanta necessary to populate edge attributes. 

1011 

1012 Node attributes are described by the 

1013 `ProvenanceQuantumInfo`, `ProvenanceInitQuantumInfo`, and 

1014 `ProvenanceDatasetInfo` types. 

1015 

1016 This graph includes init-input and init-output datasets, but it does 

1017 *not* reflect the dependency between each task's special "init" quantum 

1018 and its runtime quanta (as this would require edges between quanta, and 

1019 that would break the "bipartite" property). 

1020 

1021 The returned object is a read-only view of an internal one. 

1022 """ 

1023 return self._bipartite_xgraph.copy(as_view=True) 

1024 

1025 def make_quantum_table(self) -> astropy.table.Table: 

1026 """Construct an `astropy.table.Table` with a tabular summary of the 

1027 quanta. 

1028 

1029 Returns 

1030 ------- 

1031 table : `astropy.table.Table` 

1032 A table view of the quantum information. This only includes 

1033 counts of status categories and caveats, not any per-data-ID 

1034 detail. 

1035 

1036 Notes 

1037 ----- 

1038 Success caveats in the table are represented by their 

1039 `~QuantumSuccessCaveats.concise` form, so when pretty-printing this 

1040 table for users, the `~QuantumSuccessCaveats.legend` should generally 

1041 be printed as well. 

1042 """ 

1043 rows = [] 

1044 for task_label, quanta_for_task in self.quanta_by_task.items(): 

1045 if not self.header.n_task_quanta[task_label]: 

1046 continue 

1047 status_counts = Counter[QuantumAttemptStatus]( 

1048 self._quantum_only_xgraph.nodes[q]["status"] for q in quanta_for_task.values() 

1049 ) 

1050 caveat_counts = Counter[QuantumSuccessCaveats | None]( 

1051 self._quantum_only_xgraph.nodes[q]["caveats"] for q in quanta_for_task.values() 

1052 ) 

1053 caveat_counts.pop(QuantumSuccessCaveats.NO_CAVEATS, None) 

1054 caveat_counts.pop(None, None) 

1055 if len(caveat_counts) > 1: 

1056 caveats = "(multiple)" 

1057 elif len(caveat_counts) == 1: 

1058 ((code, count),) = caveat_counts.items() 

1059 # MyPy can't tell that the pop(None, None) above makes None 

1060 # impossible here. 

1061 caveats = f"{code.concise()}({count})" # type: ignore[union-attr] 

1062 else: 

1063 caveats = "" 

1064 rows.append( 

1065 { 

1066 "Task": task_label, 

1067 "Unknown": status_counts.get(QuantumAttemptStatus.UNKNOWN, 0), 

1068 "Successful": status_counts.get(QuantumAttemptStatus.SUCCESSFUL, 0), 

1069 "Caveats": caveats, 

1070 "Blocked": status_counts.get(QuantumAttemptStatus.BLOCKED, 0), 

1071 "Failed": status_counts.get(QuantumAttemptStatus.FAILED, 0), 

1072 "TOTAL": len(quanta_for_task), 

1073 "EXPECTED": self.header.n_task_quanta[task_label], 

1074 } 

1075 ) 

1076 return astropy.table.Table(rows) 

1077 

1078 def make_exception_table(self) -> astropy.table.Table: 

1079 """Construct an `astropy.table.Table` with counts for each exception 

1080 type raised by each task. 

1081 

1082 At present this only includes information from partial-outputs-error 

1083 successes, since exception information for failures is not tracked. 

1084 This may change in the future. 

1085 

1086 Returns 

1087 ------- 

1088 table : `astropy.table.Table` 

1089 A table with columns for task label, exception type, and counts. 

1090 """ 

1091 rows = [] 

1092 for task_label, quanta_for_task in self.quanta_by_task.items(): 

1093 counts_by_type = Counter( 

1094 exc_info.type_name 

1095 for q in quanta_for_task.values() 

1096 if (exc_info := self._quantum_only_xgraph.nodes[q]["exception"]) is not None 

1097 ) 

1098 for type_name, count in counts_by_type.items(): 

1099 rows.append({"Task": task_label, "Exception": type_name, "Count": count}) 

1100 return astropy.table.Table(rows) 

1101 

1102 def make_task_resource_usage_table( 

1103 self, task_label: TaskLabel, include_data_ids: bool = False 

1104 ) -> astropy.table.Table: 

1105 """Make a table of resource usage for a single task. 

1106 

1107 Parameters 

1108 ---------- 

1109 task_label : `str` 

1110 Label of the task to extract resource usage for. 

1111 include_data_ids : `bool`, optional 

1112 Whether to also include data ID columns. 

1113 

1114 Returns 

1115 ------- 

1116 table : `astropy.table.Table` 

1117 A table with columns for quantum ID and all fields in 

1118 `QuantumResourceUsage`. 

1119 """ 

1120 quanta_for_task = self.quanta_by_task[task_label] 

1121 dtype_terms: list[tuple[str, np.dtype]] = [("quantum_id", np.dtype((np.void, 16)))] 

1122 if include_data_ids: 

1123 dimensions = self.pipeline_graph.tasks[task_label].dimensions 

1124 for dimension_name in dimensions.data_coordinate_keys: 

1125 dtype = np.dtype(self.pipeline_graph.universe.dimensions[dimension_name].primary_key.pytype) 

1126 dtype_terms.append((dimension_name, dtype)) 

1127 fields = QuantumResourceUsage.get_numpy_fields() 

1128 dtype_terms.extend(fields.items()) 

1129 row_dtype = np.dtype(dtype_terms) 

1130 rows: list[object] = [] 

1131 for data_id, quantum_id in quanta_for_task.items(): 

1132 info: ProvenanceQuantumInfo = self._quantum_only_xgraph.nodes[quantum_id] 

1133 if (resource_usage := info["resource_usage"]) is not None: 

1134 row: tuple[object, ...] = (quantum_id.bytes,) 

1135 if include_data_ids: 

1136 row += data_id.full_values 

1137 row += resource_usage.get_numpy_row() 

1138 rows.append(row) 

1139 array = np.array(rows, dtype=row_dtype) 

1140 return astropy.table.Table(array, units=QuantumResourceUsage.get_units()) 

1141 

1142 

1143@dataclasses.dataclass 

1144class ProvenanceQuantumGraphReader(BaseQuantumGraphReader): 

1145 """A helper class for reading provenance quantum graphs. 

1146 

1147 Notes 

1148 ----- 

1149 The `open` context manager should be used to construct new instances. 

1150 Instances cannot be used after the context manager exits, except to access 

1151 the `graph` attribute`. 

1152 

1153 The various ``read_*`` methods in this class update the `graph` attribute 

1154 in place. 

1155 """ 

1156 

1157 graph: ProvenanceQuantumGraph = dataclasses.field(init=False) 

1158 """Loaded provenance graph, populated in place as components are read.""" 

1159 

1160 @classmethod 

1161 @contextmanager 

1162 def open( 

1163 cls, 

1164 uri: ResourcePathExpression, 

1165 *, 

1166 page_size: int | None = None, 

1167 import_mode: TaskImportMode = TaskImportMode.DO_NOT_IMPORT, 

1168 ) -> Iterator[ProvenanceQuantumGraphReader]: 

1169 """Construct a reader from a URI. 

1170 

1171 Parameters 

1172 ---------- 

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

1174 URI to open. Should have a ``.qg`` extension. 

1175 page_size : `int`, optional 

1176 Approximate number of bytes to read at once from address files and 

1177 multi-block files. Note that this does not set a page size for 

1178 *all* reads, but it does affect the smallest, most numerous reads. 

1179 Can also be set via the ``LSST_QG_PAGE_SIZE`` environment variable. 

1180 import_mode : `.pipeline_graph.TaskImportMode`, optional 

1181 How to handle importing the task classes referenced in the pipeline 

1182 graph. 

1183 

1184 Returns 

1185 ------- 

1186 reader : `contextlib.AbstractContextManager` [ \ 

1187 `ProvenanceQuantumGraphReader` ] 

1188 A context manager that returns the reader when entered. 

1189 """ 

1190 with cls._open( 

1191 uri, 

1192 graph_type="provenance", 

1193 address_filename="nodes", 

1194 page_size=page_size, 

1195 import_mode=import_mode, 

1196 n_addresses=4, 

1197 ) as self: 

1198 yield self 

1199 

1200 def __post_init__(self) -> None: 

1201 self.graph = ProvenanceQuantumGraph(self.header, self.pipeline_graph) 

1202 

1203 def read_init_quanta(self) -> None: 

1204 """Read the thin graph, with all edge information and categorization of 

1205 quanta by task label. 

1206 """ 

1207 init_quanta = self._read_single_block("init_quanta", ProvenanceInitQuantaModel) 

1208 for init_quantum in init_quanta.root: 

1209 self.graph._init_quanta[init_quantum.task_label] = init_quantum.quantum_id 

1210 init_quanta._add_to_graph(self.graph) 

1211 

1212 def read_full_graph(self) -> None: 

1213 """Read all bipartite edges and all quantum and dataset node 

1214 attributes, fully populating the `graph` attribute. 

1215 

1216 Notes 

1217 ----- 

1218 This does not read logs, metadata, or packages ; those must always be 

1219 fetched explicitly. 

1220 """ 

1221 self.read_init_quanta() 

1222 self.read_datasets() 

1223 self.read_quanta() 

1224 

1225 def read_datasets(self, datasets: Iterable[uuid.UUID] | None = None) -> None: 

1226 """Read information about the given datasets. 

1227 

1228 Parameters 

1229 ---------- 

1230 datasets : `~collections.abc.Iterable` [`uuid.UUID`], optional 

1231 Iterable of dataset IDs to load. If not provided, all datasets 

1232 will be loaded. The UUIDs and indices of quanta will be ignored. 

1233 """ 

1234 self._read_nodes(datasets, DATASET_ADDRESS_INDEX, DATASET_MB_NAME, ProvenanceDatasetModel) 

1235 

1236 def read_quanta(self, quanta: Iterable[uuid.UUID] | None = None) -> None: 

1237 """Read information about the given quanta. 

1238 

1239 Parameters 

1240 ---------- 

1241 quanta : `~collections.abc.Iterable` [`uuid.UUID`], optional 

1242 Iterable of quantum IDs to load. If not provided, all quanta will 

1243 be loaded. The UUIDs and indices of datasets and special init 

1244 quanta will be ignored. 

1245 """ 

1246 self._read_nodes(quanta, QUANTUM_ADDRESS_INDEX, QUANTUM_MB_NAME, ProvenanceQuantumModel) 

1247 

1248 def _read_nodes( 

1249 self, 

1250 nodes: Iterable[uuid.UUID] | None, 

1251 address_index: int, 

1252 mb_name: str, 

1253 model_type: type[ProvenanceDatasetModel] | type[ProvenanceQuantumModel], 

1254 ) -> None: 

1255 node: ProvenanceDatasetModel | ProvenanceQuantumModel | None 

1256 if nodes is None: 

1257 self.address_reader.read_all() 

1258 nodes = self.address_reader.rows.keys() 

1259 for node in MultiblockReader.read_all_models_in_zip( 

1260 self.zf, 

1261 mb_name, 

1262 model_type, 

1263 self.decompressor, 

1264 int_size=self.header.int_size, 

1265 page_size=self.page_size, 

1266 ): 

1267 if "pipeline_node" in self.graph._bipartite_xgraph.nodes.get(node.node_id, {}): 

1268 # Use the old node to reduce memory usage (since it might 

1269 # also have other outstanding reference holders). 

1270 continue 

1271 node._add_to_graph(self.graph) 

1272 return 

1273 with MultiblockReader.open_in_zip(self.zf, mb_name, int_size=self.header.int_size) as mb_reader: 

1274 for node_id_or_index in nodes: 

1275 address_row = self.address_reader.find(node_id_or_index) 

1276 if "pipeline_node" in self.graph._bipartite_xgraph.nodes.get(address_row.key, {}): 

1277 # Use the old node to reduce memory usage (since it might 

1278 # also have other outstanding reference holders). 

1279 continue 

1280 node = mb_reader.read_model( 

1281 address_row.addresses[address_index], model_type, self.decompressor 

1282 ) 

1283 if node is not None: 

1284 node._add_to_graph(self.graph) 

1285 

1286 def fetch_logs(self, nodes: Iterable[uuid.UUID]) -> dict[uuid.UUID, list[ButlerLogRecords | None]]: 

1287 """Fetch log datasets. 

1288 

1289 Parameters 

1290 ---------- 

1291 nodes : `~collections.abc.Iterable` [ `uuid.UUID` ] 

1292 UUIDs of the log datasets themselves or of the quanta they 

1293 correspond to. 

1294 

1295 Returns 

1296 ------- 

1297 logs : `dict` [ `uuid.UUID`, `list` [\ 

1298 `lsst.daf.butler.ButlerLogRecords` or `None`] ] 

1299 Logs for the given IDs. Each value is a list of 

1300 `lsst.daf.butler.ButlerLogRecords` instances representing different 

1301 execution attempts, ordered chronologically from first to last. 

1302 Attempts where logs were missing will have `None` in this list. 

1303 """ 

1304 result: dict[uuid.UUID, list[ButlerLogRecords | None]] = {} 

1305 with MultiblockReader.open_in_zip(self.zf, LOG_MB_NAME, int_size=self.header.int_size) as mb_reader: 

1306 for node_id_or_index in nodes: 

1307 address_row = self.address_reader.find(node_id_or_index) 

1308 logs_by_attempt = mb_reader.read_model( 

1309 address_row.addresses[LOG_ADDRESS_INDEX], ProvenanceLogRecordsModel, self.decompressor 

1310 ) 

1311 if logs_by_attempt is not None: 

1312 result[node_id_or_index] = [ 

1313 ButlerLogRecords.from_records(attempt_logs) if attempt_logs is not None else None 

1314 for attempt_logs in logs_by_attempt.attempts 

1315 ] 

1316 return result 

1317 

1318 def fetch_metadata(self, nodes: Iterable[uuid.UUID]) -> dict[uuid.UUID, list[TaskMetadata | None]]: 

1319 """Fetch metadata datasets. 

1320 

1321 Parameters 

1322 ---------- 

1323 nodes : `~collections.abc.Iterable` [ `uuid.UUID` ] 

1324 UUIDs of the metadata datasets themselves or of the quanta they 

1325 correspond to. 

1326 

1327 Returns 

1328 ------- 

1329 metadata : `dict` [ `uuid.UUID`, `list` [`.TaskMetadata`] ] 

1330 Metadata for the given IDs. Each value is a list of 

1331 `.TaskMetadata` instances representing different execution 

1332 attempts, ordered chronologically from first to last. Attempts 

1333 where metadata was missing (not written even in the fallback extra 

1334 provenance in the logs) will have `None` in this list. 

1335 """ 

1336 result: dict[uuid.UUID, list[TaskMetadata | None]] = {} 

1337 with MultiblockReader.open_in_zip( 

1338 self.zf, METADATA_MB_NAME, int_size=self.header.int_size 

1339 ) as mb_reader: 

1340 for node_id_or_index in nodes: 

1341 address_row = self.address_reader.find(node_id_or_index) 

1342 metadata_by_attempt = mb_reader.read_model( 

1343 address_row.addresses[METADATA_ADDRESS_INDEX], 

1344 ProvenanceTaskMetadataModel, 

1345 self.decompressor, 

1346 ) 

1347 if metadata_by_attempt is not None: 

1348 result[node_id_or_index] = metadata_by_attempt.attempts 

1349 return result 

1350 

1351 def fetch_packages(self) -> Packages: 

1352 """Fetch package version information.""" 

1353 data = self._read_single_block_raw("packages") 

1354 return Packages.fromBytes(data, format="json")