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

693 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-08 16:40 -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, TypedDict 

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 

105type LoopWrapper[T] = Callable[[Iterable[T]], Iterable[T]] 

106 

107_LOG = getLogger(__file__) 

108 

109DATASET_ADDRESS_INDEX = 0 

110QUANTUM_ADDRESS_INDEX = 1 

111LOG_ADDRESS_INDEX = 2 

112METADATA_ADDRESS_INDEX = 3 

113 

114DATASET_MB_NAME = "datasets" 

115QUANTUM_MB_NAME = "quanta" 

116LOG_MB_NAME = "logs" 

117METADATA_MB_NAME = "metadata" 

118 

119 

120def pass_through[T](arg: T) -> T: 

121 return arg 

122 

123 

124class ProvenanceDatasetInfo(DatasetInfo): 

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

126 node data for a provenance dataset. 

127 

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

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

130 

131 node_data: ProvenanceDatasetInfo = xgraph.nodes[dataset_id] 

132 

133 where ``xgraph`` is `ProvenanceQuantumGraph.bipartite_xgraph`. 

134 """ 

135 

136 dataset_id: uuid.UUID 

137 """Unique identifier for the dataset.""" 

138 

139 produced: bool 

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

141 

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

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

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

145 existence of the dataset. 

146 """ 

147 

148 

149class ProvenanceQuantumInfo(QuantumInfo): 

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

151 node data for a provenance quantum. 

152 

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

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

155 

156 node_data: ProvenanceQuantumInfo = xgraph.nodes[quantum_id] 

157 

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

159 `ProvenanceQuantumGraph.quantum_only_xgraph` 

160 """ 

161 

162 status: QuantumAttemptStatus 

163 """Enumerated status for the quantum. 

164 

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

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

167 """ 

168 

169 caveats: QuantumSuccessCaveats | None 

170 """Flags indicating caveats on successful quanta. 

171 

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

173 """ 

174 

175 exception: ExceptionInfo | None 

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

177 

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

179 """ 

180 

181 resource_usage: QuantumResourceUsage | None 

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

183 

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

185 """ 

186 

187 attempts: list[ProvenanceQuantumAttemptModel] 

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

189 

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

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

192 failure. 

193 """ 

194 

195 metadata_id: uuid.UUID 

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

197 

198 log_id: uuid.UUID 

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

200 

201 

202class ProvenanceInitQuantumInfo(TypedDict): 

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

204 node data for a provenance init quantum. 

205 

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

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

208 

209 node_data: ProvenanceInitQuantumInfo = xgraph.nodes[quantum_id] 

210 

211 where ``xgraph`` is `ProvenanceQuantumGraph.bipartite_xgraph`. 

212 """ 

213 

214 data_id: DataCoordinate 

215 """Data ID of the quantum. 

216 

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

218 regular quanta to be treated more similarly. 

219 """ 

220 

221 task_label: str 

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

223 

224 pipeline_node: TaskInitNode 

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

226 

227 config_id: uuid.UUID 

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

229 

230 

231class ProvenanceDatasetModel(PredictedDatasetModel): 

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

233 

234 produced: bool 

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

236 

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

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

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

240 existence of the dataset. 

241 """ 

242 

243 producer: uuid.UUID | None = None 

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

245 

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

247 """ 

248 

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

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

251 

252 @property 

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

254 """Alias for the dataset ID.""" 

255 return self.dataset_id 

256 

257 @classmethod 

258 def from_predicted( 

259 cls, 

260 predicted: PredictedDatasetModel, 

261 producer: uuid.UUID | None = None, 

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

263 ) -> ProvenanceDatasetModel: 

264 """Construct from a predicted dataset model. 

265 

266 Parameters 

267 ---------- 

268 predicted : `PredictedDatasetModel` 

269 Information about the dataset from the predicted graph. 

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

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

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

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

274 

275 Returns 

276 ------- 

277 provenance : `ProvenanceDatasetModel` 

278 Provenance dataset model. 

279 

280 Notes 

281 ----- 

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

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

284 """ 

285 return cls.model_construct( 

286 dataset_id=predicted.dataset_id, 

287 dataset_type_name=predicted.dataset_type_name, 

288 data_coordinate=predicted.data_coordinate, 

289 run=predicted.run, 

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

291 producer=producer, 

292 consumers=list(consumers), 

293 ) 

294 

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

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

297 

298 Parameters 

299 ---------- 

300 graph : `ProvenanceQuantumGraph` 

301 Graph to update in place. 

302 

303 Notes 

304 ----- 

305 This method adds: 

306 

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

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

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

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

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

312 nodes with no attributes). 

313 """ 

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

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

316 graph._bipartite_xgraph.add_node( 

317 self.dataset_id, 

318 data_id=data_id, 

319 dataset_type_name=self.dataset_type_name, 

320 pipeline_node=dataset_type_node, 

321 run=self.run, 

322 produced=self.produced, 

323 ) 

324 if self.producer is not None: 

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

326 for consumer_id in self.consumers: 

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

328 if self.producer is not None: 

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

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

331 

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

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

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

335 

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

337 """See `pydantic.BaseModel.copy`.""" 

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

339 

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

341 """See `pydantic.BaseModel.model_dump`.""" 

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

343 

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

345 """See `pydantic.BaseModel.model_dump_json`.""" 

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

347 

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

349 """See `pydantic.BaseModel.model_copy`.""" 

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

351 

352 @classmethod 

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

354 """See `pydantic.BaseModel.model_construct`.""" 

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

356 

357 @classmethod 

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

359 """See `pydantic.BaseModel.model_json_schema`.""" 

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

361 

362 @classmethod 

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

364 """See `pydantic.BaseModel.model_validate`.""" 

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

366 

367 @classmethod 

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

369 """See `pydantic.BaseModel.model_validate_json`.""" 

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

371 

372 @classmethod 

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

374 """See `pydantic.BaseModel.model_validate_strings`.""" 

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

376 

377 

378class ProvenanceQuantumAttemptModel(pydantic.BaseModel): 

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

380 provenance quantum graph file. 

381 """ 

382 

383 attempt: int = 0 

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

385 

386 status: QuantumAttemptStatus = QuantumAttemptStatus.UNKNOWN 

387 """Enumerated status for the quantum.""" 

388 

389 caveats: QuantumSuccessCaveats | None = None 

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

391 

392 exception: ExceptionInfo | None = None 

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

394 

395 resource_usage: QuantumResourceUsage | None = None 

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

397 

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

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

400 one. 

401 """ 

402 

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

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

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

406 

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

408 """See `pydantic.BaseModel.copy`.""" 

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

410 

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

412 """See `pydantic.BaseModel.model_dump`.""" 

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

414 

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

416 """See `pydantic.BaseModel.model_dump_json`.""" 

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

418 

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

420 """See `pydantic.BaseModel.model_copy`.""" 

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

422 

423 @classmethod 

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

425 """See `pydantic.BaseModel.model_construct`.""" 

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

427 

428 @classmethod 

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

430 """See `pydantic.BaseModel.model_json_schema`.""" 

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

432 

433 @classmethod 

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

435 """See `pydantic.BaseModel.model_validate`.""" 

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

437 

438 @classmethod 

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

440 """See `pydantic.BaseModel.model_validate_json`.""" 

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

442 

443 @classmethod 

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

445 """See `pydantic.BaseModel.model_validate_strings`.""" 

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

447 

448 

449class ProvenanceLogRecordsModel(pydantic.BaseModel): 

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

451 file. 

452 """ 

453 

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

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

456 to last. 

457 """ 

458 

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

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

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

462 

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

464 """See `pydantic.BaseModel.copy`.""" 

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

466 

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

468 """See `pydantic.BaseModel.model_dump`.""" 

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

470 

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

472 """See `pydantic.BaseModel.model_dump_json`.""" 

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

474 

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

476 """See `pydantic.BaseModel.model_copy`.""" 

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

478 

479 @classmethod 

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

481 """See `pydantic.BaseModel.model_construct`.""" 

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

483 

484 @classmethod 

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

486 """See `pydantic.BaseModel.model_json_schema`.""" 

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

488 

489 @classmethod 

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

491 """See `pydantic.BaseModel.model_validate`.""" 

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

493 

494 @classmethod 

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

496 """See `pydantic.BaseModel.model_validate_json`.""" 

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

498 

499 @classmethod 

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

501 """See `pydantic.BaseModel.model_validate_strings`.""" 

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

503 

504 

505class ProvenanceTaskMetadataModel(pydantic.BaseModel): 

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

507 file. 

508 """ 

509 

510 # We want to convert infs and nans to constants, not null. Unfortunately 

511 # the fact that TaskMetadata _also_ sets this is ignored when that model 

512 # is nested here. 

513 model_config = pydantic.ConfigDict(ser_json_inf_nan="constants") 

514 

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

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

517 first to last. 

518 """ 

519 

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

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

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

523 

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

525 """See `pydantic.BaseModel.copy`.""" 

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

527 

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

529 """See `pydantic.BaseModel.model_dump`.""" 

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

531 

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

533 """See `pydantic.BaseModel.model_dump_json`.""" 

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

535 

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

537 """See `pydantic.BaseModel.model_copy`.""" 

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

539 

540 @classmethod 

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

542 """See `pydantic.BaseModel.model_construct`.""" 

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

544 

545 @classmethod 

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

547 """See `pydantic.BaseModel.model_json_schema`.""" 

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

549 

550 @classmethod 

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

552 """See `pydantic.BaseModel.model_validate`.""" 

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

554 

555 @classmethod 

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

557 """See `pydantic.BaseModel.model_validate_json`.""" 

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

559 

560 @classmethod 

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

562 """See `pydantic.BaseModel.model_validate_strings`.""" 

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

564 

565 

566class ProvenanceQuantumReport(pydantic.BaseModel): 

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

568 (generally problematic) quantum. 

569 """ 

570 

571 quantum_id: uuid.UUID 

572 data_id: dict[str, int | str] 

573 attempts: list[ProvenanceQuantumAttemptModel] 

574 

575 @classmethod 

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

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

578 

579 Parameters 

580 ---------- 

581 quantum_id : `uuid.UUID` 

582 Unique ID for the quantum. 

583 quantum_info : `ProvenanceQuantumInfo` 

584 Node attributes for this quantum. 

585 """ 

586 return cls( 

587 quantum_id=quantum_id, 

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

589 attempts=quantum_info["attempts"], 

590 ) 

591 

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

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

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

595 

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

597 """See `pydantic.BaseModel.copy`.""" 

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

599 

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

601 """See `pydantic.BaseModel.model_dump`.""" 

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

603 

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

605 """See `pydantic.BaseModel.model_dump_json`.""" 

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

607 

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

609 """See `pydantic.BaseModel.model_copy`.""" 

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

611 

612 @classmethod 

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

614 """See `pydantic.BaseModel.model_construct`.""" 

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

616 

617 @classmethod 

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

619 """See `pydantic.BaseModel.model_json_schema`.""" 

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

621 

622 @classmethod 

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

624 """See `pydantic.BaseModel.model_validate`.""" 

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

626 

627 @classmethod 

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

629 """See `pydantic.BaseModel.model_validate_json`.""" 

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

631 

632 @classmethod 

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

634 """See `pydantic.BaseModel.model_validate_strings`.""" 

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

636 

637 

638class ProvenanceReport(pydantic.RootModel): 

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

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

641 """ 

642 

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

644 

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

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

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

648 

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

650 """See `pydantic.BaseModel.copy`.""" 

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

652 

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

654 """See `pydantic.BaseModel.model_dump`.""" 

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

656 

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

658 """See `pydantic.BaseModel.model_dump_json`.""" 

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

660 

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

662 """See `pydantic.BaseModel.model_copy`.""" 

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

664 

665 @classmethod 

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

667 """See `pydantic.BaseModel.model_construct`.""" 

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

669 

670 @classmethod 

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

672 """See `pydantic.BaseModel.model_json_schema`.""" 

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

674 

675 @classmethod 

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

677 """See `pydantic.BaseModel.model_validate`.""" 

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

679 

680 @classmethod 

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

682 """See `pydantic.BaseModel.model_validate_json`.""" 

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

684 

685 @classmethod 

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

687 """See `pydantic.BaseModel.model_validate_strings`.""" 

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

689 

690 

691class ProvenanceQuantumModel(pydantic.BaseModel): 

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

693 

694 quantum_id: uuid.UUID 

695 """Unique identifier for the quantum.""" 

696 

697 task_label: TaskLabel 

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

699 

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

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

702 

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

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

705 connection name. 

706 """ 

707 

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

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

710 connection name. 

711 """ 

712 

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

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

715 chronologically from first to last. 

716 

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

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

719 failure. 

720 """ 

721 

722 @property 

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

724 """Alias for the quantum ID.""" 

725 return self.quantum_id 

726 

727 @classmethod 

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

729 """Construct from a predicted quantum model. 

730 

731 Parameters 

732 ---------- 

733 predicted : `PredictedQuantumDatasetsModel` 

734 Information about the quantum from the predicted graph. 

735 

736 Returns 

737 ------- 

738 provenance : `ProvenanceQuantumModel` 

739 Provenance quantum model. 

740 """ 

741 inputs = { 

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

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

744 } 

745 outputs = { 

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

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

748 } 

749 return cls( 

750 quantum_id=predicted.quantum_id, 

751 task_label=predicted.task_label, 

752 data_coordinate=predicted.data_coordinate, 

753 inputs=inputs, 

754 outputs=outputs, 

755 ) 

756 

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

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

759 

760 Parameters 

761 ---------- 

762 graph : `ProvenanceQuantumGraph` 

763 Graph to update in place. 

764 

765 Notes 

766 ----- 

767 This method adds: 

768 

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

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

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

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

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

774 already been loaded. 

775 """ 

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

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

778 last_attempt = ( 

779 self.attempts[-1] 

780 if self.attempts 

781 else ProvenanceQuantumAttemptModel(status=QuantumAttemptStatus.BLOCKED) 

782 ) 

783 graph._bipartite_xgraph.add_node( 

784 self.quantum_id, 

785 data_id=data_id, 

786 task_label=self.task_label, 

787 pipeline_node=task_node, 

788 status=last_attempt.status, 

789 caveats=last_attempt.caveats, 

790 exception=last_attempt.exception, 

791 resource_usage=last_attempt.resource_usage, 

792 attempts=self.attempts, 

793 ) 

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

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

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

797 read_edge = task_node.get_input_edge(connection_name) 

798 for dataset_id in dataset_ids: 

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

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

801 "pipeline_edges", [] 

802 ).append(read_edge) 

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

804 write_edge = task_node.get_output_edge(connection_name) 

805 if connection_name == acc.METADATA_OUTPUT_CONNECTION_NAME: 

806 graph._bipartite_xgraph.add_node( 

807 dataset_ids[0], 

808 data_id=data_id, 

809 dataset_type_name=write_edge.dataset_type_name, 

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

811 run=graph.header.output_run, 

812 produced=last_attempt.status.has_metadata, 

813 ) 

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

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

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

817 if connection_name == acc.LOG_OUTPUT_CONNECTION_NAME: 

818 graph._bipartite_xgraph.add_node( 

819 dataset_ids[0], 

820 data_id=data_id, 

821 dataset_type_name=write_edge.dataset_type_name, 

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

823 run=graph.header.output_run, 

824 produced=last_attempt.status.has_log, 

825 ) 

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

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

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

829 for dataset_id in dataset_ids: 

830 graph._bipartite_xgraph.add_edge( 

831 self.quantum_id, 

832 dataset_id, 

833 is_read=False, 

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

835 pipeline_edges=[write_edge], 

836 ) 

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

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

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

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

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

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

843 

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

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

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

847 

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

849 """See `pydantic.BaseModel.copy`.""" 

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

851 

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

853 """See `pydantic.BaseModel.model_dump`.""" 

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

855 

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

857 """See `pydantic.BaseModel.model_dump_json`.""" 

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

859 

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

861 """See `pydantic.BaseModel.model_copy`.""" 

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

863 

864 @classmethod 

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

866 """See `pydantic.BaseModel.model_construct`.""" 

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

868 

869 @classmethod 

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

871 """See `pydantic.BaseModel.model_json_schema`.""" 

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

873 

874 @classmethod 

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

876 """See `pydantic.BaseModel.model_validate`.""" 

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

878 

879 @classmethod 

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

881 """See `pydantic.BaseModel.model_validate_json`.""" 

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

883 

884 @classmethod 

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

886 """See `pydantic.BaseModel.model_validate_strings`.""" 

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

888 

889 

890class ProvenanceInitQuantumModel(pydantic.BaseModel): 

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

892 file. 

893 """ 

894 

895 quantum_id: uuid.UUID 

896 """Unique identifier for the quantum.""" 

897 

898 task_label: TaskLabel 

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

900 

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

902 

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

904 """ 

905 

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

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

908 connection name. 

909 """ 

910 

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

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

913 connection name. 

914 """ 

915 

916 @classmethod 

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

918 """Construct from a predicted quantum model. 

919 

920 Parameters 

921 ---------- 

922 predicted : `PredictedQuantumDatasetsModel` 

923 Information about the quantum from the predicted graph. 

924 

925 Returns 

926 ------- 

927 provenance : `ProvenanceInitQuantumModel` 

928 Provenance init quantum model. 

929 """ 

930 inputs = { 

931 connection_name: predicted_inputs[0].dataset_id 

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

933 } 

934 outputs = { 

935 connection_name: predicted_outputs[0].dataset_id 

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

937 } 

938 return cls( 

939 quantum_id=predicted.quantum_id, 

940 task_label=predicted.task_label, 

941 inputs=inputs, 

942 outputs=outputs, 

943 ) 

944 

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

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

947 

948 Parameters 

949 ---------- 

950 graph : `ProvenanceQuantumGraph` 

951 Graph to update in place. 

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

953 The empty data ID for the appropriate dimension universe. 

954 

955 Notes 

956 ----- 

957 This method adds: 

958 

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

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

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

962 """ 

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

964 graph._bipartite_xgraph.add_node( 

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

966 ) 

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

968 read_edge = task_init_node.get_input_edge(connection_name) 

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

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

971 "pipeline_edges", [] 

972 ).append(read_edge) 

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

974 write_edge = task_init_node.get_output_edge(connection_name) 

975 graph._bipartite_xgraph.add_node( 

976 dataset_id, 

977 data_id=empty_data_id, 

978 dataset_type_name=write_edge.dataset_type_name, 

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

980 run=graph.header.output_run, 

981 produced=True, 

982 ) 

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

984 graph._bipartite_xgraph.add_edge( 

985 self.quantum_id, 

986 dataset_id, 

987 is_read=False, 

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

989 pipeline_edges=[write_edge], 

990 ) 

991 if write_edge.connection_name == acc.CONFIG_INIT_OUTPUT_CONNECTION_NAME: 

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

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

994 

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

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

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

998 

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

1000 """See `pydantic.BaseModel.copy`.""" 

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

1002 

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

1004 """See `pydantic.BaseModel.model_dump`.""" 

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

1006 

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

1008 """See `pydantic.BaseModel.model_dump_json`.""" 

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

1010 

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

1012 """See `pydantic.BaseModel.model_copy`.""" 

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

1014 

1015 @classmethod 

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

1017 """See `pydantic.BaseModel.model_construct`.""" 

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

1019 

1020 @classmethod 

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

1022 """See `pydantic.BaseModel.model_json_schema`.""" 

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

1024 

1025 @classmethod 

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

1027 """See `pydantic.BaseModel.model_validate`.""" 

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

1029 

1030 @classmethod 

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

1032 """See `pydantic.BaseModel.model_validate_json`.""" 

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

1034 

1035 @classmethod 

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

1037 """See `pydantic.BaseModel.model_validate_strings`.""" 

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

1039 

1040 

1041class ProvenanceInitQuantaModel(pydantic.RootModel): 

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

1043 

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

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

1046 

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

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

1049 

1050 Parameters 

1051 ---------- 

1052 graph : `ProvenanceQuantumGraph` 

1053 Graph to update in place. 

1054 """ 

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

1056 for init_quantum in self.root: 

1057 init_quantum._add_to_graph(graph, empty_data_id=empty_data_id) 

1058 

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

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

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

1062 

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

1064 """See `pydantic.BaseModel.copy`.""" 

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

1066 

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

1068 """See `pydantic.BaseModel.model_dump`.""" 

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

1070 

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

1072 """See `pydantic.BaseModel.model_dump_json`.""" 

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

1074 

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

1076 """See `pydantic.BaseModel.model_copy`.""" 

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

1078 

1079 @classmethod 

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

1081 """See `pydantic.BaseModel.model_construct`.""" 

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

1083 

1084 @classmethod 

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

1086 """See `pydantic.BaseModel.model_json_schema`.""" 

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

1088 

1089 @classmethod 

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

1091 """See `pydantic.BaseModel.model_validate`.""" 

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

1093 

1094 @classmethod 

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

1096 """See `pydantic.BaseModel.model_validate_json`.""" 

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

1098 

1099 @classmethod 

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

1101 """See `pydantic.BaseModel.model_validate_strings`.""" 

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

1103 

1104 

1105class ProvenanceQuantumGraph(BaseQuantumGraph): 

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

1107 executed. 

1108 

1109 Parameters 

1110 ---------- 

1111 header : `HeaderModel` 

1112 General metadata shared with other quantum graph types. 

1113 pipeline_graph : `.pipeline_graph.PipelineGraph` 

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

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

1116 graph. 

1117 

1118 Notes 

1119 ----- 

1120 A provenance quantum graph is generally obtained via the 

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

1122 as information is read from disk. 

1123 """ 

1124 

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

1126 super().__init__(header, pipeline_graph) 

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

1128 self._quantum_only_xgraph = networkx.DiGraph() 

1129 self._bipartite_xgraph = networkx.DiGraph() 

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

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

1132 } 

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

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

1135 } 

1136 

1137 @classmethod 

1138 @contextmanager 

1139 def from_args( 

1140 cls, 

1141 repo_or_filename: str, 

1142 /, 

1143 collection: str | None = None, 

1144 *, 

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

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

1147 writeable: bool = False, 

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

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

1150 a file or butler-ingested graph dataset. 

1151 

1152 Parameters 

1153 ---------- 

1154 repo_or_filename : `str` 

1155 Either a provenance quantum graph filename or a butler repository 

1156 path or alias. 

1157 collection : `str`, optional 

1158 Collection to search; presence indicates that the first argument 

1159 is a butler repository, not a filename. 

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

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

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

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

1164 writeable : `bool`, optional 

1165 Whether the butler should be constructed with write support. 

1166 

1167 Returns 

1168 ------- 

1169 context : `contextlib.AbstractContextManager` 

1170 A context manager that yields a tuple of 

1171 

1172 - the `ProvenanceQuantumGraph` 

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

1174 

1175 when entered. 

1176 """ 

1177 exit_stack = ExitStack() 

1178 if collection is not None: 

1179 try: 

1180 butler = exit_stack.enter_context( 

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

1182 ) 

1183 except Exception as err: 

1184 err.add_note( 

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

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

1187 ) 

1188 raise 

1189 with exit_stack: 

1190 graph = butler.get( 

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

1192 ) 

1193 yield graph, butler 

1194 else: 

1195 try: 

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

1197 except Exception as err: 

1198 err.add_note( 

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

1200 f"because no collection was provided." 

1201 ) 

1202 raise 

1203 with exit_stack: 

1204 if quanta is None: 

1205 reader.read_quanta() 

1206 elif not quanta: 

1207 reader.read_quanta(quanta) 

1208 if datasets is None: 

1209 reader.read_datasets() 

1210 elif not datasets: 

1211 reader.read_datasets(datasets) 

1212 yield reader.graph, None 

1213 

1214 @property 

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

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

1217 that task. 

1218 

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

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

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

1222 

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

1224 """ 

1225 return self._init_quanta 

1226 

1227 @property 

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

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

1230 data ID. 

1231 

1232 Notes 

1233 ----- 

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

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

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

1237 

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

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

1240 """ 

1241 return self._quanta_by_task_label 

1242 

1243 @property 

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

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

1246 and then by data ID. 

1247 

1248 Notes 

1249 ----- 

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

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

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

1253 

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

1255 

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

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

1258 """ 

1259 return self._datasets_by_type 

1260 

1261 @property 

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

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

1264 

1265 Notes 

1266 ----- 

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

1268 they are loaded. Loading quanta (via 

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

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

1271 attributes. Loading datasets (via 

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

1273 nodes with no attributes. 

1274 

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

1276 

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

1278 

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

1280 """ 

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

1282 

1283 @property 

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

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

1286 

1287 Notes 

1288 ----- 

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

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

1291 `ProvenanceQuantumGraphReader.read_quanta`) or datasets (via 

1292 `ProvenanceQuantumGraphReader.read_datasets`) will load those nodes 

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

1294 Loading quanta is necessary to populate edge attributes. 

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

1296 

1297 Node attributes are described by the 

1298 `ProvenanceQuantumInfo`, `ProvenanceInitQuantumInfo`, and 

1299 `ProvenanceDatasetInfo` types. 

1300 

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

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

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

1304 that would break the "bipartite" property). 

1305 

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

1307 """ 

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

1309 

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

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

1312 quanta. 

1313 

1314 Parameters 

1315 ---------- 

1316 drop_unused_columns : `bool`, optional 

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

1318 occur in this run. 

1319 

1320 Returns 

1321 ------- 

1322 table : `astropy.table.Table` 

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

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

1325 detail. 

1326 

1327 Notes 

1328 ----- 

1329 Success caveats in the table are represented by their 

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

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

1332 be printed as well. 

1333 """ 

1334 rows = [] 

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

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

1337 continue 

1338 status_counts = Counter[QuantumAttemptStatus]( 

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

1340 ) 

1341 caveat_counts = Counter[QuantumSuccessCaveats | None]( 

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

1343 ) 

1344 caveat_counts.pop(QuantumSuccessCaveats.NO_CAVEATS, None) 

1345 caveat_counts.pop(None, None) 

1346 if len(caveat_counts) > 1: 

1347 caveats = "(multiple)" 

1348 elif len(caveat_counts) == 1: 

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

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

1351 # impossible here. 

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

1353 else: 

1354 caveats = "" 

1355 row: dict[str, Any] = { 

1356 "Task": task_label, 

1357 "Caveats": caveats, 

1358 } 

1359 for status in QuantumAttemptStatus: 

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

1361 row.update( 

1362 { 

1363 "TOTAL": len(quanta_for_task), 

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

1365 } 

1366 ) 

1367 rows.append(row) 

1368 table = astropy.table.Table(rows) 

1369 if drop_unused_columns: 

1370 for status in QuantumAttemptStatus: 

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

1372 del table[status.title] 

1373 return table 

1374 

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

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

1377 type raised by each task. 

1378 

1379 Returns 

1380 ------- 

1381 table : `astropy.table.Table` 

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

1383 """ 

1384 rows = [] 

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

1386 success_counts = Counter[str]() 

1387 failed_counts = Counter[str]() 

1388 for quantum_id in quanta_for_task.values(): 

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

1390 exc_info = quantum_info["exception"] 

1391 if exc_info is not None: 

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

1393 success_counts[exc_info.type_name] += 1 

1394 else: 

1395 failed_counts[exc_info.type_name] += 1 

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

1397 rows.append( 

1398 { 

1399 "Task": task_label, 

1400 "Exception": type_name, 

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

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

1403 } 

1404 ) 

1405 return astropy.table.Table(rows) 

1406 

1407 def make_task_resource_usage_table( 

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

1409 ) -> astropy.table.Table: 

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

1411 

1412 Parameters 

1413 ---------- 

1414 task_label : `str` 

1415 Label of the task to extract resource usage for. 

1416 include_data_ids : `bool`, optional 

1417 Whether to also include data ID columns. 

1418 

1419 Returns 

1420 ------- 

1421 table : `astropy.table.Table` 

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

1423 `QuantumResourceUsage`. 

1424 """ 

1425 quanta_for_task = self.quanta_by_task[task_label] 

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

1427 if include_data_ids: 

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

1429 for dimension_name in dimensions.data_coordinate_keys: 

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

1431 dtype_terms.append((dimension_name, dtype)) 

1432 fields = QuantumResourceUsage.get_numpy_fields() 

1433 dtype_terms.extend(fields.items()) 

1434 row_dtype = np.dtype(dtype_terms) 

1435 rows: list[object] = [] 

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

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

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

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

1440 if include_data_ids: 

1441 row += data_id.full_values 

1442 row += resource_usage.get_numpy_row() 

1443 rows.append(row) 

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

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

1446 

1447 def make_status_report( 

1448 self, 

1449 states: Iterable[QuantumAttemptStatus] = ( 

1450 QuantumAttemptStatus.FAILED, 

1451 QuantumAttemptStatus.ABORTED, 

1452 QuantumAttemptStatus.ABORTED_SUCCESS, 

1453 ), 

1454 *, 

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

1456 with_caveats: QuantumSuccessCaveats | None = QuantumSuccessCaveats.PARTIAL_OUTPUTS_ERROR, 

1457 data_id_table_dir: ResourcePathExpression | None = None, 

1458 ) -> ProvenanceReport: 

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

1460 states. 

1461 

1462 Parameters 

1463 ---------- 

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

1465 `..QuantumAttemptStatus`, optional 

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

1467 states that clearly represent problems. 

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

1469 `..QuantumAttemptStatus`, optional 

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

1471 provided so users can easily request additional states while also 

1472 getting the defaults. 

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

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

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

1476 to report on all successful quanta. 

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

1478 optional 

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

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

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

1482 Subdirectories for each task and status will created within this 

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

1484 when there is no exception). 

1485 

1486 Returns 

1487 ------- 

1488 report : `ProvenanceModel` 

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

1490 type. 

1491 """ 

1492 states = set(ensure_iterable(states)) 

1493 states.update(ensure_iterable(also)) 

1494 result = ProvenanceReport(root={}) 

1495 if data_id_table_dir is not None: 

1496 data_id_table_dir = ResourcePath(data_id_table_dir) 

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

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

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

1500 for quantum_id in quanta_for_task.values(): 

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

1502 quantum_status = quantum_info["status"] 

1503 if quantum_status not in states: 

1504 continue 

1505 if ( 

1506 quantum_status is QuantumAttemptStatus.SUCCESSFUL 

1507 and with_caveats is not None 

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

1509 ): 

1510 continue 

1511 key1 = quantum_status.name 

1512 exc_info = quantum_info["exception"] 

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

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

1515 ProvenanceQuantumReport.from_info(quantum_id, quantum_info) 

1516 ) 

1517 if data_id_table_dir: 

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

1519 quantum_info["data_id"].required_values 

1520 ) 

1521 if reports_for_task: 

1522 result.root[task_label] = reports_for_task 

1523 if table_rows_for_task: 

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

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

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

1527 status_name, forceDirectory=True 

1528 ) 

1529 if dir_for_task_and_status.isLocal: 

1530 dir_for_task_and_status.mkdir() 

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

1532 table = astropy.table.Table( 

1533 rows=data_id_rows, 

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

1535 ) 

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

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

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

1539 return result 

1540 

1541 def make_many_reports( 

1542 self, 

1543 states: Iterable[QuantumAttemptStatus] = ( 

1544 QuantumAttemptStatus.FAILED, 

1545 QuantumAttemptStatus.ABORTED, 

1546 QuantumAttemptStatus.ABORTED_SUCCESS, 

1547 ), 

1548 *, 

1549 status_report_file: ResourcePathExpression | None = None, 

1550 print_quantum_table: bool = False, 

1551 print_exception_table: bool = False, 

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

1553 with_caveats: QuantumSuccessCaveats | None = None, 

1554 data_id_table_dir: ResourcePathExpression | None = None, 

1555 ) -> None: 

1556 """Write multiple reports. 

1557 

1558 Parameters 

1559 ---------- 

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

1561 `..QuantumAttemptStatus`, optional 

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

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

1564 problems. 

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

1566 optional 

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

1568 print_quantum_table : `bool`, optional 

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

1570 print_exception_table : `bool`, optional 

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

1572 STDOUT. 

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

1574 `..QuantumAttemptStatus`, optional 

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

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

1577 easily request additional states while also getting the defaults. 

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

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

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

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

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

1583 automatically included in ``states``. 

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

1585 optional 

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

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

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

1589 Subdirectories for each task and status will created within this 

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

1591 when there is no exception). 

1592 """ 

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

1594 status_report = self.make_status_report( 

1595 states, also=also, with_caveats=with_caveats, data_id_table_dir=data_id_table_dir 

1596 ) 

1597 if status_report_file is not None: 

1598 status_report_file = ResourcePath(status_report_file) 

1599 if status_report_file.isLocal: 

1600 status_report_file.dirname().mkdir() 

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

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

1603 if print_quantum_table: 

1604 quantum_table = self.make_quantum_table() 

1605 quantum_table.pprint_all() 

1606 print("") 

1607 if print_exception_table: 

1608 exception_table = self.make_exception_table() 

1609 exception_table.pprint_all() 

1610 print("") 

1611 

1612 

1613@dataclasses.dataclass 

1614class ProvenanceQuantumGraphReader(BaseQuantumGraphReader): 

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

1616 

1617 Notes 

1618 ----- 

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

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

1621 the `graph` attribute`. 

1622 

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

1624 in place. 

1625 """ 

1626 

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

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

1629 

1630 @classmethod 

1631 @contextmanager 

1632 def open( 

1633 cls, 

1634 uri: ResourcePathExpression, 

1635 *, 

1636 page_size: int | None = None, 

1637 import_mode: TaskImportMode = TaskImportMode.DO_NOT_IMPORT, 

1638 ) -> Iterator[ProvenanceQuantumGraphReader]: 

1639 """Construct a reader from a URI. 

1640 

1641 Parameters 

1642 ---------- 

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

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

1645 page_size : `int`, optional 

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

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

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

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

1650 import_mode : `.pipeline_graph.TaskImportMode`, optional 

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

1652 graph. 

1653 

1654 Returns 

1655 ------- 

1656 reader : `contextlib.AbstractContextManager` [ \ 

1657 `ProvenanceQuantumGraphReader` ] 

1658 A context manager that returns the reader when entered. 

1659 """ 

1660 with cls._open( 

1661 uri, 

1662 graph_type="provenance", 

1663 address_filename="nodes", 

1664 page_size=page_size, 

1665 import_mode=import_mode, 

1666 n_addresses=4, 

1667 ) as self: 

1668 yield self 

1669 

1670 def __post_init__(self) -> None: 

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

1672 

1673 def read_init_quanta(self) -> None: 

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

1675 quanta by task label. 

1676 """ 

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

1678 for init_quantum in init_quanta.root: 

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

1680 init_quanta._add_to_graph(self.graph) 

1681 

1682 def read_full_graph(self) -> None: 

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

1684 attributes, fully populating the `graph` attribute. 

1685 

1686 Notes 

1687 ----- 

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

1689 fetched explicitly. 

1690 """ 

1691 self.read_init_quanta() 

1692 self.read_datasets() 

1693 self.read_quanta() 

1694 

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

1696 """Read information about the given datasets. 

1697 

1698 Parameters 

1699 ---------- 

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

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

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

1703 """ 

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

1705 

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

1707 """Read information about the given quanta. 

1708 

1709 Parameters 

1710 ---------- 

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

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

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

1714 quanta will be ignored. 

1715 """ 

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

1717 

1718 def _read_nodes( 

1719 self, 

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

1721 address_index: int, 

1722 mb_name: str, 

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

1724 ) -> None: 

1725 node: ProvenanceDatasetModel | ProvenanceQuantumModel | None 

1726 if nodes is None: 

1727 self.address_reader.read_all() 

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

1729 for node in MultiblockReader.read_all_models_in_zip( 

1730 self.zf, 

1731 mb_name, 

1732 model_type, 

1733 self.decompressor, 

1734 int_size=self.header.int_size, 

1735 page_size=self.page_size, 

1736 ): 

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

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

1739 # also have other outstanding reference holders). 

1740 continue 

1741 node._add_to_graph(self.graph) 

1742 else: 

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

1744 for node_id_or_index in nodes: 

1745 address_row = self.address_reader.find(node_id_or_index) 

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

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

1748 # might also have other outstanding reference holders). 

1749 continue 

1750 node = mb_reader.read_model( 

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

1752 ) 

1753 if node is not None: 

1754 node._add_to_graph(self.graph) 

1755 

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

1757 """Fetch log datasets. 

1758 

1759 Parameters 

1760 ---------- 

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

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

1763 correspond to. 

1764 

1765 Returns 

1766 ------- 

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

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

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

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

1771 execution attempts, ordered chronologically from first to last. 

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

1773 """ 

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

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

1776 for node_id_or_index in nodes: 

1777 address_row = self.address_reader.find(node_id_or_index) 

1778 logs_by_attempt = mb_reader.read_model( 

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

1780 ) 

1781 if logs_by_attempt is not None: 

1782 result[node_id_or_index] = [ 

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

1784 for attempt_logs in logs_by_attempt.attempts 

1785 ] 

1786 return result 

1787 

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

1789 """Fetch metadata datasets. 

1790 

1791 Parameters 

1792 ---------- 

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

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

1795 correspond to. 

1796 

1797 Returns 

1798 ------- 

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

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

1801 `.TaskMetadata` instances representing different execution 

1802 attempts, ordered chronologically from first to last. Attempts 

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

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

1805 """ 

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

1807 with MultiblockReader.open_in_zip( 

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

1809 ) as mb_reader: 

1810 for node_id_or_index in nodes: 

1811 address_row = self.address_reader.find(node_id_or_index) 

1812 metadata_by_attempt = mb_reader.read_model( 

1813 address_row.addresses[METADATA_ADDRESS_INDEX], 

1814 ProvenanceTaskMetadataModel, 

1815 self.decompressor, 

1816 ) 

1817 if metadata_by_attempt is not None: 

1818 result[node_id_or_index] = metadata_by_attempt.attempts 

1819 return result 

1820 

1821 def fetch_packages(self) -> Packages: 

1822 """Fetch package version information.""" 

1823 data = self._read_single_block_raw("packages") 

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

1825 

1826 

1827class ProvenanceQuantumGraphWriter: 

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

1829 provenance quantum graph. 

1830 

1831 Parameters 

1832 ---------- 

1833 output_path : `str` 

1834 Path to write the graph to. 

1835 exit_stack : `contextlib.ExitStack` 

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

1837 log_on_close : `LogOnClose` 

1838 Factory for context managers that log when closed. 

1839 predicted : `.PredictedQuantumGraphComponents` 

1840 Components of the predicted graph. 

1841 zstd_level : `int`, optional 

1842 Compression level. 

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

1844 Bytes representation of the compression dictionary used by the 

1845 compressor. 

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

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

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

1849 reporting or check for cancelation signals. 

1850 log : `LsstLogAdapter`, optional 

1851 Logger to use for debug messages. 

1852 """ 

1853 

1854 def __init__( 

1855 self, 

1856 output_path: str, 

1857 *, 

1858 exit_stack: ExitStack, 

1859 log_on_close: LogOnClose, 

1860 predicted: PredictedQuantumGraphComponents | PredictedQuantumGraph, 

1861 zstd_level: int = 10, 

1862 cdict_data: bytes | None = None, 

1863 loop_wrapper: LoopWrapper = pass_through, 

1864 log: LsstLogAdapter | None = None, 

1865 ) -> None: 

1866 header = predicted.header.model_copy() 

1867 header.graph_type = "provenance" 

1868 if log is None: 

1869 log = _LOG 

1870 self.log = log 

1871 self._base_writer = exit_stack.enter_context( 

1872 log_on_close.wrap( 

1873 BaseQuantumGraphWriter.open( 

1874 output_path, 

1875 header, 

1876 predicted.pipeline_graph, 

1877 address_filename="nodes", 

1878 zstd_level=zstd_level, 

1879 cdict_data=cdict_data, 

1880 ), 

1881 "Finishing writing provenance quantum graph.", 

1882 ) 

1883 ) 

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

1885 self._log_writer = exit_stack.enter_context( 

1886 log_on_close.wrap( 

1887 MultiblockWriter.open_in_zip( 

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

1889 ), 

1890 "Copying logs into zip archive.", 

1891 ), 

1892 ) 

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

1894 self._metadata_writer = exit_stack.enter_context( 

1895 log_on_close.wrap( 

1896 MultiblockWriter.open_in_zip( 

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

1898 ), 

1899 "Copying metadata into zip archive.", 

1900 ) 

1901 ) 

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

1903 self._dataset_writer = exit_stack.enter_context( 

1904 log_on_close.wrap( 

1905 MultiblockWriter.open_in_zip( 

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

1907 ), 

1908 "Copying dataset provenance into zip archive.", 

1909 ) 

1910 ) 

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

1912 self._quantum_writer = exit_stack.enter_context( 

1913 log_on_close.wrap( 

1914 MultiblockWriter.open_in_zip( 

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

1916 ), 

1917 "Copying quantum provenance into zip archive.", 

1918 ) 

1919 ) 

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

1921 self._init_predicted_quanta(predicted) 

1922 self._populate_xgraph_and_inputs(loop_wrapper) 

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

1924 

1925 def _init_predicted_quanta( 

1926 self, predicted: PredictedQuantumGraph | PredictedQuantumGraphComponents 

1927 ) -> None: 

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

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

1930 if isinstance(predicted, PredictedQuantumGraph): 

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

1932 self._predicted_quanta.update(predicted._quantum_datasets) 

1933 else: 

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

1935 self._predicted_quanta.update(predicted.quantum_datasets) 

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

1937 

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

1939 self._xgraph = networkx.DiGraph() 

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

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

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

1943 if not predicted_quantum.task_label: 

1944 # Skip the 'packages' producer quantum. 

1945 continue 

1946 output_dataset_ids.update(predicted_quantum.iter_output_dataset_ids()) 

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

1948 if not predicted_quantum.task_label: 

1949 # Skip the 'packages' producer quantum. 

1950 continue 

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

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

1953 if predicted_input.dataset_id not in output_dataset_ids: 

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

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

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

1957 

1958 @property 

1959 def compressor(self) -> Compressor: 

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

1961 return self._base_writer.compressor 

1962 

1963 def write_packages(self) -> None: 

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

1965 packages = Packages.fromSystem(include_all=True) 

1966 data = packages.toBytes("json") 

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

1968 

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

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

1971 

1972 Parameters 

1973 ---------- 

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

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

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

1977 progress reporting or check for cancelation signals. 

1978 """ 

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

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

1981 self._dataset_writer.write_model( 

1982 predicted_input.dataset_id, 

1983 ProvenanceDatasetModel.from_predicted( 

1984 predicted_input, 

1985 producer=None, 

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

1987 ), 

1988 self.compressor, 

1989 ) 

1990 del self._overall_inputs 

1991 

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

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

1994 

1995 Parameters 

1996 ---------- 

1997 assume_existence : `bool`, optional 

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

1999 """ 

2000 init_quanta = ProvenanceInitQuantaModel() 

2001 for predicted_init_quantum in self._predicted_init_quanta: 

2002 if not predicted_init_quantum.task_label: 

2003 # Skip the 'packages' producer quantum. 

2004 continue 

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

2006 provenance_output = ProvenanceDatasetModel.from_predicted( 

2007 predicted_output, 

2008 producer=predicted_init_quantum.quantum_id, 

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

2010 ) 

2011 provenance_output.produced = assume_existence or ( 

2012 provenance_output.dataset_id in self._existing_init_outputs 

2013 ) 

2014 self._dataset_writer.write_model( 

2015 provenance_output.dataset_id, provenance_output, self.compressor 

2016 ) 

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

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

2019 

2020 def write_quantum_provenance( 

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

2022 ) -> None: 

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

2024 

2025 Parameters 

2026 ---------- 

2027 quantum_id : `uuid.UUID` 

2028 Unique ID for the quantum. 

2029 metadata : `..TaskMetadata` or `None` 

2030 Task metadata. 

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

2032 Task logs. 

2033 """ 

2034 predicted_quantum = self._predicted_quanta[quantum_id] 

2035 provenance_models = ProvenanceQuantumScanModels.from_metadata_and_logs( 

2036 predicted_quantum, metadata, logs, incomplete=False 

2037 ) 

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

2039 self.write_scan_data(scan_data) 

2040 

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

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

2043 

2044 Parameters 

2045 ---------- 

2046 scan_data : `ProvenanceQuantumScanData` 

2047 Result of a quantum provenance scan. 

2048 """ 

2049 if scan_data.status is ProvenanceQuantumScanStatus.INIT: 

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

2051 self._existing_init_outputs.update(scan_data.existing_outputs) 

2052 return 

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

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

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

2056 # done. 

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

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

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

2060 provenance_output = ProvenanceDatasetModel.from_predicted( 

2061 predicted_output, 

2062 producer=predicted_quantum.quantum_id, 

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

2064 ) 

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

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

2067 provenance_output.model_dump_json().encode() 

2068 ) 

2069 if not scan_data.quantum: 

2070 scan_data.quantum = ( 

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

2072 ) 

2073 if scan_data.is_compressed: 

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

2075 if not scan_data.is_compressed: 

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

2077 if scan_data.metadata: 

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

2079 if scan_data.logs: 

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

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

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

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

2084 self._dataset_writer.write_bytes(dataset_id, dataset_data) 

2085 if scan_data.metadata: 

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

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

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

2089 if scan_data.logs: 

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

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

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

2093 

2094 

2095class ProvenanceQuantumScanStatus(enum.Enum): 

2096 """Status enum for quantum scanning. 

2097 

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

2099 from the status of the quantum's execution. 

2100 """ 

2101 

2102 INCOMPLETE = enum.auto() 

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

2104 conclusively yet. 

2105 """ 

2106 

2107 ABANDONED = enum.auto() 

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

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

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

2111 to stop trying for now. 

2112 

2113 This state means `ProvenanceQuantumScanModels.from_metadata_and_logs` must 

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

2115 """ 

2116 

2117 SUCCESSFUL = enum.auto() 

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

2119 unblocking scans for downstream quanta. 

2120 """ 

2121 

2122 FAILED = enum.auto() 

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

2124 scans for downstream quanta. 

2125 """ 

2126 

2127 BLOCKED = enum.auto() 

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

2129 

2130 INIT = enum.auto() 

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

2132 metadata. 

2133 """ 

2134 

2135 

2136@dataclasses.dataclass 

2137class ProvenanceQuantumScanModels: 

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

2139 

2140 quantum_id: uuid.UUID 

2141 """Unique ID for the quantum.""" 

2142 

2143 status: ProvenanceQuantumScanStatus = ProvenanceQuantumScanStatus.INCOMPLETE 

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

2145 

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

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

2148 

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

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

2151 produced. 

2152 """ 

2153 

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

2155 """Task metadata information for each attempt. 

2156 """ 

2157 

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

2159 """Log records for each attempt. 

2160 """ 

2161 

2162 @classmethod 

2163 def from_metadata_and_logs( 

2164 cls, 

2165 predicted: PredictedQuantumDatasetsModel, 

2166 metadata: TaskMetadata | None, 

2167 logs: ButlerLogRecords | None, 

2168 *, 

2169 incomplete: bool = False, 

2170 ) -> ProvenanceQuantumScanModels: 

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

2172 

2173 Parameters 

2174 ---------- 

2175 predicted : `PredictedQuantumDatasetsModel` 

2176 Information about the predicted quantum. 

2177 metadata : `..TaskMetadata` or `None` 

2178 Task metadata. 

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

2180 Task logs. 

2181 incomplete : `bool`, optional 

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

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

2184 `ProvenanceQuantumScanStatus.ABANDONED` and return. 

2185 

2186 Returns 

2187 ------- 

2188 scan_models : `ProvenanceQuantumScanModels` 

2189 Struct of models that describe quantum provenance. 

2190 

2191 Notes 

2192 ----- 

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

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

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

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

2197 """ 

2198 self = ProvenanceQuantumScanModels(predicted.quantum_id) 

2199 last_attempt = ProvenanceQuantumAttemptModel() 

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

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

2202 if self.status is ProvenanceQuantumScanStatus.ABANDONED: 

2203 return self 

2204 self._reconcile_attempts(last_attempt) 

2205 self._extract_output_existence(predicted) 

2206 return self 

2207 

2208 def _process_logs( 

2209 self, 

2210 predicted: PredictedQuantumDatasetsModel, 

2211 logs: ButlerLogRecords | None, 

2212 last_attempt: ProvenanceQuantumAttemptModel, 

2213 *, 

2214 incomplete: bool, 

2215 ) -> None: 

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

2217 if logs is None: 

2218 self.output_existence[predicted_log_dataset.dataset_id] = False 

2219 if incomplete: 

2220 self.status = ProvenanceQuantumScanStatus.ABANDONED 

2221 else: 

2222 self.status = ProvenanceQuantumScanStatus.FAILED 

2223 else: 

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

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

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

2227 # when we look for metadata next. 

2228 last_attempt.status = QuantumAttemptStatus.FAILED 

2229 self.output_existence[predicted_log_dataset.dataset_id] = True 

2230 if logs.extra: 

2231 log_extra = _ExecutionLogRecordsExtra.model_validate(logs.extra) 

2232 self._extract_from_log_extra(log_extra, last_attempt=last_attempt) 

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

2234 

2235 def _extract_from_log_extra( 

2236 self, 

2237 log_extra: _ExecutionLogRecordsExtra, 

2238 last_attempt: ProvenanceQuantumAttemptModel | None, 

2239 ) -> None: 

2240 for previous_attempt_log_extra in log_extra.previous_attempts: 

2241 self._extract_from_log_extra( 

2242 previous_attempt_log_extra, 

2243 last_attempt=None, 

2244 ) 

2245 quantum_attempt: ProvenanceQuantumAttemptModel 

2246 if last_attempt is None: 

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

2248 quantum_attempt = ProvenanceQuantumAttemptModel( 

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

2250 ) 

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

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

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

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

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

2256 self.attempts.append(quantum_attempt) 

2257 else: 

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

2259 quantum_attempt = last_attempt 

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

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

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

2263 # provenance in the logs. 

2264 quantum_attempt.exception = log_extra.exception 

2265 if log_extra.metadata is not None: 

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

2267 self.metadata.attempts.append(log_extra.metadata) 

2268 else: 

2269 self.metadata.attempts.append(None) 

2270 # Regardless of whether this is the last attempt or not, we can only 

2271 # get the previous_process_quanta from the log extra. 

2272 quantum_attempt.previous_process_quanta.extend(log_extra.previous_process_quanta) 

2273 

2274 def _process_metadata( 

2275 self, 

2276 predicted: PredictedQuantumDatasetsModel, 

2277 metadata: TaskMetadata | None, 

2278 last_attempt: ProvenanceQuantumAttemptModel, 

2279 *, 

2280 incomplete: bool, 

2281 ) -> None: 

2282 (predicted_metadata_dataset,) = predicted.outputs[acc.METADATA_OUTPUT_CONNECTION_NAME] 

2283 if metadata is None: 

2284 self.output_existence[predicted_metadata_dataset.dataset_id] = False 

2285 if incomplete: 

2286 self.status = ProvenanceQuantumScanStatus.ABANDONED 

2287 else: 

2288 self.status = ProvenanceQuantumScanStatus.FAILED 

2289 else: 

2290 self.status = ProvenanceQuantumScanStatus.SUCCESSFUL 

2291 self.output_existence[predicted_metadata_dataset.dataset_id] = True 

2292 last_attempt.status = QuantumAttemptStatus.SUCCESSFUL 

2293 try: 

2294 # Int conversion guards against spurious conversion to 

2295 # float that can apparently sometimes happen in 

2296 # TaskMetadata. 

2297 last_attempt.caveats = QuantumSuccessCaveats(int(metadata["quantum"]["caveats"])) 

2298 except LookupError: 

2299 pass 

2300 try: 

2301 last_attempt.exception = ExceptionInfo._from_metadata( 

2302 metadata[predicted.task_label]["failure"] 

2303 ) 

2304 except LookupError: 

2305 pass 

2306 last_attempt.resource_usage = QuantumResourceUsage.from_task_metadata(metadata) 

2307 self.metadata.attempts.append(metadata) 

2308 

2309 def _reconcile_attempts(self, last_attempt: ProvenanceQuantumAttemptModel) -> None: 

2310 last_attempt.attempt = len(self.attempts) 

2311 self.attempts.append(last_attempt) 

2312 assert self.status is not ProvenanceQuantumScanStatus.INCOMPLETE 

2313 assert self.status is not ProvenanceQuantumScanStatus.ABANDONED 

2314 if len(self.logs.attempts) < len(self.attempts): 

2315 # Logs were not found for this attempt; must have been a hard error 

2316 # that kept the `finally` block from running or otherwise 

2317 # interrupted the writing of the logs. 

2318 self.logs.attempts.append(None) 

2319 if self.status is ProvenanceQuantumScanStatus.SUCCESSFUL: 

2320 # But we found the metadata! Either that hard error happened 

2321 # at a very unlucky time (in between those two writes), or 

2322 # something even weirder happened. 

2323 self.attempts[-1].status = QuantumAttemptStatus.ABORTED_SUCCESS 

2324 else: 

2325 self.attempts[-1].status = QuantumAttemptStatus.FAILED 

2326 if len(self.metadata.attempts) < len(self.attempts): 

2327 # Metadata missing usually just means a failure. In any case, the 

2328 # status will already be correct, either because it was set to a 

2329 # failure when we read the logs, or left at UNKNOWN if there were 

2330 # no logs. Note that scanners never process BLOCKED quanta at all. 

2331 self.metadata.attempts.append(None) 

2332 assert len(self.logs.attempts) == len(self.attempts) or len(self.metadata.attempts) == len( 

2333 self.attempts 

2334 ), ( 

2335 "The only way we can add more than one quantum attempt is by " 

2336 "extracting info stored with the logs, and that always appends " 

2337 "a log attempt and a metadata attempt, so this must be a bug in " 

2338 "this class." 

2339 ) 

2340 

2341 def _extract_output_existence(self, predicted: PredictedQuantumDatasetsModel) -> None: 

2342 try: 

2343 outputs_put = self.metadata.attempts[-1]["quantum"].getArray("outputs") # type: ignore[index] 

2344 except ( 

2345 IndexError, # metadata.attempts is empty 

2346 TypeError, # metadata.attempts[-1] is None 

2347 LookupError, # no 'quantum' entry in metadata or 'outputs' in that 

2348 ): 

2349 pass 

2350 else: 

2351 for id_str in ensure_iterable(outputs_put): 

2352 self.output_existence[uuid.UUID(id_str)] = True 

2353 # If the metadata told us what it wrote, anything not in that 

2354 # list was not written. 

2355 for predicted_output in itertools.chain.from_iterable(predicted.outputs.values()): 

2356 self.output_existence.setdefault(predicted_output.dataset_id, False) 

2357 

2358 def to_scan_data( 

2359 self: ProvenanceQuantumScanModels, 

2360 predicted_quantum: PredictedQuantumDatasetsModel, 

2361 compressor: Compressor | None = None, 

2362 ) -> ProvenanceQuantumScanData: 

2363 """Convert these models to JSON data. 

2364 

2365 Parameters 

2366 ---------- 

2367 predicted_quantum : `PredictedQuantumDatasetsModel` 

2368 Information about the predicted quantum. 

2369 compressor : `Compressor` 

2370 Object that can compress bytes. 

2371 

2372 Returns 

2373 ------- 

2374 scan_data : `ProvenanceQuantumScanData` 

2375 Scan information ready for serialization. 

2376 """ 

2377 quantum: ProvenanceInitQuantumModel | ProvenanceQuantumModel 

2378 if self.status is ProvenanceQuantumScanStatus.INIT: 

2379 quantum = ProvenanceInitQuantumModel.from_predicted(predicted_quantum) 

2380 else: 

2381 quantum = ProvenanceQuantumModel.from_predicted(predicted_quantum) 

2382 quantum.attempts = self.attempts 

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

2384 if predicted_output.dataset_id not in self.output_existence: 

2385 raise RuntimeError( 

2386 "Logic bug in provenance gathering or execution invariants: " 

2387 f"no existence information for output {predicted_output.dataset_id} " 

2388 f"({predicted_output.dataset_type_name}@{predicted_output.data_coordinate})." 

2389 ) 

2390 data = ProvenanceQuantumScanData( 

2391 self.quantum_id, 

2392 self.status, 

2393 existing_outputs={ 

2394 dataset_id for dataset_id, was_produced in self.output_existence.items() if was_produced 

2395 }, 

2396 quantum=quantum.model_dump_json().encode(), 

2397 logs=self.logs.model_dump_json().encode() if self.logs.attempts else b"", 

2398 metadata=self.metadata.model_dump_json().encode() if self.metadata.attempts else b"", 

2399 ) 

2400 if compressor is not None: 

2401 data.compress(compressor) 

2402 return data 

2403 

2404 

2405@dataclasses.dataclass 

2406class ProvenanceQuantumScanData: 

2407 """A struct that represents ready-for-serialization provenance information 

2408 for a single quantum. 

2409 """ 

2410 

2411 quantum_id: uuid.UUID 

2412 """Unique ID for the quantum.""" 

2413 

2414 status: ProvenanceQuantumScanStatus 

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

2416 

2417 existing_outputs: set[uuid.UUID] = dataclasses.field(default_factory=set) 

2418 """Unique IDs of the output datasets that were actually written.""" 

2419 

2420 quantum: bytes = b"" 

2421 """Serialized quantum provenance model. 

2422 

2423 This may be empty for quanta that had no attempts. 

2424 """ 

2425 

2426 metadata: bytes = b"" 

2427 """Serialized task metadata.""" 

2428 

2429 logs: bytes = b"" 

2430 """Serialized logs.""" 

2431 

2432 is_compressed: bool = False 

2433 """Whether the ``quantum``, ``metadata``, and ``log`` attributes are 

2434 compressed. 

2435 """ 

2436 

2437 def compress(self, compressor: Compressor) -> None: 

2438 """Compress the data in this struct if it has not been compressed 

2439 already. 

2440 

2441 Parameters 

2442 ---------- 

2443 compressor : `Compressor` 

2444 Object with a ``compress`` method that takes and returns `bytes`. 

2445 """ 

2446 if not self.is_compressed: 

2447 self.quantum = compressor.compress(self.quantum) 

2448 self.logs = compressor.compress(self.logs) if self.logs else b"" 

2449 self.metadata = compressor.compress(self.metadata) if self.metadata else b"" 

2450 self.is_compressed = True