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

693 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-29 01:37 -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 "ProvenanceQuantumGraphWriter", 

39 "ProvenanceQuantumInfo", 

40 "ProvenanceQuantumModel", 

41 "ProvenanceQuantumReport", 

42 "ProvenanceQuantumScanData", 

43 "ProvenanceQuantumScanModels", 

44 "ProvenanceQuantumScanStatus", 

45 "ProvenanceReport", 

46 "ProvenanceTaskMetadataModel", 

47) 

48 

49import dataclasses 

50import enum 

51import itertools 

52import sys 

53import uuid 

54from collections import Counter 

55from collections.abc import Callable, Iterable, Iterator, Mapping 

56from contextlib import ExitStack, contextmanager 

57from typing import TYPE_CHECKING, Any, TypeAlias, TypedDict, TypeVar 

58 

59import astropy.table 

60import networkx 

61import numpy as np 

62import pydantic 

63 

64from lsst.daf.butler import Butler, DataCoordinate 

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

66from lsst.resources import ResourcePath, ResourcePathExpression 

67from lsst.utils.iteration import ensure_iterable 

68from lsst.utils.logging import LsstLogAdapter, getLogger 

69from lsst.utils.packages import Packages 

70 

71from .. import automatic_connection_constants as acc 

72from .._status import ExceptionInfo, QuantumAttemptStatus, QuantumSuccessCaveats 

73from .._task_metadata import TaskMetadata 

74from ..log_capture import _ExecutionLogRecordsExtra 

75from ..log_on_close import LogOnClose 

76from ..pipeline_graph import PipelineGraph, TaskImportMode, TaskInitNode 

77from ..resource_usage import QuantumResourceUsage 

78from ._common import ( 

79 BaseQuantumGraph, 

80 BaseQuantumGraphReader, 

81 BaseQuantumGraphWriter, 

82 ConnectionName, 

83 DataCoordinateValues, 

84 DatasetInfo, 

85 DatasetTypeName, 

86 HeaderModel, 

87 QuantumInfo, 

88 TaskLabel, 

89) 

90from ._multiblock import Compressor, MultiblockReader, MultiblockWriter 

91from ._predicted import ( 

92 PredictedDatasetModel, 

93 PredictedQuantumDatasetsModel, 

94 PredictedQuantumGraph, 

95 PredictedQuantumGraphComponents, 

96) 

97 

98# Sphinx needs imports for type annotations of base class members. 

99if "sphinx" in sys.modules: 

100 import zipfile # noqa: F401 

101 

102 from ._multiblock import AddressReader, Decompressor # noqa: F401 

103 

104 

105_T = TypeVar("_T") 

106 

107LoopWrapper: TypeAlias = Callable[[Iterable[_T]], Iterable[_T]] 

108 

109_LOG = getLogger(__file__) 

110 

111DATASET_ADDRESS_INDEX = 0 

112QUANTUM_ADDRESS_INDEX = 1 

113LOG_ADDRESS_INDEX = 2 

114METADATA_ADDRESS_INDEX = 3 

115 

116DATASET_MB_NAME = "datasets" 

117QUANTUM_MB_NAME = "quanta" 

118LOG_MB_NAME = "logs" 

119METADATA_MB_NAME = "metadata" 

120 

121 

122def pass_through(arg: _T) -> _T: 

123 return arg 

124 

125 

126class ProvenanceDatasetInfo(DatasetInfo): 

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

128 node data for a provenance dataset. 

129 

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

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

132 

133 node_data: ProvenanceDatasetInfo = xgraph.nodes[dataset_id] 

134 

135 where ``xgraph`` is `ProvenanceQuantumGraph.bipartite_xgraph`. 

136 """ 

137 

138 dataset_id: uuid.UUID 

139 """Unique identifier for the dataset.""" 

140 

141 produced: bool 

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

143 

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

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

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

147 existence of the dataset. 

148 """ 

149 

150 

151class ProvenanceQuantumInfo(QuantumInfo): 

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

153 node data for a provenance quantum. 

154 

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

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

157 

158 node_data: ProvenanceQuantumInfo = xgraph.nodes[quantum_id] 

159 

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

161 `ProvenanceQuantumGraph.quantum_only_xgraph` 

162 """ 

163 

164 status: QuantumAttemptStatus 

165 """Enumerated status for the quantum. 

166 

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

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

169 """ 

170 

171 caveats: QuantumSuccessCaveats | None 

172 """Flags indicating caveats on successful quanta. 

173 

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

175 """ 

176 

177 exception: ExceptionInfo | None 

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

179 

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

181 """ 

182 

183 resource_usage: QuantumResourceUsage | None 

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

185 

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

187 """ 

188 

189 attempts: list[ProvenanceQuantumAttemptModel] 

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

191 

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

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

194 failure. 

195 """ 

196 

197 metadata_id: uuid.UUID 

198 """ID of this quantum's metadata dataset.""" 

199 

200 log_id: uuid.UUID 

201 """ID of this quantum's log dataset.""" 

202 

203 

204class ProvenanceInitQuantumInfo(TypedDict): 

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

206 node data for a provenance init quantum. 

207 

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

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

210 

211 node_data: ProvenanceInitQuantumInfo = xgraph.nodes[quantum_id] 

212 

213 where ``xgraph`` is `ProvenanceQuantumGraph.bipartite_xgraph`. 

214 """ 

215 

216 data_id: DataCoordinate 

217 """Data ID of the quantum. 

218 

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

220 regular quanta to be treated more similarly. 

221 """ 

222 

223 task_label: str 

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

225 

226 pipeline_node: TaskInitNode 

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

228 

229 config_id: uuid.UUID 

230 """ID of this task's config dataset.""" 

231 

232 

233class ProvenanceDatasetModel(PredictedDatasetModel): 

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

235 

236 produced: bool 

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

238 

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

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

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

242 existence of the dataset. 

243 """ 

244 

245 producer: uuid.UUID | None = None 

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

247 

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

249 """ 

250 

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

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

253 

254 @property 

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

256 """Alias for the dataset ID.""" 

257 return self.dataset_id 

258 

259 @classmethod 

260 def from_predicted( 

261 cls, 

262 predicted: PredictedDatasetModel, 

263 producer: uuid.UUID | None = None, 

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

265 ) -> ProvenanceDatasetModel: 

266 """Construct from a predicted dataset model. 

267 

268 Parameters 

269 ---------- 

270 predicted : `PredictedDatasetModel` 

271 Information about the dataset from the predicted graph. 

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

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

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

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

276 

277 Returns 

278 ------- 

279 provenance : `ProvenanceDatasetModel` 

280 Provenance dataset model. 

281 

282 Notes 

283 ----- 

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

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

286 """ 

287 return cls.model_construct( 

288 dataset_id=predicted.dataset_id, 

289 dataset_type_name=predicted.dataset_type_name, 

290 data_coordinate=predicted.data_coordinate, 

291 run=predicted.run, 

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

293 producer=producer, 

294 consumers=list(consumers), 

295 ) 

296 

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

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

299 

300 Parameters 

301 ---------- 

302 graph : `ProvenanceQuantumGraph` 

303 Graph to update in place. 

304 

305 Notes 

306 ----- 

307 This method adds: 

308 

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

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

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

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

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

314 nodes with no attributes). 

315 """ 

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

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

318 graph._bipartite_xgraph.add_node( 

319 self.dataset_id, 

320 data_id=data_id, 

321 dataset_type_name=self.dataset_type_name, 

322 pipeline_node=dataset_type_node, 

323 run=self.run, 

324 produced=self.produced, 

325 ) 

326 if self.producer is not None: 

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

328 for consumer_id in self.consumers: 

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

330 if self.producer is not None: 

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

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

333 

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

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

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

337 

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

339 """See `pydantic.BaseModel.copy`.""" 

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

341 

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

343 """See `pydantic.BaseModel.model_dump`.""" 

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

345 

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

347 """See `pydantic.BaseModel.model_dump_json`.""" 

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

349 

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

351 """See `pydantic.BaseModel.model_copy`.""" 

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

353 

354 @classmethod 

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

356 """See `pydantic.BaseModel.model_construct`.""" 

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

358 

359 @classmethod 

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

361 """See `pydantic.BaseModel.model_json_schema`.""" 

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

363 

364 @classmethod 

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

366 """See `pydantic.BaseModel.model_validate`.""" 

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

368 

369 @classmethod 

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

371 """See `pydantic.BaseModel.model_validate_json`.""" 

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

373 

374 @classmethod 

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

376 """See `pydantic.BaseModel.model_validate_strings`.""" 

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

378 

379 

380class ProvenanceQuantumAttemptModel(pydantic.BaseModel): 

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

382 provenance quantum graph file. 

383 """ 

384 

385 attempt: int = 0 

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

387 

388 status: QuantumAttemptStatus = QuantumAttemptStatus.UNKNOWN 

389 """Enumerated status for the quantum.""" 

390 

391 caveats: QuantumSuccessCaveats | None = None 

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

393 

394 exception: ExceptionInfo | None = None 

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

396 

397 resource_usage: QuantumResourceUsage | None = None 

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

399 

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

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

402 one. 

403 """ 

404 

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

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

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

408 

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

410 """See `pydantic.BaseModel.copy`.""" 

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

412 

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

414 """See `pydantic.BaseModel.model_dump`.""" 

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

416 

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

418 """See `pydantic.BaseModel.model_dump_json`.""" 

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

420 

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

422 """See `pydantic.BaseModel.model_copy`.""" 

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

424 

425 @classmethod 

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

427 """See `pydantic.BaseModel.model_construct`.""" 

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

429 

430 @classmethod 

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

432 """See `pydantic.BaseModel.model_json_schema`.""" 

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

434 

435 @classmethod 

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

437 """See `pydantic.BaseModel.model_validate`.""" 

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

439 

440 @classmethod 

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

442 """See `pydantic.BaseModel.model_validate_json`.""" 

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

444 

445 @classmethod 

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

447 """See `pydantic.BaseModel.model_validate_strings`.""" 

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

449 

450 

451class ProvenanceLogRecordsModel(pydantic.BaseModel): 

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

453 file. 

454 """ 

455 

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

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

458 to last. 

459 """ 

460 

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

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

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

464 

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

466 """See `pydantic.BaseModel.copy`.""" 

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

468 

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

470 """See `pydantic.BaseModel.model_dump`.""" 

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

472 

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

474 """See `pydantic.BaseModel.model_dump_json`.""" 

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

476 

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

478 """See `pydantic.BaseModel.model_copy`.""" 

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

480 

481 @classmethod 

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

483 """See `pydantic.BaseModel.model_construct`.""" 

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

485 

486 @classmethod 

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

488 """See `pydantic.BaseModel.model_json_schema`.""" 

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

490 

491 @classmethod 

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

493 """See `pydantic.BaseModel.model_validate`.""" 

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

495 

496 @classmethod 

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

498 """See `pydantic.BaseModel.model_validate_json`.""" 

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

500 

501 @classmethod 

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

503 """See `pydantic.BaseModel.model_validate_strings`.""" 

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

505 

506 

507class ProvenanceTaskMetadataModel(pydantic.BaseModel): 

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

509 file. 

510 """ 

511 

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

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

514 first to last. 

515 """ 

516 

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

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

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

520 

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

522 """See `pydantic.BaseModel.copy`.""" 

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

524 

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

526 """See `pydantic.BaseModel.model_dump`.""" 

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

528 

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

530 """See `pydantic.BaseModel.model_dump_json`.""" 

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

532 

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

534 """See `pydantic.BaseModel.model_copy`.""" 

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

536 

537 @classmethod 

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

539 """See `pydantic.BaseModel.model_construct`.""" 

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

541 

542 @classmethod 

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

544 """See `pydantic.BaseModel.model_json_schema`.""" 

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

546 

547 @classmethod 

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

549 """See `pydantic.BaseModel.model_validate`.""" 

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

551 

552 @classmethod 

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

554 """See `pydantic.BaseModel.model_validate_json`.""" 

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

556 

557 @classmethod 

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

559 """See `pydantic.BaseModel.model_validate_strings`.""" 

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

561 

562 

563class ProvenanceQuantumReport(pydantic.BaseModel): 

564 """A Pydantic model that used to report information about a single 

565 (generally problematic) quantum. 

566 """ 

567 

568 quantum_id: uuid.UUID 

569 data_id: dict[str, int | str] 

570 attempts: list[ProvenanceQuantumAttemptModel] 

571 

572 @classmethod 

573 def from_info(cls, quantum_id: uuid.UUID, quantum_info: ProvenanceQuantumInfo) -> ProvenanceQuantumReport: 

574 """Construct from a provenance quantum graph node. 

575 

576 Parameters 

577 ---------- 

578 quantum_id : `uuid.UUID` 

579 Unique ID for the quantum. 

580 quantum_info : `ProvenanceQuantumInfo` 

581 Node attributes for this quantum. 

582 """ 

583 return cls( 

584 quantum_id=quantum_id, 

585 data_id=dict(quantum_info["data_id"].mapping), 

586 attempts=quantum_info["attempts"], 

587 ) 

588 

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

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

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

592 

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

594 """See `pydantic.BaseModel.copy`.""" 

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

596 

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

598 """See `pydantic.BaseModel.model_dump`.""" 

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

600 

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

602 """See `pydantic.BaseModel.model_dump_json`.""" 

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

604 

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

606 """See `pydantic.BaseModel.model_copy`.""" 

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

608 

609 @classmethod 

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

611 """See `pydantic.BaseModel.model_construct`.""" 

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

613 

614 @classmethod 

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

616 """See `pydantic.BaseModel.model_json_schema`.""" 

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

618 

619 @classmethod 

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

621 """See `pydantic.BaseModel.model_validate`.""" 

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

623 

624 @classmethod 

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

626 """See `pydantic.BaseModel.model_validate_json`.""" 

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

628 

629 @classmethod 

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

631 """See `pydantic.BaseModel.model_validate_strings`.""" 

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

633 

634 

635class ProvenanceReport(pydantic.RootModel): 

636 """A Pydantic model that groups quantum information by task label, then 

637 status (as a string), and then exception type. 

638 """ 

639 

640 root: dict[TaskLabel, dict[str, dict[str | None, list[ProvenanceQuantumReport]]]] = {} 

641 

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

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

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

645 

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

647 """See `pydantic.BaseModel.copy`.""" 

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

649 

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

651 """See `pydantic.BaseModel.model_dump`.""" 

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

653 

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

655 """See `pydantic.BaseModel.model_dump_json`.""" 

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

657 

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

659 """See `pydantic.BaseModel.model_copy`.""" 

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

661 

662 @classmethod 

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

664 """See `pydantic.BaseModel.model_construct`.""" 

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

666 

667 @classmethod 

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

669 """See `pydantic.BaseModel.model_json_schema`.""" 

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

671 

672 @classmethod 

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

674 """See `pydantic.BaseModel.model_validate`.""" 

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

676 

677 @classmethod 

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

679 """See `pydantic.BaseModel.model_validate_json`.""" 

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

681 

682 @classmethod 

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

684 """See `pydantic.BaseModel.model_validate_strings`.""" 

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

686 

687 

688class ProvenanceQuantumModel(pydantic.BaseModel): 

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

690 

691 quantum_id: uuid.UUID 

692 """Unique identifier for the quantum.""" 

693 

694 task_label: TaskLabel 

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

696 

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

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

699 

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

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

702 connection name. 

703 """ 

704 

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

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

707 connection name. 

708 """ 

709 

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

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

712 chronologically from first to last. 

713 

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

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

716 failure. 

717 """ 

718 

719 @property 

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

721 """Alias for the quantum ID.""" 

722 return self.quantum_id 

723 

724 @classmethod 

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

726 """Construct from a predicted quantum model. 

727 

728 Parameters 

729 ---------- 

730 predicted : `PredictedQuantumDatasetsModel` 

731 Information about the quantum from the predicted graph. 

732 

733 Returns 

734 ------- 

735 provenance : `ProvenanceQuantumModel` 

736 Provenance quantum model. 

737 """ 

738 inputs = { 

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

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

741 } 

742 outputs = { 

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

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

745 } 

746 return cls( 

747 quantum_id=predicted.quantum_id, 

748 task_label=predicted.task_label, 

749 data_coordinate=predicted.data_coordinate, 

750 inputs=inputs, 

751 outputs=outputs, 

752 ) 

753 

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

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

756 

757 Parameters 

758 ---------- 

759 graph : `ProvenanceQuantumGraph` 

760 Graph to update in place. 

761 

762 Notes 

763 ----- 

764 This method adds: 

765 

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

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

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

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

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

771 already been loaded. 

772 """ 

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

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

775 last_attempt = ( 

776 self.attempts[-1] 

777 if self.attempts 

778 else ProvenanceQuantumAttemptModel(status=QuantumAttemptStatus.BLOCKED) 

779 ) 

780 graph._bipartite_xgraph.add_node( 

781 self.quantum_id, 

782 data_id=data_id, 

783 task_label=self.task_label, 

784 pipeline_node=task_node, 

785 status=last_attempt.status, 

786 caveats=last_attempt.caveats, 

787 exception=last_attempt.exception, 

788 resource_usage=last_attempt.resource_usage, 

789 attempts=self.attempts, 

790 ) 

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

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

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

794 read_edge = task_node.get_input_edge(connection_name) 

795 for dataset_id in dataset_ids: 

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

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

798 "pipeline_edges", [] 

799 ).append(read_edge) 

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

801 write_edge = task_node.get_output_edge(connection_name) 

802 if connection_name == acc.METADATA_OUTPUT_CONNECTION_NAME: 

803 graph._bipartite_xgraph.add_node( 

804 dataset_ids[0], 

805 data_id=data_id, 

806 dataset_type_name=write_edge.dataset_type_name, 

807 pipeline_node=graph.pipeline_graph.dataset_types[write_edge.dataset_type_name], 

808 run=graph.header.output_run, 

809 produced=last_attempt.status.has_metadata, 

810 ) 

811 graph._datasets_by_type[write_edge.dataset_type_name][data_id] = dataset_ids[0] 

812 graph._bipartite_xgraph.nodes[self.quantum_id]["metadata_id"] = dataset_ids[0] 

813 graph._quantum_only_xgraph.nodes[self.quantum_id]["metadata_id"] = dataset_ids[0] 

814 if connection_name == acc.LOG_OUTPUT_CONNECTION_NAME: 

815 graph._bipartite_xgraph.add_node( 

816 dataset_ids[0], 

817 data_id=data_id, 

818 dataset_type_name=write_edge.dataset_type_name, 

819 pipeline_node=graph.pipeline_graph.dataset_types[write_edge.dataset_type_name], 

820 run=graph.header.output_run, 

821 produced=last_attempt.status.has_log, 

822 ) 

823 graph._datasets_by_type[write_edge.dataset_type_name][data_id] = dataset_ids[0] 

824 graph._bipartite_xgraph.nodes[self.quantum_id]["log_id"] = dataset_ids[0] 

825 graph._quantum_only_xgraph.nodes[self.quantum_id]["log_id"] = dataset_ids[0] 

826 for dataset_id in dataset_ids: 

827 graph._bipartite_xgraph.add_edge( 

828 self.quantum_id, 

829 dataset_id, 

830 is_read=False, 

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

832 pipeline_edges=[write_edge], 

833 ) 

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

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

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

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

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

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

840 

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

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

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

844 

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

846 """See `pydantic.BaseModel.copy`.""" 

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

848 

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

850 """See `pydantic.BaseModel.model_dump`.""" 

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

852 

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

854 """See `pydantic.BaseModel.model_dump_json`.""" 

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

856 

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

858 """See `pydantic.BaseModel.model_copy`.""" 

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

860 

861 @classmethod 

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

863 """See `pydantic.BaseModel.model_construct`.""" 

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

865 

866 @classmethod 

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

868 """See `pydantic.BaseModel.model_json_schema`.""" 

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

870 

871 @classmethod 

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

873 """See `pydantic.BaseModel.model_validate`.""" 

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

875 

876 @classmethod 

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

878 """See `pydantic.BaseModel.model_validate_json`.""" 

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

880 

881 @classmethod 

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

883 """See `pydantic.BaseModel.model_validate_strings`.""" 

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

885 

886 

887class ProvenanceInitQuantumModel(pydantic.BaseModel): 

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

889 file. 

890 """ 

891 

892 quantum_id: uuid.UUID 

893 """Unique identifier for the quantum.""" 

894 

895 task_label: TaskLabel 

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

897 

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

899 

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

901 """ 

902 

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

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

905 connection name. 

906 """ 

907 

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

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

910 connection name. 

911 """ 

912 

913 @classmethod 

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

915 """Construct from a predicted quantum model. 

916 

917 Parameters 

918 ---------- 

919 predicted : `PredictedQuantumDatasetsModel` 

920 Information about the quantum from the predicted graph. 

921 

922 Returns 

923 ------- 

924 provenance : `ProvenanceInitQuantumModel` 

925 Provenance init quantum model. 

926 """ 

927 inputs = { 

928 connection_name: predicted_inputs[0].dataset_id 

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

930 } 

931 outputs = { 

932 connection_name: predicted_outputs[0].dataset_id 

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

934 } 

935 return cls( 

936 quantum_id=predicted.quantum_id, 

937 task_label=predicted.task_label, 

938 inputs=inputs, 

939 outputs=outputs, 

940 ) 

941 

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

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

944 

945 Parameters 

946 ---------- 

947 graph : `ProvenanceQuantumGraph` 

948 Graph to update in place. 

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

950 The empty data ID for the appropriate dimension universe. 

951 

952 Notes 

953 ----- 

954 This method adds: 

955 

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

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

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

959 """ 

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

961 graph._bipartite_xgraph.add_node( 

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

963 ) 

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

965 read_edge = task_init_node.get_input_edge(connection_name) 

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

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

968 "pipeline_edges", [] 

969 ).append(read_edge) 

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

971 write_edge = task_init_node.get_output_edge(connection_name) 

972 graph._bipartite_xgraph.add_node( 

973 dataset_id, 

974 data_id=empty_data_id, 

975 dataset_type_name=write_edge.dataset_type_name, 

976 pipeline_node=graph.pipeline_graph.dataset_types[write_edge.dataset_type_name], 

977 run=graph.header.output_run, 

978 produced=True, 

979 ) 

980 graph._datasets_by_type[write_edge.dataset_type_name][empty_data_id] = dataset_id 

981 graph._bipartite_xgraph.add_edge( 

982 self.quantum_id, 

983 dataset_id, 

984 is_read=False, 

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

986 pipeline_edges=[write_edge], 

987 ) 

988 if write_edge.connection_name == acc.CONFIG_INIT_OUTPUT_CONNECTION_NAME: 

989 graph._bipartite_xgraph.nodes[self.quantum_id]["config_id"] = dataset_id 

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

991 

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

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

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

995 

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

997 """See `pydantic.BaseModel.copy`.""" 

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

999 

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

1001 """See `pydantic.BaseModel.model_dump`.""" 

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

1003 

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

1005 """See `pydantic.BaseModel.model_dump_json`.""" 

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

1007 

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

1009 """See `pydantic.BaseModel.model_copy`.""" 

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

1011 

1012 @classmethod 

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

1014 """See `pydantic.BaseModel.model_construct`.""" 

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

1016 

1017 @classmethod 

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

1019 """See `pydantic.BaseModel.model_json_schema`.""" 

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

1021 

1022 @classmethod 

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

1024 """See `pydantic.BaseModel.model_validate`.""" 

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

1026 

1027 @classmethod 

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

1029 """See `pydantic.BaseModel.model_validate_json`.""" 

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

1031 

1032 @classmethod 

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

1034 """See `pydantic.BaseModel.model_validate_strings`.""" 

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

1036 

1037 

1038class ProvenanceInitQuantaModel(pydantic.RootModel): 

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

1040 

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

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

1043 

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

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

1046 

1047 Parameters 

1048 ---------- 

1049 graph : `ProvenanceQuantumGraph` 

1050 Graph to update in place. 

1051 """ 

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

1053 for init_quantum in self.root: 

1054 init_quantum._add_to_graph(graph, empty_data_id=empty_data_id) 

1055 

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

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

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

1059 

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

1061 """See `pydantic.BaseModel.copy`.""" 

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

1063 

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

1065 """See `pydantic.BaseModel.model_dump`.""" 

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

1067 

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

1069 """See `pydantic.BaseModel.model_dump_json`.""" 

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

1071 

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

1073 """See `pydantic.BaseModel.model_copy`.""" 

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

1075 

1076 @classmethod 

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

1078 """See `pydantic.BaseModel.model_construct`.""" 

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

1080 

1081 @classmethod 

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

1083 """See `pydantic.BaseModel.model_json_schema`.""" 

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

1085 

1086 @classmethod 

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

1088 """See `pydantic.BaseModel.model_validate`.""" 

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

1090 

1091 @classmethod 

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

1093 """See `pydantic.BaseModel.model_validate_json`.""" 

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

1095 

1096 @classmethod 

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

1098 """See `pydantic.BaseModel.model_validate_strings`.""" 

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

1100 

1101 

1102class ProvenanceQuantumGraph(BaseQuantumGraph): 

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

1104 executed. 

1105 

1106 Parameters 

1107 ---------- 

1108 header : `HeaderModel` 

1109 General metadata shared with other quantum graph types. 

1110 pipeline_graph : `.pipeline_graph.PipelineGraph` 

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

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

1113 graph. 

1114 

1115 Notes 

1116 ----- 

1117 A provenance quantum graph is generally obtained via the 

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

1119 as information is read from disk. 

1120 """ 

1121 

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

1123 super().__init__(header, pipeline_graph) 

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

1125 self._quantum_only_xgraph = networkx.DiGraph() 

1126 self._bipartite_xgraph = networkx.DiGraph() 

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

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

1129 } 

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

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

1132 } 

1133 

1134 @classmethod 

1135 @contextmanager 

1136 def from_args( 

1137 cls, 

1138 repo_or_filename: str, 

1139 /, 

1140 collection: str | None = None, 

1141 *, 

1142 quanta: Iterable[uuid.UUID] | None = None, 

1143 datasets: Iterable[uuid.UUID] | None = None, 

1144 writeable: bool = False, 

1145 ) -> Iterator[tuple[ProvenanceQuantumGraph, Butler | None]]: 

1146 """Construct a `ProvenanceQuantumGraph` fron CLI-friendly arguments for 

1147 a file or butler-ingested graph dataset. 

1148 

1149 Parameters 

1150 ---------- 

1151 repo_or_filename : `str` 

1152 Either a provenance quantum graph filename or a butler repository 

1153 path or alias. 

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

1155 Collections to search; presence indicates that the first argument 

1156 is a butler repository, not a filename. 

1157 quanta : `~collections.abc.Iterable` [ `str` ] or `None`, optional 

1158 IDs of the quanta to load, or `None` to load all. 

1159 datasets : `~collections.abc.Iterable` [ `str` ], optional 

1160 IDs of the datasets to load, or `None` to load all. 

1161 writeable : `bool`, optional 

1162 Whether the butler should be constructed with write support. 

1163 

1164 Returns 

1165 ------- 

1166 context : `contextlib.AbstractContextManager` 

1167 A context manager that yields a tuple of 

1168 

1169 - the `ProvenanceQuantumGraph` 

1170 - the `Butler` constructed (or `None`) 

1171 

1172 when entered. 

1173 """ 

1174 exit_stack = ExitStack() 

1175 if collection is not None: 

1176 try: 

1177 butler = exit_stack.enter_context( 

1178 Butler.from_config(repo_or_filename, collections=[collection], writeable=writeable) 

1179 ) 

1180 except Exception as err: 

1181 err.add_note( 

1182 f"Expected {repo_or_filename!r} to be a butler repository path or alias because a " 

1183 f"collection ({collection}) was provided." 

1184 ) 

1185 raise 

1186 with exit_stack: 

1187 graph = butler.get( 

1188 acc.PROVENANCE_DATASET_TYPE_NAME, parameters={"quanta": quanta, "datasets": datasets} 

1189 ) 

1190 yield graph, butler 

1191 else: 

1192 try: 

1193 reader = exit_stack.enter_context(ProvenanceQuantumGraphReader.open(repo_or_filename)) 

1194 except Exception as err: 

1195 err.add_note( 

1196 f"Expected a {repo_or_filename} to be a provenance quantum graph filename " 

1197 f"because no collection was provided." 

1198 ) 

1199 raise 

1200 with exit_stack: 

1201 if quanta is None: 

1202 reader.read_quanta() 

1203 elif not quanta: 

1204 reader.read_quanta(quanta) 

1205 if datasets is None: 

1206 reader.read_datasets() 

1207 elif not datasets: 

1208 reader.read_datasets(datasets) 

1209 yield reader.graph, None 

1210 

1211 @property 

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

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

1214 that task. 

1215 

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

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

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

1219 

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

1221 """ 

1222 return self._init_quanta 

1223 

1224 @property 

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

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

1227 data ID. 

1228 

1229 Notes 

1230 ----- 

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

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

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

1234 

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

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

1237 """ 

1238 return self._quanta_by_task_label 

1239 

1240 @property 

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

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

1243 and then by data ID. 

1244 

1245 Notes 

1246 ----- 

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

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

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

1250 

1251 Reading a quantum also populates its log and metadata datasets. 

1252 

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

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

1255 """ 

1256 return self._datasets_by_type 

1257 

1258 @property 

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

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

1261 

1262 Notes 

1263 ----- 

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

1265 they are loaded. Loading quanta (via 

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

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

1268 attributes. Loading datasets (via 

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

1270 nodes with no attributes. 

1271 

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

1273 

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

1275 

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

1277 """ 

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

1279 

1280 @property 

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

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

1283 

1284 Notes 

1285 ----- 

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

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

1288 `ProvenanceQuantumGraphReader.read_quanta`) or datasets (via 

1289 `ProvenanceQuantumGraphReader.read_datasets`) will load those nodes 

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

1291 Loading quanta is necessary to populate edge attributes. 

1292 Reading a quantum also populates its log and metadata datasets. 

1293 

1294 Node attributes are described by the 

1295 `ProvenanceQuantumInfo`, `ProvenanceInitQuantumInfo`, and 

1296 `ProvenanceDatasetInfo` types. 

1297 

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

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

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

1301 that would break the "bipartite" property). 

1302 

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

1304 """ 

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

1306 

1307 def make_quantum_table(self, drop_unused_columns: bool = True) -> astropy.table.Table: 

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

1309 quanta. 

1310 

1311 Parameters 

1312 ---------- 

1313 drop_unused_columns : `bool`, optional 

1314 Whether to drop columns for rare states that did not actually 

1315 occur in this run. 

1316 

1317 Returns 

1318 ------- 

1319 table : `astropy.table.Table` 

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

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

1322 detail. 

1323 

1324 Notes 

1325 ----- 

1326 Success caveats in the table are represented by their 

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

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

1329 be printed as well. 

1330 """ 

1331 rows = [] 

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

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

1334 continue 

1335 status_counts = Counter[QuantumAttemptStatus]( 

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

1337 ) 

1338 caveat_counts = Counter[QuantumSuccessCaveats | None]( 

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

1340 ) 

1341 caveat_counts.pop(QuantumSuccessCaveats.NO_CAVEATS, None) 

1342 caveat_counts.pop(None, None) 

1343 if len(caveat_counts) > 1: 

1344 caveats = "(multiple)" 

1345 elif len(caveat_counts) == 1: 

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

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

1348 # impossible here. 

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

1350 else: 

1351 caveats = "" 

1352 row: dict[str, Any] = { 

1353 "Task": task_label, 

1354 "Caveats": caveats, 

1355 } 

1356 for status in QuantumAttemptStatus: 

1357 row[status.title] = status_counts.get(status, 0) 

1358 row.update( 

1359 { 

1360 "TOTAL": len(quanta_for_task), 

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

1362 } 

1363 ) 

1364 rows.append(row) 

1365 table = astropy.table.Table(rows) 

1366 if drop_unused_columns: 

1367 for status in QuantumAttemptStatus: 

1368 if status.is_rare and not table[status.title].any(): 

1369 del table[status.title] 

1370 return table 

1371 

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

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

1374 type raised by each task. 

1375 

1376 Returns 

1377 ------- 

1378 table : `astropy.table.Table` 

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

1380 """ 

1381 rows = [] 

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

1383 success_counts = Counter[str]() 

1384 failed_counts = Counter[str]() 

1385 for quantum_id in quanta_for_task.values(): 

1386 quantum_info: ProvenanceQuantumInfo = self._quantum_only_xgraph.nodes[quantum_id] 

1387 exc_info = quantum_info["exception"] 

1388 if exc_info is not None: 

1389 if quantum_info["status"] is QuantumAttemptStatus.SUCCESSFUL: 

1390 success_counts[exc_info.type_name] += 1 

1391 else: 

1392 failed_counts[exc_info.type_name] += 1 

1393 for type_name in sorted(success_counts.keys() | failed_counts.keys()): 

1394 rows.append( 

1395 { 

1396 "Task": task_label, 

1397 "Exception": type_name, 

1398 "Successes": success_counts.get(type_name, 0), 

1399 "Failures": failed_counts.get(type_name, 0), 

1400 } 

1401 ) 

1402 return astropy.table.Table(rows) 

1403 

1404 def make_task_resource_usage_table( 

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

1406 ) -> astropy.table.Table: 

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

1408 

1409 Parameters 

1410 ---------- 

1411 task_label : `str` 

1412 Label of the task to extract resource usage for. 

1413 include_data_ids : `bool`, optional 

1414 Whether to also include data ID columns. 

1415 

1416 Returns 

1417 ------- 

1418 table : `astropy.table.Table` 

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

1420 `QuantumResourceUsage`. 

1421 """ 

1422 quanta_for_task = self.quanta_by_task[task_label] 

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

1424 if include_data_ids: 

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

1426 for dimension_name in dimensions.data_coordinate_keys: 

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

1428 dtype_terms.append((dimension_name, dtype)) 

1429 fields = QuantumResourceUsage.get_numpy_fields() 

1430 dtype_terms.extend(fields.items()) 

1431 row_dtype = np.dtype(dtype_terms) 

1432 rows: list[object] = [] 

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

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

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

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

1437 if include_data_ids: 

1438 row += data_id.full_values 

1439 row += resource_usage.get_numpy_row() 

1440 rows.append(row) 

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

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

1443 

1444 def make_status_report( 

1445 self, 

1446 states: Iterable[QuantumAttemptStatus] = ( 

1447 QuantumAttemptStatus.FAILED, 

1448 QuantumAttemptStatus.ABORTED, 

1449 QuantumAttemptStatus.ABORTED_SUCCESS, 

1450 ), 

1451 *, 

1452 also: QuantumAttemptStatus | Iterable[QuantumAttemptStatus] = (), 

1453 with_caveats: QuantumSuccessCaveats | None = QuantumSuccessCaveats.PARTIAL_OUTPUTS_ERROR, 

1454 data_id_table_dir: ResourcePathExpression | None = None, 

1455 ) -> ProvenanceReport: 

1456 """Make a JSON- or YAML-friendly report of all quanta with the given 

1457 states. 

1458 

1459 Parameters 

1460 ---------- 

1461 states : `~collections.abc.Iterable` [`..QuantumAttemptStatus`] or \ 

1462 `..QuantumAttemptStatus`, optional 

1463 A quantum is included if it has any of these states. Defaults to 

1464 states that clearly represent problems. 

1465 also : `~collections.abc.Iterable` [`..QuantumAttemptStatus`] or \ 

1466 `..QuantumAttemptStatus`, optional 

1467 Additional states to consider; unioned with ``states``. This is 

1468 provided so users can easily request additional states while also 

1469 getting the defaults. 

1470 with_caveats : `..QuantumSuccessCaveats` or `None`, optional 

1471 If `..QuantumAttemptStatus.SUCCESSFUL` is in ``states``, only 

1472 include quanta with these caveat flags. May be set to `None` 

1473 to report on all successful quanta. 

1474 data_id_table_dir : convertible to `~lsst.resources.ResourcePath`, \ 

1475 optional 

1476 If provided, a directory to write data ID tables (in ECSV format) 

1477 with all of the data IDs with the given states, for use with the 

1478 ``--data-id-tables`` argument to the quantum graph builder. 

1479 Subdirectories for each task and status will created within this 

1480 directory, with one file for each exception type (or ``UNKNOWN`` 

1481 when there is no exception). 

1482 

1483 Returns 

1484 ------- 

1485 report : `ProvenanceModel` 

1486 A Pydantic model that groups quanta by task label and exception 

1487 type. 

1488 """ 

1489 states = set(ensure_iterable(states)) 

1490 states.update(ensure_iterable(also)) 

1491 result = ProvenanceReport(root={}) 

1492 if data_id_table_dir is not None: 

1493 data_id_table_dir = ResourcePath(data_id_table_dir) 

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

1495 reports_for_task: dict[str, dict[str | None, list[ProvenanceQuantumReport]]] = {} 

1496 table_rows_for_task: dict[str, dict[str | None, list[tuple[int | str, ...]]]] = {} 

1497 for quantum_id in quanta_for_task.values(): 

1498 quantum_info: ProvenanceQuantumInfo = self._quantum_only_xgraph.nodes[quantum_id] 

1499 quantum_status = quantum_info["status"] 

1500 if quantum_status not in states: 

1501 continue 

1502 if ( 

1503 quantum_status is QuantumAttemptStatus.SUCCESSFUL 

1504 and with_caveats is not None 

1505 and (quantum_info["caveats"] is None or not (quantum_info["caveats"] & with_caveats)) 

1506 ): 

1507 continue 

1508 key1 = quantum_status.name 

1509 exc_info = quantum_info["exception"] 

1510 key2 = exc_info.type_name if exc_info is not None else None 

1511 reports_for_task.setdefault(key1, {}).setdefault(key2, []).append( 

1512 ProvenanceQuantumReport.from_info(quantum_id, quantum_info) 

1513 ) 

1514 if data_id_table_dir: 

1515 table_rows_for_task.setdefault(key1, {}).setdefault(key2, []).append( 

1516 quantum_info["data_id"].required_values 

1517 ) 

1518 if reports_for_task: 

1519 result.root[task_label] = reports_for_task 

1520 if table_rows_for_task: 

1521 assert data_id_table_dir is not None, "table_rows_for_task should be empty" 

1522 for status_name, table_rows_for_status in table_rows_for_task.items(): 

1523 dir_for_task_and_status = data_id_table_dir.join(task_label, forceDirectory=True).join( 

1524 status_name, forceDirectory=True 

1525 ) 

1526 if dir_for_task_and_status.isLocal: 

1527 dir_for_task_and_status.mkdir() 

1528 for exc_name, data_id_rows in table_rows_for_status.items(): 

1529 table = astropy.table.Table( 

1530 rows=data_id_rows, 

1531 names=list(self.pipeline_graph.tasks[task_label].dimensions.required), 

1532 ) 

1533 filename = f"{exc_name}.ecsv" if exc_name is not None else "UNKNOWN.ecsv" 

1534 with dir_for_task_and_status.join(filename).open("w") as stream: 

1535 table.write(stream, format="ecsv") 

1536 return result 

1537 

1538 def make_many_reports( 

1539 self, 

1540 states: Iterable[QuantumAttemptStatus] = ( 

1541 QuantumAttemptStatus.FAILED, 

1542 QuantumAttemptStatus.ABORTED, 

1543 QuantumAttemptStatus.ABORTED_SUCCESS, 

1544 ), 

1545 *, 

1546 status_report_file: ResourcePathExpression | None = None, 

1547 print_quantum_table: bool = False, 

1548 print_exception_table: bool = False, 

1549 also: QuantumAttemptStatus | Iterable[QuantumAttemptStatus] = (), 

1550 with_caveats: QuantumSuccessCaveats | None = None, 

1551 data_id_table_dir: ResourcePathExpression | None = None, 

1552 ) -> None: 

1553 """Write multiple reports. 

1554 

1555 Parameters 

1556 ---------- 

1557 states : `~collections.abc.Iterable` [`..QuantumAttemptStatus`] or \ 

1558 `..QuantumAttemptStatus`, optional 

1559 A quantum is included in the status report and data ID tables if it 

1560 has any of these states. Defaults to states that clearly represent 

1561 problems. 

1562 status_report_file : convertible to `~lsst.resources.ResourcePath`, 

1563 optional 

1564 Filename for the JSON status report (see `make_status_report`). 

1565 print_quantum_table : `bool`, optional 

1566 If `True`, print a quantum summary table (counts only) to STDOUT. 

1567 print_exception_table : `bool`, optional 

1568 If `True`, print an exception-type summary table (counts only) to 

1569 STDOUT. 

1570 also : `~collections.abc.Iterable` [`..QuantumAttemptStatus`] or \ 

1571 `..QuantumAttemptStatus`, optional 

1572 Additional states to consider in the status report and data ID 

1573 tables; unioned with ``states``. This is provided so users can 

1574 easily request additional states while also getting the defaults. 

1575 with_caveats : `..QuantumSuccessCaveats` or `None`, optional 

1576 Only include quanta with these caveat flags in the status report 

1577 and data ID tables. May be set to `None` to report on all 

1578 successful quanta (an empty sequence reports on only quanta with no 

1579 caveats). If provided, `QuantumAttemptStatus.SUCCESSFUL` is 

1580 automatically included in ``states``. 

1581 data_id_table_dir : convertible to `~lsst.resources.ResourcePath`, \ 

1582 optional 

1583 If provided, a directory to write data ID tables (in ECSV format) 

1584 with all of the data IDs with the given states, for use with the 

1585 ``--data-id-tables`` argument to the quantum graph builder. 

1586 Subdirectories for each task and status will created within this 

1587 directory, with one file for each exception type (or ``UNKNOWN`` 

1588 when there is no exception). 

1589 """ 

1590 if status_report_file is not None or data_id_table_dir is not None: 

1591 status_report = self.make_status_report( 

1592 states, also=also, with_caveats=with_caveats, data_id_table_dir=data_id_table_dir 

1593 ) 

1594 if status_report_file is not None: 

1595 status_report_file = ResourcePath(status_report_file) 

1596 if status_report_file.isLocal: 

1597 status_report_file.dirname().mkdir() 

1598 with ResourcePath(status_report_file).open("w") as stream: 

1599 stream.write(status_report.model_dump_json(indent=2)) 

1600 if print_quantum_table: 

1601 quantum_table = self.make_quantum_table() 

1602 quantum_table.pprint_all() 

1603 print("") 

1604 if print_exception_table: 

1605 exception_table = self.make_exception_table() 

1606 exception_table.pprint_all() 

1607 print("") 

1608 

1609 

1610@dataclasses.dataclass 

1611class ProvenanceQuantumGraphReader(BaseQuantumGraphReader): 

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

1613 

1614 Notes 

1615 ----- 

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

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

1618 the `graph` attribute`. 

1619 

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

1621 in place. 

1622 """ 

1623 

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

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

1626 

1627 @classmethod 

1628 @contextmanager 

1629 def open( 

1630 cls, 

1631 uri: ResourcePathExpression, 

1632 *, 

1633 page_size: int | None = None, 

1634 import_mode: TaskImportMode = TaskImportMode.DO_NOT_IMPORT, 

1635 ) -> Iterator[ProvenanceQuantumGraphReader]: 

1636 """Construct a reader from a URI. 

1637 

1638 Parameters 

1639 ---------- 

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

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

1642 page_size : `int`, optional 

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

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

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

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

1647 import_mode : `.pipeline_graph.TaskImportMode`, optional 

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

1649 graph. 

1650 

1651 Returns 

1652 ------- 

1653 reader : `contextlib.AbstractContextManager` [ \ 

1654 `ProvenanceQuantumGraphReader` ] 

1655 A context manager that returns the reader when entered. 

1656 """ 

1657 with cls._open( 

1658 uri, 

1659 graph_type="provenance", 

1660 address_filename="nodes", 

1661 page_size=page_size, 

1662 import_mode=import_mode, 

1663 n_addresses=4, 

1664 ) as self: 

1665 yield self 

1666 

1667 def __post_init__(self) -> None: 

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

1669 

1670 def read_init_quanta(self) -> None: 

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

1672 quanta by task label. 

1673 """ 

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

1675 for init_quantum in init_quanta.root: 

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

1677 init_quanta._add_to_graph(self.graph) 

1678 

1679 def read_full_graph(self) -> None: 

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

1681 attributes, fully populating the `graph` attribute. 

1682 

1683 Notes 

1684 ----- 

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

1686 fetched explicitly. 

1687 """ 

1688 self.read_init_quanta() 

1689 self.read_datasets() 

1690 self.read_quanta() 

1691 

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

1693 """Read information about the given datasets. 

1694 

1695 Parameters 

1696 ---------- 

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

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

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

1700 """ 

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

1702 

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

1704 """Read information about the given quanta. 

1705 

1706 Parameters 

1707 ---------- 

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

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

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

1711 quanta will be ignored. 

1712 """ 

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

1714 

1715 def _read_nodes( 

1716 self, 

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

1718 address_index: int, 

1719 mb_name: str, 

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

1721 ) -> None: 

1722 node: ProvenanceDatasetModel | ProvenanceQuantumModel | None 

1723 if nodes is None: 

1724 self.address_reader.read_all() 

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

1726 for node in MultiblockReader.read_all_models_in_zip( 

1727 self.zf, 

1728 mb_name, 

1729 model_type, 

1730 self.decompressor, 

1731 int_size=self.header.int_size, 

1732 page_size=self.page_size, 

1733 ): 

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

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

1736 # also have other outstanding reference holders). 

1737 continue 

1738 node._add_to_graph(self.graph) 

1739 else: 

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

1741 for node_id_or_index in nodes: 

1742 address_row = self.address_reader.find(node_id_or_index) 

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

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

1745 # might also have other outstanding reference holders). 

1746 continue 

1747 node = mb_reader.read_model( 

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

1749 ) 

1750 if node is not None: 

1751 node._add_to_graph(self.graph) 

1752 

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

1754 """Fetch log datasets. 

1755 

1756 Parameters 

1757 ---------- 

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

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

1760 correspond to. 

1761 

1762 Returns 

1763 ------- 

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

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

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

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

1768 execution attempts, ordered chronologically from first to last. 

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

1770 """ 

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

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

1773 for node_id_or_index in nodes: 

1774 address_row = self.address_reader.find(node_id_or_index) 

1775 logs_by_attempt = mb_reader.read_model( 

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

1777 ) 

1778 if logs_by_attempt is not None: 

1779 result[node_id_or_index] = [ 

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

1781 for attempt_logs in logs_by_attempt.attempts 

1782 ] 

1783 return result 

1784 

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

1786 """Fetch metadata datasets. 

1787 

1788 Parameters 

1789 ---------- 

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

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

1792 correspond to. 

1793 

1794 Returns 

1795 ------- 

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

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

1798 `.TaskMetadata` instances representing different execution 

1799 attempts, ordered chronologically from first to last. Attempts 

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

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

1802 """ 

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

1804 with MultiblockReader.open_in_zip( 

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

1806 ) as mb_reader: 

1807 for node_id_or_index in nodes: 

1808 address_row = self.address_reader.find(node_id_or_index) 

1809 metadata_by_attempt = mb_reader.read_model( 

1810 address_row.addresses[METADATA_ADDRESS_INDEX], 

1811 ProvenanceTaskMetadataModel, 

1812 self.decompressor, 

1813 ) 

1814 if metadata_by_attempt is not None: 

1815 result[node_id_or_index] = metadata_by_attempt.attempts 

1816 return result 

1817 

1818 def fetch_packages(self) -> Packages: 

1819 """Fetch package version information.""" 

1820 data = self._read_single_block_raw("packages") 

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

1822 

1823 

1824class ProvenanceQuantumGraphWriter: 

1825 """A struct of low-level writer objects for the main components of a 

1826 provenance quantum graph. 

1827 

1828 Parameters 

1829 ---------- 

1830 output_path : `str` 

1831 Path to write the graph to. 

1832 exit_stack : `contextlib.ExitStack` 

1833 Object that can be used to manage multiple context managers. 

1834 log_on_close : `LogOnClose` 

1835 Factory for context managers that log when closed. 

1836 predicted : `.PredictedQuantumGraphComponents` 

1837 Components of the predicted graph. 

1838 zstd_level : `int`, optional 

1839 Compression level. 

1840 cdict_data : `bytes` or `None`, optional 

1841 Bytes representation of the compression dictionary used by the 

1842 compressor. 

1843 loop_wrapper : `~collections.abc.Callable`, optional 

1844 A callable that takes an iterable and returns an equivalent one, to be 

1845 used in all potentially-large loops. This can be used to add progress 

1846 reporting or check for cancelation signals. 

1847 log : `LsstLogAdapter`, optional 

1848 Logger to use for debug messages. 

1849 """ 

1850 

1851 def __init__( 

1852 self, 

1853 output_path: str, 

1854 *, 

1855 exit_stack: ExitStack, 

1856 log_on_close: LogOnClose, 

1857 predicted: PredictedQuantumGraphComponents | PredictedQuantumGraph, 

1858 zstd_level: int = 10, 

1859 cdict_data: bytes | None = None, 

1860 loop_wrapper: LoopWrapper = pass_through, 

1861 log: LsstLogAdapter | None = None, 

1862 ) -> None: 

1863 header = predicted.header.model_copy() 

1864 header.graph_type = "provenance" 

1865 if log is None: 

1866 log = _LOG 

1867 self.log = log 

1868 self._base_writer = exit_stack.enter_context( 

1869 log_on_close.wrap( 

1870 BaseQuantumGraphWriter.open( 

1871 output_path, 

1872 header, 

1873 predicted.pipeline_graph, 

1874 address_filename="nodes", 

1875 zstd_level=zstd_level, 

1876 cdict_data=cdict_data, 

1877 ), 

1878 "Finishing writing provenance quantum graph.", 

1879 ) 

1880 ) 

1881 self._base_writer.address_writer.addresses = [{}, {}, {}, {}] 

1882 self._log_writer = exit_stack.enter_context( 

1883 log_on_close.wrap( 

1884 MultiblockWriter.open_in_zip( 

1885 self._base_writer.zf, LOG_MB_NAME, header.int_size, use_tempfile=True 

1886 ), 

1887 "Copying logs into zip archive.", 

1888 ), 

1889 ) 

1890 self._base_writer.address_writer.addresses[LOG_ADDRESS_INDEX] = self._log_writer.addresses 

1891 self._metadata_writer = exit_stack.enter_context( 

1892 log_on_close.wrap( 

1893 MultiblockWriter.open_in_zip( 

1894 self._base_writer.zf, METADATA_MB_NAME, header.int_size, use_tempfile=True 

1895 ), 

1896 "Copying metadata into zip archive.", 

1897 ) 

1898 ) 

1899 self._base_writer.address_writer.addresses[METADATA_ADDRESS_INDEX] = self._metadata_writer.addresses 

1900 self._dataset_writer = exit_stack.enter_context( 

1901 log_on_close.wrap( 

1902 MultiblockWriter.open_in_zip( 

1903 self._base_writer.zf, DATASET_MB_NAME, header.int_size, use_tempfile=True 

1904 ), 

1905 "Copying dataset provenance into zip archive.", 

1906 ) 

1907 ) 

1908 self._base_writer.address_writer.addresses[DATASET_ADDRESS_INDEX] = self._dataset_writer.addresses 

1909 self._quantum_writer = exit_stack.enter_context( 

1910 log_on_close.wrap( 

1911 MultiblockWriter.open_in_zip( 

1912 self._base_writer.zf, QUANTUM_MB_NAME, header.int_size, use_tempfile=True 

1913 ), 

1914 "Copying quantum provenance into zip archive.", 

1915 ) 

1916 ) 

1917 self._base_writer.address_writer.addresses[QUANTUM_ADDRESS_INDEX] = self._quantum_writer.addresses 

1918 self._init_predicted_quanta(predicted) 

1919 self._populate_xgraph_and_inputs(loop_wrapper) 

1920 self._existing_init_outputs: set[uuid.UUID] = set() 

1921 

1922 def _init_predicted_quanta( 

1923 self, predicted: PredictedQuantumGraph | PredictedQuantumGraphComponents 

1924 ) -> None: 

1925 self._predicted_init_quanta: list[PredictedQuantumDatasetsModel] = [] 

1926 self._predicted_quanta: dict[uuid.UUID, PredictedQuantumDatasetsModel] = {} 

1927 if isinstance(predicted, PredictedQuantumGraph): 

1928 self._predicted_init_quanta.extend(predicted._init_quanta.values()) 

1929 self._predicted_quanta.update(predicted._quantum_datasets) 

1930 else: 

1931 self._predicted_init_quanta.extend(predicted.init_quanta.root) 

1932 self._predicted_quanta.update(predicted.quantum_datasets) 

1933 self._predicted_quanta.update({q.quantum_id: q for q in self._predicted_init_quanta}) 

1934 

1935 def _populate_xgraph_and_inputs(self, loop_wrapper: LoopWrapper = pass_through) -> None: 

1936 self._xgraph = networkx.DiGraph() 

1937 self._overall_inputs: dict[uuid.UUID, PredictedDatasetModel] = {} 

1938 output_dataset_ids: set[uuid.UUID] = set() 

1939 for predicted_quantum in loop_wrapper(self._predicted_quanta.values()): 

1940 if not predicted_quantum.task_label: 

1941 # Skip the 'packages' producer quantum. 

1942 continue 

1943 output_dataset_ids.update(predicted_quantum.iter_output_dataset_ids()) 

1944 for predicted_quantum in loop_wrapper(self._predicted_quanta.values()): 

1945 if not predicted_quantum.task_label: 

1946 # Skip the 'packages' producer quantum. 

1947 continue 

1948 for predicted_input in itertools.chain.from_iterable(predicted_quantum.inputs.values()): 

1949 self._xgraph.add_edge(predicted_input.dataset_id, predicted_quantum.quantum_id) 

1950 if predicted_input.dataset_id not in output_dataset_ids: 

1951 self._overall_inputs.setdefault(predicted_input.dataset_id, predicted_input) 

1952 for predicted_output in itertools.chain.from_iterable(predicted_quantum.outputs.values()): 

1953 self._xgraph.add_edge(predicted_quantum.quantum_id, predicted_output.dataset_id) 

1954 

1955 @property 

1956 def compressor(self) -> Compressor: 

1957 """Object that should be used to compress all JSON blocks.""" 

1958 return self._base_writer.compressor 

1959 

1960 def write_packages(self) -> None: 

1961 """Write package version information to the provenance graph.""" 

1962 packages = Packages.fromSystem(include_all=True) 

1963 data = packages.toBytes("json") 

1964 self._base_writer.write_single_block("packages", data) 

1965 

1966 def write_overall_inputs(self, loop_wrapper: LoopWrapper = pass_through) -> None: 

1967 """Write provenance for overall-input datasets. 

1968 

1969 Parameters 

1970 ---------- 

1971 loop_wrapper : `~collections.abc.Callable`, optional 

1972 A callable that takes an iterable and returns an equivalent one, to 

1973 be used in all potentially-large loops. This can be used to add 

1974 progress reporting or check for cancelation signals. 

1975 """ 

1976 for predicted_input in loop_wrapper(self._overall_inputs.values()): 

1977 if predicted_input.dataset_id not in self._dataset_writer.addresses: 

1978 self._dataset_writer.write_model( 

1979 predicted_input.dataset_id, 

1980 ProvenanceDatasetModel.from_predicted( 

1981 predicted_input, 

1982 producer=None, 

1983 consumers=self._xgraph.successors(predicted_input.dataset_id), 

1984 ), 

1985 self.compressor, 

1986 ) 

1987 del self._overall_inputs 

1988 

1989 def write_init_outputs(self, assume_existence: bool = True) -> None: 

1990 """Write provenance for init-output datasets and init-quanta. 

1991 

1992 Parameters 

1993 ---------- 

1994 assume_existence : `bool`, optional 

1995 If `True`, just assume all init-outputs exist. 

1996 """ 

1997 init_quanta = ProvenanceInitQuantaModel() 

1998 for predicted_init_quantum in self._predicted_init_quanta: 

1999 if not predicted_init_quantum.task_label: 

2000 # Skip the 'packages' producer quantum. 

2001 continue 

2002 for predicted_output in itertools.chain.from_iterable(predicted_init_quantum.outputs.values()): 

2003 provenance_output = ProvenanceDatasetModel.from_predicted( 

2004 predicted_output, 

2005 producer=predicted_init_quantum.quantum_id, 

2006 consumers=self._xgraph.successors(predicted_output.dataset_id), 

2007 ) 

2008 provenance_output.produced = assume_existence or ( 

2009 provenance_output.dataset_id in self._existing_init_outputs 

2010 ) 

2011 self._dataset_writer.write_model( 

2012 provenance_output.dataset_id, provenance_output, self.compressor 

2013 ) 

2014 init_quanta.root.append(ProvenanceInitQuantumModel.from_predicted(predicted_init_quantum)) 

2015 self._base_writer.write_single_model("init_quanta", init_quanta) 

2016 

2017 def write_quantum_provenance( 

2018 self, quantum_id: uuid.UUID, metadata: TaskMetadata | None, logs: ButlerLogRecords | None 

2019 ) -> None: 

2020 """Gather and write provenance for a quantum. 

2021 

2022 Parameters 

2023 ---------- 

2024 quantum_id : `uuid.UUID` 

2025 Unique ID for the quantum. 

2026 metadata : `..TaskMetadata` or `None` 

2027 Task metadata. 

2028 logs : `lsst.daf.butler.logging.ButlerLogRecords` or `None` 

2029 Task logs. 

2030 """ 

2031 predicted_quantum = self._predicted_quanta[quantum_id] 

2032 provenance_models = ProvenanceQuantumScanModels.from_metadata_and_logs( 

2033 predicted_quantum, metadata, logs, incomplete=False 

2034 ) 

2035 scan_data = provenance_models.to_scan_data(predicted_quantum, compressor=self.compressor) 

2036 self.write_scan_data(scan_data) 

2037 

2038 def write_scan_data(self, scan_data: ProvenanceQuantumScanData) -> None: 

2039 """Write the output of a quantum provenance scan to disk. 

2040 

2041 Parameters 

2042 ---------- 

2043 scan_data : `ProvenanceQuantumScanData` 

2044 Result of a quantum provenance scan. 

2045 """ 

2046 if scan_data.status is ProvenanceQuantumScanStatus.INIT: 

2047 self.log.debug("Handling init-output scan for %s.", scan_data.quantum_id) 

2048 self._existing_init_outputs.update(scan_data.existing_outputs) 

2049 return 

2050 self.log.debug("Handling quantum scan for %s.", scan_data.quantum_id) 

2051 # We shouldn't need this predicted quantum after this method runs; pop 

2052 # from the dict it in the hopes that'll free up some memory when we're 

2053 # done. 

2054 predicted_quantum = self._predicted_quanta.pop(scan_data.quantum_id) 

2055 outputs: dict[uuid.UUID, bytes] = {} 

2056 for predicted_output in itertools.chain.from_iterable(predicted_quantum.outputs.values()): 

2057 provenance_output = ProvenanceDatasetModel.from_predicted( 

2058 predicted_output, 

2059 producer=predicted_quantum.quantum_id, 

2060 consumers=self._xgraph.successors(predicted_output.dataset_id), 

2061 ) 

2062 provenance_output.produced = provenance_output.dataset_id in scan_data.existing_outputs 

2063 outputs[provenance_output.dataset_id] = self.compressor.compress( 

2064 provenance_output.model_dump_json().encode() 

2065 ) 

2066 if not scan_data.quantum: 

2067 scan_data.quantum = ( 

2068 ProvenanceQuantumModel.from_predicted(predicted_quantum).model_dump_json().encode() 

2069 ) 

2070 if scan_data.is_compressed: 

2071 scan_data.quantum = self.compressor.compress(scan_data.quantum) 

2072 if not scan_data.is_compressed: 

2073 scan_data.quantum = self.compressor.compress(scan_data.quantum) 

2074 if scan_data.metadata: 

2075 scan_data.metadata = self.compressor.compress(scan_data.metadata) 

2076 if scan_data.logs: 

2077 scan_data.logs = self.compressor.compress(scan_data.logs) 

2078 self.log.debug("Writing quantum %s.", scan_data.quantum_id) 

2079 self._quantum_writer.write_bytes(scan_data.quantum_id, scan_data.quantum) 

2080 for dataset_id, dataset_data in outputs.items(): 

2081 self._dataset_writer.write_bytes(dataset_id, dataset_data) 

2082 if scan_data.metadata: 

2083 (metadata_output,) = predicted_quantum.outputs[acc.METADATA_OUTPUT_CONNECTION_NAME] 

2084 address = self._metadata_writer.write_bytes(scan_data.quantum_id, scan_data.metadata) 

2085 self._metadata_writer.addresses[metadata_output.dataset_id] = address 

2086 if scan_data.logs: 

2087 (log_output,) = predicted_quantum.outputs[acc.LOG_OUTPUT_CONNECTION_NAME] 

2088 address = self._log_writer.write_bytes(scan_data.quantum_id, scan_data.logs) 

2089 self._log_writer.addresses[log_output.dataset_id] = address 

2090 

2091 

2092class ProvenanceQuantumScanStatus(enum.Enum): 

2093 """Status enum for quantum scanning. 

2094 

2095 Note that this records the status for the *scanning* which is distinct 

2096 from the status of the quantum's execution. 

2097 """ 

2098 

2099 INCOMPLETE = enum.auto() 

2100 """The quantum is not necessarily done running, and cannot be scanned 

2101 conclusively yet. 

2102 """ 

2103 

2104 ABANDONED = enum.auto() 

2105 """The quantum's execution appears to have failed but we cannot rule out 

2106 the possibility that it could be recovered, but we've also waited long 

2107 enough (according to `ScannerTimeConfigDict.retry_timeout`) that it's time 

2108 to stop trying for now. 

2109 

2110 This state means `ProvenanceQuantumScanModels.from_metadata_and_logs` must 

2111 be run again with ``incomplete=False``. 

2112 """ 

2113 

2114 SUCCESSFUL = enum.auto() 

2115 """The quantum was conclusively scanned and was executed successfully, 

2116 unblocking scans for downstream quanta. 

2117 """ 

2118 

2119 FAILED = enum.auto() 

2120 """The quantum was conclusively scanned and failed execution, blocking 

2121 scans for downstream quanta. 

2122 """ 

2123 

2124 BLOCKED = enum.auto() 

2125 """A quantum upstream of this one failed.""" 

2126 

2127 INIT = enum.auto() 

2128 """Init quanta need special handling, because they don't have logs and 

2129 metadata. 

2130 """ 

2131 

2132 

2133@dataclasses.dataclass 

2134class ProvenanceQuantumScanModels: 

2135 """A struct that represents provenance information for a single quantum.""" 

2136 

2137 quantum_id: uuid.UUID 

2138 """Unique ID for the quantum.""" 

2139 

2140 status: ProvenanceQuantumScanStatus = ProvenanceQuantumScanStatus.INCOMPLETE 

2141 """Combined status for the scan and the execution of the quantum.""" 

2142 

2143 attempts: list[ProvenanceQuantumAttemptModel] = dataclasses.field(default_factory=list) 

2144 """Provenance information about each attempt to run the quantum.""" 

2145 

2146 output_existence: dict[uuid.UUID, bool] = dataclasses.field(default_factory=dict) 

2147 """Unique IDs of the output datasets mapped to whether they were actually 

2148 produced. 

2149 """ 

2150 

2151 metadata: ProvenanceTaskMetadataModel = dataclasses.field(default_factory=ProvenanceTaskMetadataModel) 

2152 """Task metadata information for each attempt. 

2153 """ 

2154 

2155 logs: ProvenanceLogRecordsModel = dataclasses.field(default_factory=ProvenanceLogRecordsModel) 

2156 """Log records for each attempt. 

2157 """ 

2158 

2159 @classmethod 

2160 def from_metadata_and_logs( 

2161 cls, 

2162 predicted: PredictedQuantumDatasetsModel, 

2163 metadata: TaskMetadata | None, 

2164 logs: ButlerLogRecords | None, 

2165 *, 

2166 incomplete: bool = False, 

2167 ) -> ProvenanceQuantumScanModels: 

2168 """Construct provenance information from task metadata and logs. 

2169 

2170 Parameters 

2171 ---------- 

2172 predicted : `PredictedQuantumDatasetsModel` 

2173 Information about the predicted quantum. 

2174 metadata : `..TaskMetadata` or `None` 

2175 Task metadata. 

2176 logs : `lsst.daf.butler.logging.ButlerLogRecords` or `None` 

2177 Task logs. 

2178 incomplete : `bool`, optional 

2179 If `True`, treat execution failures as possibly-incomplete quanta 

2180 and do not fully process them; instead just set the status to 

2181 `ProvenanceQuantumScanStatus.ABANDONED` and return. 

2182 

2183 Returns 

2184 ------- 

2185 scan_models : `ProvenanceQuantumScanModels` 

2186 Struct of models that describe quantum provenance. 

2187 

2188 Notes 

2189 ----- 

2190 This method does not necessarily fully populate the `output_existence` 

2191 field; it does what it can given the information in the metadata and 

2192 logs, but the caller is responsible for filling in the existence status 

2193 for any predicted outputs that are not present at all in that `dict`. 

2194 """ 

2195 self = ProvenanceQuantumScanModels(predicted.quantum_id) 

2196 last_attempt = ProvenanceQuantumAttemptModel() 

2197 self._process_logs(predicted, logs, last_attempt, incomplete=incomplete) 

2198 self._process_metadata(predicted, metadata, last_attempt, incomplete=incomplete) 

2199 if self.status is ProvenanceQuantumScanStatus.ABANDONED: 

2200 return self 

2201 self._reconcile_attempts(last_attempt) 

2202 self._extract_output_existence(predicted) 

2203 return self 

2204 

2205 def _process_logs( 

2206 self, 

2207 predicted: PredictedQuantumDatasetsModel, 

2208 logs: ButlerLogRecords | None, 

2209 last_attempt: ProvenanceQuantumAttemptModel, 

2210 *, 

2211 incomplete: bool, 

2212 ) -> None: 

2213 (predicted_log_dataset,) = predicted.outputs[acc.LOG_OUTPUT_CONNECTION_NAME] 

2214 if logs is None: 

2215 self.output_existence[predicted_log_dataset.dataset_id] = False 

2216 if incomplete: 

2217 self.status = ProvenanceQuantumScanStatus.ABANDONED 

2218 else: 

2219 self.status = ProvenanceQuantumScanStatus.FAILED 

2220 else: 

2221 # Set the attempt's run status to FAILED, since the default is 

2222 # UNKNOWN (i.e. logs *and* metadata are missing) and we now know 

2223 # the logs exist. This will usually get replaced by SUCCESSFUL 

2224 # when we look for metadata next. 

2225 last_attempt.status = QuantumAttemptStatus.FAILED 

2226 self.output_existence[predicted_log_dataset.dataset_id] = True 

2227 if logs.extra: 

2228 log_extra = _ExecutionLogRecordsExtra.model_validate(logs.extra) 

2229 self._extract_from_log_extra(log_extra, last_attempt=last_attempt) 

2230 self.logs.attempts.append(list(logs)) 

2231 

2232 def _extract_from_log_extra( 

2233 self, 

2234 log_extra: _ExecutionLogRecordsExtra, 

2235 last_attempt: ProvenanceQuantumAttemptModel | None, 

2236 ) -> None: 

2237 for previous_attempt_log_extra in log_extra.previous_attempts: 

2238 self._extract_from_log_extra( 

2239 previous_attempt_log_extra, 

2240 last_attempt=None, 

2241 ) 

2242 quantum_attempt: ProvenanceQuantumAttemptModel 

2243 if last_attempt is None: 

2244 # This is not the last attempt, so it must be a failure. 

2245 quantum_attempt = ProvenanceQuantumAttemptModel( 

2246 attempt=len(self.attempts), status=QuantumAttemptStatus.FAILED 

2247 ) 

2248 # We also need to get the logs from this extra provenance, since 

2249 # they won't be the main section of the log records. 

2250 self.logs.attempts.append(log_extra.logs) 

2251 # The special last attempt is only appended after we attempt to 

2252 # read metadata later, but we have to append this one now. 

2253 self.attempts.append(quantum_attempt) 

2254 else: 

2255 assert not log_extra.logs, "Logs for the last attempt should not be stored in the extra JSON." 

2256 quantum_attempt = last_attempt 

2257 if log_extra.exception is not None or log_extra.metadata is not None or last_attempt is None: 

2258 # We won't be getting a separate metadata dataset, so anything we 

2259 # might get from the metadata has to come from this extra 

2260 # provenance in the logs. 

2261 quantum_attempt.exception = log_extra.exception 

2262 if log_extra.metadata is not None: 

2263 quantum_attempt.resource_usage = QuantumResourceUsage.from_task_metadata(log_extra.metadata) 

2264 self.metadata.attempts.append(log_extra.metadata) 

2265 else: 

2266 self.metadata.attempts.append(None) 

2267 # Regardless of whether this is the last attempt or not, we can only 

2268 # get the previous_process_quanta from the log extra. 

2269 quantum_attempt.previous_process_quanta.extend(log_extra.previous_process_quanta) 

2270 

2271 def _process_metadata( 

2272 self, 

2273 predicted: PredictedQuantumDatasetsModel, 

2274 metadata: TaskMetadata | None, 

2275 last_attempt: ProvenanceQuantumAttemptModel, 

2276 *, 

2277 incomplete: bool, 

2278 ) -> None: 

2279 (predicted_metadata_dataset,) = predicted.outputs[acc.METADATA_OUTPUT_CONNECTION_NAME] 

2280 if metadata is None: 

2281 self.output_existence[predicted_metadata_dataset.dataset_id] = False 

2282 if incomplete: 

2283 self.status = ProvenanceQuantumScanStatus.ABANDONED 

2284 else: 

2285 self.status = ProvenanceQuantumScanStatus.FAILED 

2286 else: 

2287 self.status = ProvenanceQuantumScanStatus.SUCCESSFUL 

2288 self.output_existence[predicted_metadata_dataset.dataset_id] = True 

2289 last_attempt.status = QuantumAttemptStatus.SUCCESSFUL 

2290 try: 

2291 # Int conversion guards against spurious conversion to 

2292 # float that can apparently sometimes happen in 

2293 # TaskMetadata. 

2294 last_attempt.caveats = QuantumSuccessCaveats(int(metadata["quantum"]["caveats"])) 

2295 except LookupError: 

2296 pass 

2297 try: 

2298 last_attempt.exception = ExceptionInfo._from_metadata( 

2299 metadata[predicted.task_label]["failure"] 

2300 ) 

2301 except LookupError: 

2302 pass 

2303 last_attempt.resource_usage = QuantumResourceUsage.from_task_metadata(metadata) 

2304 self.metadata.attempts.append(metadata) 

2305 

2306 def _reconcile_attempts(self, last_attempt: ProvenanceQuantumAttemptModel) -> None: 

2307 last_attempt.attempt = len(self.attempts) 

2308 self.attempts.append(last_attempt) 

2309 assert self.status is not ProvenanceQuantumScanStatus.INCOMPLETE 

2310 assert self.status is not ProvenanceQuantumScanStatus.ABANDONED 

2311 if len(self.logs.attempts) < len(self.attempts): 

2312 # Logs were not found for this attempt; must have been a hard error 

2313 # that kept the `finally` block from running or otherwise 

2314 # interrupted the writing of the logs. 

2315 self.logs.attempts.append(None) 

2316 if self.status is ProvenanceQuantumScanStatus.SUCCESSFUL: 

2317 # But we found the metadata! Either that hard error happened 

2318 # at a very unlucky time (in between those two writes), or 

2319 # something even weirder happened. 

2320 self.attempts[-1].status = QuantumAttemptStatus.ABORTED_SUCCESS 

2321 else: 

2322 self.attempts[-1].status = QuantumAttemptStatus.FAILED 

2323 if len(self.metadata.attempts) < len(self.attempts): 

2324 # Metadata missing usually just means a failure. In any case, the 

2325 # status will already be correct, either because it was set to a 

2326 # failure when we read the logs, or left at UNKNOWN if there were 

2327 # no logs. Note that scanners never process BLOCKED quanta at all. 

2328 self.metadata.attempts.append(None) 

2329 assert len(self.logs.attempts) == len(self.attempts) or len(self.metadata.attempts) == len( 

2330 self.attempts 

2331 ), ( 

2332 "The only way we can add more than one quantum attempt is by " 

2333 "extracting info stored with the logs, and that always appends " 

2334 "a log attempt and a metadata attempt, so this must be a bug in " 

2335 "this class." 

2336 ) 

2337 

2338 def _extract_output_existence(self, predicted: PredictedQuantumDatasetsModel) -> None: 

2339 try: 

2340 outputs_put = self.metadata.attempts[-1]["quantum"].getArray("outputs") # type: ignore[index] 

2341 except ( 

2342 IndexError, # metadata.attempts is empty 

2343 TypeError, # metadata.attempts[-1] is None 

2344 LookupError, # no 'quantum' entry in metadata or 'outputs' in that 

2345 ): 

2346 pass 

2347 else: 

2348 for id_str in ensure_iterable(outputs_put): 

2349 self.output_existence[uuid.UUID(id_str)] = True 

2350 # If the metadata told us what it wrote, anything not in that 

2351 # list was not written. 

2352 for predicted_output in itertools.chain.from_iterable(predicted.outputs.values()): 

2353 self.output_existence.setdefault(predicted_output.dataset_id, False) 

2354 

2355 def to_scan_data( 

2356 self: ProvenanceQuantumScanModels, 

2357 predicted_quantum: PredictedQuantumDatasetsModel, 

2358 compressor: Compressor | None = None, 

2359 ) -> ProvenanceQuantumScanData: 

2360 """Convert these models to JSON data. 

2361 

2362 Parameters 

2363 ---------- 

2364 predicted_quantum : `PredictedQuantumDatasetsModel` 

2365 Information about the predicted quantum. 

2366 compressor : `Compressor` 

2367 Object that can compress bytes. 

2368 

2369 Returns 

2370 ------- 

2371 scan_data : `ProvenanceQuantumScanData` 

2372 Scan information ready for serialization. 

2373 """ 

2374 quantum: ProvenanceInitQuantumModel | ProvenanceQuantumModel 

2375 if self.status is ProvenanceQuantumScanStatus.INIT: 

2376 quantum = ProvenanceInitQuantumModel.from_predicted(predicted_quantum) 

2377 else: 

2378 quantum = ProvenanceQuantumModel.from_predicted(predicted_quantum) 

2379 quantum.attempts = self.attempts 

2380 for predicted_output in itertools.chain.from_iterable(predicted_quantum.outputs.values()): 

2381 if predicted_output.dataset_id not in self.output_existence: 

2382 raise RuntimeError( 

2383 "Logic bug in provenance gathering or execution invariants: " 

2384 f"no existence information for output {predicted_output.dataset_id} " 

2385 f"({predicted_output.dataset_type_name}@{predicted_output.data_coordinate})." 

2386 ) 

2387 data = ProvenanceQuantumScanData( 

2388 self.quantum_id, 

2389 self.status, 

2390 existing_outputs={ 

2391 dataset_id for dataset_id, was_produced in self.output_existence.items() if was_produced 

2392 }, 

2393 quantum=quantum.model_dump_json().encode(), 

2394 logs=self.logs.model_dump_json().encode() if self.logs.attempts else b"", 

2395 metadata=self.metadata.model_dump_json().encode() if self.metadata.attempts else b"", 

2396 ) 

2397 if compressor is not None: 

2398 data.compress(compressor) 

2399 return data 

2400 

2401 

2402@dataclasses.dataclass 

2403class ProvenanceQuantumScanData: 

2404 """A struct that represents ready-for-serialization provenance information 

2405 for a single quantum. 

2406 """ 

2407 

2408 quantum_id: uuid.UUID 

2409 """Unique ID for the quantum.""" 

2410 

2411 status: ProvenanceQuantumScanStatus 

2412 """Combined status for the scan and the execution of the quantum.""" 

2413 

2414 existing_outputs: set[uuid.UUID] = dataclasses.field(default_factory=set) 

2415 """Unique IDs of the output datasets that were actually written.""" 

2416 

2417 quantum: bytes = b"" 

2418 """Serialized quantum provenance model. 

2419 

2420 This may be empty for quanta that had no attempts. 

2421 """ 

2422 

2423 metadata: bytes = b"" 

2424 """Serialized task metadata.""" 

2425 

2426 logs: bytes = b"" 

2427 """Serialized logs.""" 

2428 

2429 is_compressed: bool = False 

2430 """Whether the `quantum`, `metadata`, and `log` attributes are 

2431 compressed. 

2432 """ 

2433 

2434 def compress(self, compressor: Compressor) -> None: 

2435 """Compress the data in this struct if it has not been compressed 

2436 already. 

2437 

2438 Parameters 

2439 ---------- 

2440 compressor : `Compressor` 

2441 Object with a ``compress`` method that takes and returns `bytes`. 

2442 """ 

2443 if not self.is_compressed: 

2444 self.quantum = compressor.compress(self.quantum) 

2445 self.logs = compressor.compress(self.logs) if self.logs else b"" 

2446 self.metadata = compressor.compress(self.metadata) if self.metadata else b"" 

2447 self.is_compressed = True