Coverage for tests / test_aggregator.py: 12%

503 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-03 01:30 -0800

1# This file is part of pipe_base. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://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 <https://www.gnu.org/licenses/>. 

27 

28from __future__ import annotations 

29 

30import dataclasses 

31import itertools 

32import os 

33import tempfile 

34import time 

35import unittest.mock 

36import uuid 

37from collections.abc import Iterator 

38from contextlib import contextmanager 

39from typing import Any, cast 

40 

41import astropy.table 

42import click.testing 

43import numpy as np 

44import pydantic 

45from click.testing import CliRunner, Result 

46 

47import lsst.utils.tests 

48from lsst.daf.butler import Butler, ButlerLogRecords, QuantumBackedButler 

49from lsst.pex.config import Config 

50from lsst.pipe.base import ( 

51 AlgorithmError, 

52 QuantumAttemptStatus, 

53 QuantumSuccessCaveats, 

54 TaskMetadata, 

55) 

56from lsst.pipe.base import automatic_connection_constants as acc 

57from lsst.pipe.base.cli.cmd.commands import ( 

58 aggregate_graph as aggregate_graph_cli, 

59) 

60from lsst.pipe.base.cli.cmd.commands import ( 

61 provenance_report as provenance_report_cli, 

62) 

63from lsst.pipe.base.graph_walker import GraphWalker 

64from lsst.pipe.base.pipeline_graph import Edge 

65from lsst.pipe.base.quantum_graph import ( 

66 FORMAT_VERSION, 

67 PredictedDatasetInfo, 

68 PredictedQuantumGraph, 

69 PredictedQuantumInfo, 

70 ProvenanceDatasetInfo, 

71 ProvenanceQuantumGraph, 

72 ProvenanceQuantumInfo, 

73 ProvenanceQuantumReport, 

74 ProvenanceReport, 

75) 

76from lsst.pipe.base.quantum_graph.aggregator import AggregatorConfig, FatalWorkerError, aggregate_graph 

77from lsst.pipe.base.quantum_graph.ingest_graph import ingest_graph 

78from lsst.pipe.base.resource_usage import QuantumResourceUsage 

79from lsst.pipe.base.single_quantum_executor import SingleQuantumExecutor 

80from lsst.pipe.base.tests.mocks import ( 

81 DirectButlerRepo, 

82 DynamicConnectionConfig, 

83 DynamicTestPipelineTaskConfig, 

84) 

85from lsst.pipe.base.tests.util import patch_deterministic_uuid4 

86from lsst.utils.packages import Packages 

87 

88 

89@dataclasses.dataclass 

90class PrepInfo: 

91 """Struct of objects used in an aggregator test.""" 

92 

93 butler: Butler 

94 butler_path: str 

95 predicted: PredictedQuantumGraph 

96 predicted_path: str 

97 config: AggregatorConfig 

98 

99 

100class AggregatorTestCase(unittest.TestCase): 

101 """Unit tests for `lsst.pipe.base.quantum_graph.aggregator`.""" 

102 

103 @staticmethod 

104 @contextmanager 

105 def make_test_repo() -> Iterator[PrepInfo]: 

106 """Make a test data repository and predicted quantum graph. 

107 

108 Returns 

109 ------- 

110 prep_info : `PrepInfo` 

111 Objects used in aggregator tests. 

112 

113 Notes 

114 ----- 

115 The pipeline graph used by this task looks like this: 

116 

117 ■ calibrate: {detector, visit} 

118 ╭─┤ 

119 ■ │ consolidate: {visit} 

120 │ 

121 ■ resample: {patch, visit} 

122 │ 

123 ■ coadd: {band, patch} 

124 

125 The data can be visualized via:: 

126 

127 python -m lsst.daf.butler.tests.registry_data.spatial 

128 

129 One of the 'calibrate' quanta (visit=2, detector=2) is configured to 

130 fail with `lsst.pipe.base.AnnotatedPartialOutputsError`. This lets us 

131 test both success-with-caveats and failures, depending on how we 

132 configure the executor. This ``{visit: 2, detector: 2}`` data ID is 

133 the only one that overlaps ``{tract: 1, patch: 1}`` and 

134 ``{tract: 0, patch: 5}``, so it should chain to the 'resample' and 

135 'coadd' tasks, too. 

136 """ 

137 with patch_deterministic_uuid4(100): 

138 with DirectButlerRepo.make_temporary("base.yaml", "spatial.yaml") as (helper, root): 

139 calibrate_config = DynamicTestPipelineTaskConfig() 

140 calibrate_config.fail_exception = "lsst.pipe.base.AnnotatedPartialOutputsError" 

141 calibrate_config.fail_condition = "visit=2 AND detector=2" 

142 helper.add_task( 

143 "calibrate", 

144 config=calibrate_config, 

145 dimensions=["visit", "detector"], 

146 inputs={ 

147 "input_image": DynamicConnectionConfig( 

148 dataset_type_name="raw", 

149 dimensions=["visit", "detector"], 

150 ) 

151 }, 

152 prerequisite_inputs={ 

153 "refcat": DynamicConnectionConfig( 

154 dataset_type_name="references", 

155 dimensions=["htm7"], 

156 multiple=True, 

157 ) 

158 }, 

159 init_outputs={ 

160 "output_schema": DynamicConnectionConfig( 

161 dataset_type_name="source_schema", 

162 ) 

163 }, 

164 outputs={ 

165 "output_image": DynamicConnectionConfig( 

166 dataset_type_name="image", 

167 dimensions=["visit", "detector"], 

168 ), 

169 "output_table": DynamicConnectionConfig( 

170 dataset_type_name="source_detector", 

171 dimensions=["visit", "detector"], 

172 ), 

173 }, 

174 ) 

175 helper.add_task( 

176 "consolidate", 

177 dimensions=["visit"], 

178 init_inputs={ 

179 "input_schema": DynamicConnectionConfig( 

180 dataset_type_name="source_schema", 

181 ) 

182 }, 

183 inputs={ 

184 "input_table": DynamicConnectionConfig( 

185 dataset_type_name="source_detector", 

186 dimensions=["visit", "detector"], 

187 multiple=True, 

188 ) 

189 }, 

190 outputs={ 

191 "output_table": DynamicConnectionConfig( 

192 dataset_type_name="source", 

193 dimensions=["visit"], 

194 ) 

195 }, 

196 ) 

197 helper.add_task( 

198 "resample", 

199 dimensions=["patch", "visit"], 

200 inputs={ 

201 "input_image": DynamicConnectionConfig( 

202 dataset_type_name="image", 

203 dimensions=["visit", "detector"], 

204 multiple=True, 

205 ) 

206 }, 

207 outputs={ 

208 "output_image": DynamicConnectionConfig( 

209 dataset_type_name="warp", 

210 dimensions=["patch", "visit"], 

211 ) 

212 }, 

213 ) 

214 helper.add_task( 

215 "coadd", 

216 dimensions=["patch", "band"], 

217 inputs={ 

218 "input_image": DynamicConnectionConfig( 

219 dataset_type_name="warp", 

220 dimensions=["patch", "visit"], 

221 multiple=True, 

222 ) 

223 }, 

224 outputs={ 

225 "output_image": DynamicConnectionConfig( 

226 dataset_type_name="coadd", 

227 dimensions=["patch", "band"], 

228 ), 

229 }, 

230 ) 

231 pqgc = helper.make_quantum_graph_builder().finish(output="out_chain") 

232 # We use the butler root for various QG files just because it's 

233 # a convenient temporary directory. 

234 predicted_path = os.path.join(root, "predicted.qg") 

235 pqgc.write(predicted_path) 

236 config = AggregatorConfig( 

237 output_path=os.path.join(root, "provenance.qg"), 

238 # Set these small to see logic paths that otherwise only 

239 # affect large graphs. 

240 ingest_batch_size=10, 

241 zstd_dict_size=256, 

242 zstd_dict_n_inputs=16, 

243 ) 

244 yield PrepInfo( 

245 butler=helper.butler.clone(collections="out_chain"), 

246 butler_path=root, 

247 predicted=pqgc.assemble(), 

248 predicted_path=predicted_path, 

249 config=config, 

250 ) 

251 

252 def iter_graph_execution( 

253 self, 

254 repo: str, 

255 qg: PredictedQuantumGraph, 

256 raise_on_partial_outputs: bool, 

257 is_retry: bool = False, 

258 ) -> Iterator[uuid.UUID]: 

259 """Return an iterator that executes and yields quanta one by one. 

260 

261 Parameters 

262 ---------- 

263 repo : `str` 

264 Butler repository path or alias. 

265 qg : `lsst.pipe.base.quantum_graph.PredictedQuantumGraph` 

266 Predicted quantum graph. Must have datastore records attached, 

267 since execution uses a quantum-backed butler. 

268 raise_on_partial_outputs : `bool` 

269 Whether to raise on `lsst.pipe.base.AnnotatedPartialOutputsError` 

270 or treat it as a success with caveats. 

271 is_retry : `bool`, optional 

272 If `True`, this is a retry attempt and hence some outputs may 

273 already be present; skip successes and reprocess failures. 

274 

275 Returns 

276 ------- 

277 quanta : `~collections.abc.Iterator` [`uuid.UUID`] 

278 An iterator over all executed quantum IDs (not blocked ones). 

279 """ 

280 qbb = qg.make_init_qbb(repo) 

281 self.enterContext(qbb) 

282 qg.init_output_run(qbb) 

283 sqe = SingleQuantumExecutor( 

284 limited_butler_factory=lambda quantum: QuantumBackedButler.initialize( 

285 repo, 

286 quantum, 

287 qg.pipeline_graph.universe, 

288 ), 

289 assume_no_existing_outputs=not is_retry, 

290 skip_existing=is_retry, 

291 clobber_outputs=is_retry, 

292 raise_on_partial_outputs=raise_on_partial_outputs, 

293 ) 

294 qg.build_execution_quanta() 

295 xgraph = qg.quantum_only_xgraph 

296 walker = GraphWalker[uuid.UUID](xgraph.copy()) 

297 for ready in walker: 

298 for quantum_id in ready: 

299 info = xgraph.nodes[quantum_id] 

300 try: 

301 sqe.execute(info["pipeline_node"], info["quantum"], quantum_id) 

302 except AlgorithmError: 

303 walker.fail(quantum_id) 

304 else: 

305 walker.finish(quantum_id) 

306 yield quantum_id 

307 

308 def check_provenance_graph( 

309 self, 

310 pred: PredictedQuantumGraph, 

311 butler: Butler, 

312 expect_failure: bool, 

313 start_time: float, 

314 expect_failures_retried: bool = False, 

315 ) -> ProvenanceQuantumGraph: 

316 """Run a batter of tests on a provenance quantum graph produced by 

317 scanning the graph created by `make_test_repo`. 

318 

319 Parameters 

320 ---------- 

321 pred: `lsst.pipe.base.quantum_graph.PredictedQuantumGraph` 

322 Predicted quantum graph. 

323 prov_reader : \ 

324 `lsst.pipe.base.quantum_graph.ProvenanceQuantumGraphReader` 

325 Reader for the provenance quantum graph. 

326 butler : `lsst.daf.butler.Butler` 

327 Client for the data repository. 

328 expect_failure : `bool` 

329 Whether to expect one quantum of 'calibrate' to fail (`True`) or 

330 succeed without writing anything (`False`). 

331 start_time : `float` 

332 A POSIX timestamp that strictly precedes the start time of any 

333 quantum's execution. 

334 expect_failures_retried : `bool`, optional 

335 If `True`, expect an initial attempt with failures prior to the 

336 most recent attempt. 

337 

338 Returns 

339 ------- 

340 prov : `ProvenanceQuantumGraph` 

341 The full provenance quantum graph. 

342 """ 

343 prov: ProvenanceQuantumGraph = butler.get("run_provenance") 

344 self.maxDiff = None 

345 self.assertEqual(prov.header.version, FORMAT_VERSION) 

346 self.assertEqual( 

347 list(butler.collections.get_info(prov.header.output).children), 

348 [prov.header.output_run] 

349 + list(butler.collections.query(prov.header.inputs, flatten_chains=True)), 

350 ) 

351 self.assertEqual(pred.quanta_by_task.keys(), prov.quanta_by_task.keys()) 

352 for task_label in pred.quanta_by_task: 

353 self.assertEqual(pred.quanta_by_task[task_label], prov.quanta_by_task[task_label]) 

354 self.assertEqual(pred.datasets_by_type.keys() - {"packages"}, prov.datasets_by_type.keys()) 

355 for dataset_type_name in prov.datasets_by_type: 

356 self.assertEqual( 

357 pred.datasets_by_type[dataset_type_name], prov.datasets_by_type[dataset_type_name] 

358 ) 

359 self.assertEqual(prov.init_quanta.keys(), pred.quanta_by_task.keys()) 

360 for quantum_id in pred: 

361 # Check consistency between the predicted and provenance quantum 

362 # node attributes. 

363 pred_qinfo: PredictedQuantumInfo = pred.bipartite_xgraph.nodes[quantum_id] 

364 prov_qinfo: ProvenanceQuantumInfo = prov.bipartite_xgraph.nodes[quantum_id] 

365 self.assertEqual(pred_qinfo["task_label"], prov_qinfo["task_label"]) 

366 self.assertEqual(pred_qinfo["data_id"], prov_qinfo["data_id"]) 

367 msg = f"{pred_qinfo['task_label']}@{pred_qinfo['data_id']}" 

368 # Check consistency between the predicted and provenance dataset 

369 # node attributes and edges. Also gather existence information for 

370 # use later. 

371 existence: dict[str, list[bool]] = {} 

372 pipeline_edges: list[Edge] 

373 for dataset_id, _, pipeline_edges in pred.bipartite_xgraph.in_edges( 

374 quantum_id, data="pipeline_edges" 

375 ): 

376 self.assertTrue(prov.bipartite_xgraph.has_predecessor(quantum_id, dataset_id)) 

377 for edge in pipeline_edges: 

378 existence.setdefault(edge.connection_name, []).append( 

379 self.check_dataset(dataset_id, pred, prov, butler) 

380 ) 

381 for _, dataset_id, pipeline_edges in pred.bipartite_xgraph.out_edges( 

382 quantum_id, data="pipeline_edges" 

383 ): 

384 self.assertTrue(prov.bipartite_xgraph.has_successor(quantum_id, dataset_id)) 

385 for edge in pipeline_edges: 

386 existence.setdefault(edge.connection_name, []).append( 

387 self.check_dataset(dataset_id, pred, prov, butler) 

388 ) 

389 # Check quantum status and dataset existence against the known 

390 # structure of the graph and where failures/caveats occur. 

391 match (pred_qinfo["task_label"], dict(pred_qinfo["data_id"].required)): 

392 case "calibrate", {"visit": 2, "detector": 2}: 

393 # This is the quantum that can directly raise. 

394 self._expect_all_exist(existence["input_image"], msg=msg) 

395 self._expect_all_exist(existence["refcat"], msg=msg) 

396 self._expect_none_exist(existence["output_image"], msg=msg) 

397 self._expect_none_exist(existence["output_table"], msg=msg) 

398 if expect_failure: 

399 self._expect_failure(prov_qinfo, existence, msg=msg) 

400 else: 

401 self._expect_successful( 

402 prov_qinfo, 

403 existence, 

404 caveats=( 

405 QuantumSuccessCaveats.PARTIAL_OUTPUTS_ERROR 

406 | QuantumSuccessCaveats.ALL_OUTPUTS_MISSING 

407 | QuantumSuccessCaveats.ANY_OUTPUTS_MISSING 

408 ), 

409 exception_type="lsst.pipe.base.tests.mocks.MockAlgorithmError", 

410 msg=msg, 

411 ) 

412 if expect_failures_retried: 

413 self.assertEqual(len(prov_qinfo["attempts"]), 2) 

414 self.assertEqual( 

415 prov_qinfo["attempts"][0].exception.type_name, 

416 "lsst.pipe.base.tests.mocks.MockAlgorithmError", 

417 ) 

418 else: 

419 self.assertEqual(len(prov_qinfo["attempts"]), 1) 

420 case "consolidate", {"visit": 2}: 

421 # This quantum will succeed (with one predicted input 

422 # missing) or be blocked. 

423 self._expect_one_missing(existence["input_table"], msg=msg) 

424 if expect_failure: 

425 self._expect_blocked(prov_qinfo, existence, msg=msg) 

426 else: 

427 self._expect_successful(prov_qinfo, existence, msg=msg) 

428 self.assertEqual( 

429 len(prov_qinfo["attempts"]), expect_failures_retried or not expect_failure 

430 ) 

431 case ( 

432 "resample" | "coadd", 

433 {"tract": 1, "patch": 1} | {"tract": 0, "patch": 5}, 

434 ): 

435 # These quanta will be blocked by an upstream failure or do 

436 # chained caveats, since they won't have enough inputs to 

437 # run. 

438 if expect_failure: 

439 self._expect_blocked(prov_qinfo, existence, msg=msg) 

440 else: 

441 self._expect_successful( 

442 prov_qinfo, 

443 existence, 

444 caveats=( 

445 QuantumSuccessCaveats.ADJUST_QUANTUM_RAISED 

446 | QuantumSuccessCaveats.NO_WORK 

447 | QuantumSuccessCaveats.ALL_OUTPUTS_MISSING 

448 | QuantumSuccessCaveats.ANY_OUTPUTS_MISSING 

449 ), 

450 msg=msg, 

451 ) 

452 self.assertEqual( 

453 len(prov_qinfo["attempts"]), expect_failures_retried or not expect_failure 

454 ) 

455 case ( 

456 "resample", 

457 {"tract": 0, "patch": 4, "visit": 2} | {"tract": 1, "patch": 0, "visit": 2}, 

458 ): 

459 # This will succeed or be blocked, with one input missing 

460 # regardless. 

461 self._expect_one_missing(existence["input_image"], msg=msg) 

462 if expect_failure: 

463 self._expect_blocked(prov_qinfo, existence, msg=msg) 

464 else: 

465 self._expect_successful(prov_qinfo, existence, msg=msg) 

466 self.assertEqual( 

467 len(prov_qinfo["attempts"]), expect_failures_retried or not expect_failure 

468 ) 

469 case ( 

470 "coadd", 

471 {"tract": 0, "patch": 4, "band": "r"} | {"tract": 1, "patch": 0, "band": "r"}, 

472 ): 

473 # This will succeed with no inputs missing or be blocked 

474 # with one input missing. 

475 if expect_failure: 

476 self._expect_one_missing(existence["input_image"], msg=msg) 

477 self._expect_blocked(prov_qinfo, existence, msg=msg) 

478 else: 

479 self._expect_all_exist(existence["input_image"], msg=msg) 

480 self._expect_successful(prov_qinfo, existence, msg=msg) 

481 self.assertEqual( 

482 len(prov_qinfo["attempts"]), expect_failures_retried or not expect_failure 

483 ) 

484 case _: 

485 # All other quanta should succeed and have all inputs 

486 # present. 

487 for connection_name in prov_qinfo["pipeline_node"].inputs.keys(): 

488 self._expect_all_exist(existence[connection_name], msg=msg) 

489 self._expect_successful(prov_qinfo, existence, msg=msg) 

490 self.assertEqual(len(prov_qinfo["attempts"]), 1) 

491 self.check_metadata( 

492 quantum_id, 

493 prov, 

494 butler, 

495 expect_ingested=(prov_qinfo["status"] is QuantumAttemptStatus.SUCCESSFUL), 

496 ) 

497 self.check_log( 

498 quantum_id, 

499 prov, 

500 butler, 

501 expect_ingested=( 

502 prov_qinfo["status"] 

503 in ( 

504 QuantumAttemptStatus.SUCCESSFUL, 

505 QuantumAttemptStatus.FAILED, 

506 ) 

507 ), 

508 ) 

509 self.check_resource_usage_table(prov, expect_failure=expect_failure, start_time=start_time) 

510 self.check_packages(butler) 

511 self.check_configs(butler, prov) 

512 self.check_quantum_table(prov, expect_failure=expect_failure) 

513 self.check_exception_table(prov, expect_failure=expect_failure) 

514 self.check_report(prov, expect_failure=expect_failure) 

515 return prov 

516 

517 def _expect_all_exist(self, existence: list[bool], msg: str) -> None: 

518 self.assertTrue(all(existence), msg=msg) 

519 

520 def _expect_none_exist(self, existence: list[bool], msg: str) -> None: 

521 self.assertFalse(any(existence), msg=msg) 

522 

523 def _expect_one_missing(self, existence: list[bool], msg: str) -> None: 

524 self.assertEqual(existence.count(False), 1, msg=msg) 

525 

526 def _expect_successful( 

527 self, 

528 info: ProvenanceQuantumInfo, 

529 existence: dict[str, list[bool]], 

530 caveats: QuantumSuccessCaveats = QuantumSuccessCaveats.NO_CAVEATS, 

531 exception_type: str | None = None, 

532 *, 

533 msg: str, 

534 ) -> None: 

535 self.assertEqual(info["status"], QuantumAttemptStatus.SUCCESSFUL, msg=msg) 

536 self.assertEqual(info["caveats"], caveats, msg=msg) 

537 if exception_type is None: 

538 self.assertIsNone(info["exception"], msg=msg) 

539 else: 

540 assert info["exception"] is not None 

541 self.assertEqual(info["exception"].type_name, exception_type, msg=msg) 

542 self._expect_all_exist(existence[acc.LOG_OUTPUT_CONNECTION_NAME], msg=msg) 

543 self._expect_all_exist(existence[acc.METADATA_OUTPUT_CONNECTION_NAME], msg=msg) 

544 if not (caveats & QuantumSuccessCaveats.ANY_OUTPUTS_MISSING): 

545 for connection_name in info["pipeline_node"].outputs.keys(): 

546 self._expect_all_exist(existence[connection_name], msg=msg) 

547 if caveats & QuantumSuccessCaveats.ALL_OUTPUTS_MISSING: 

548 for connection_name in info["pipeline_node"].outputs.keys(): 

549 self._expect_none_exist(existence[connection_name], msg=msg) 

550 self.assertIsNotNone(info["resource_usage"], msg=msg) 

551 self.assertGreater(info["resource_usage"].total_time, 0, msg=msg) 

552 self.assertGreater(info["resource_usage"].memory, 0, msg=msg) 

553 

554 def _expect_failure( 

555 self, info: ProvenanceQuantumInfo, existence: dict[str, list[bool]], msg: str 

556 ) -> None: 

557 self.assertEqual(info["status"], QuantumAttemptStatus.FAILED, msg=msg) 

558 self.assertEqual(info["exception"].type_name, "lsst.pipe.base.tests.mocks.MockAlgorithmError") 

559 self._expect_all_exist(existence[acc.LOG_OUTPUT_CONNECTION_NAME], msg=msg) 

560 self._expect_none_exist(existence[acc.METADATA_OUTPUT_CONNECTION_NAME], msg=msg) 

561 for connection_name in info["pipeline_node"].outputs.keys(): 

562 self._expect_none_exist(existence[connection_name], msg=msg) 

563 

564 def _expect_blocked( 

565 self, 

566 info: ProvenanceQuantumInfo, 

567 existence: dict[str, list[bool]], 

568 msg: str, 

569 ) -> None: 

570 self.assertEqual(info["status"], QuantumAttemptStatus.BLOCKED, msg=msg) 

571 self.assertEqual(info["attempts"], []) 

572 self._expect_none_exist(existence[acc.LOG_OUTPUT_CONNECTION_NAME], msg=msg) 

573 self._expect_none_exist(existence[acc.METADATA_OUTPUT_CONNECTION_NAME], msg=msg) 

574 for connection_name in info["pipeline_node"].outputs.keys(): 

575 self._expect_none_exist(existence[connection_name], msg=msg) 

576 

577 def check_dataset( 

578 self, 

579 dataset_id: uuid.UUID, 

580 pred: PredictedQuantumGraph, 

581 prov: ProvenanceQuantumGraph, 

582 butler: Butler, 

583 ) -> bool: 

584 """Check a provenance dataset for consistency with its predicted 

585 counterpart. 

586 

587 Parameters 

588 ---------- 

589 dataset_id : `uuid.UUID` 

590 Unique ID for the dataset. 

591 pred: `lsst.pipe.base.quantum_graph.PredictedQuantumGraph` 

592 Predicted quantum graph. 

593 prov : `lsst.pipe.base.quantum_graph.ProvenanceQuantumGraph` 

594 Provenance quantum graph. 

595 butler : `lsst.daf.butler.Butler` 

596 Client for the data repository. 

597 

598 Returns 

599 ------- 

600 exists : `bool` 

601 Whether the dataset was marked as existing in the provenance 

602 quantum graph. 

603 """ 

604 pred_info: PredictedDatasetInfo = pred.bipartite_xgraph.nodes[dataset_id] 

605 prov_info: ProvenanceDatasetInfo = prov.bipartite_xgraph.nodes[dataset_id] 

606 self.assertEqual(pred_info["dataset_type_name"], prov_info["dataset_type_name"]) 

607 self.assertEqual(pred_info["data_id"], prov_info["data_id"]) 

608 self.assertEqual(pred_info["run"], prov_info["run"]) 

609 exists = prov_info["produced"] 

610 dataset_type_name = prov_info["dataset_type_name"] 

611 # We can remove this guard when we ingest QG-backed metadata and logs. 

612 if not dataset_type_name.endswith("_metadata") and not dataset_type_name.endswith("_log"): 

613 self.assertEqual( 

614 butler.get_dataset(dataset_id) is not None, 

615 exists, 

616 msg=( 

617 f"Ingest/existence inconsistency for {dataset_type_name}" 

618 f"@{prov_info['data_id']}/{dataset_id}]" 

619 ), 

620 ) 

621 return exists 

622 

623 def check_metadata( 

624 self, quantum_id: uuid.UUID, prov: ProvenanceQuantumGraph, butler: Butler, expect_ingested: bool 

625 ) -> None: 

626 """Check reading a metadata dataset from the butler, and check that the 

627 original metadata file has been deleted. 

628 

629 Parameters 

630 ---------- 

631 quantum_id : `uuid.UUID` 

632 Unique ID for the quantum this metadata belongs to. 

633 prov : `lsst.pipe.base.quantum_graph.ProvenanceQuantumGraph` 

634 Provenance quantum graph. 

635 butler : `lsst.daf.butler.Butler` 

636 Client for the data repository. 

637 expect_ingested : `bool` 

638 Whether the metadata dataset should have been ingested. 

639 """ 

640 dataset_id = prov.bipartite_xgraph.nodes[quantum_id]["metadata_id"] 

641 ref = butler.get_dataset(dataset_id) 

642 if not expect_ingested: 

643 self.assertIsNone(ref) 

644 return 

645 assert ref is not None 

646 metadata = butler.get(ref) 

647 self.assertIsInstance(metadata, TaskMetadata) 

648 graph_path = butler.getURI("run_provenance") 

649 self.assertEqual(butler.getURI(ref), graph_path) 

650 # We now delete the metadata dataset, in order let us get the original 

651 # location from the butler and check that there's nothing there. Note 

652 # that this doesn't actually delete the file because the butler knows 

653 # it's shared with other datasets. 

654 butler.pruneDatasets([ref], disassociate=True, unstore=True, purge=True) 

655 original_path = butler.getURI(ref, predict=True) 

656 self.assertTrue(graph_path.exists()) 

657 self.assertNotEqual(graph_path, original_path) 

658 self.assertFalse(original_path.exists()) 

659 

660 def check_log( 

661 self, quantum_id: uuid.UUID, prov: ProvenanceQuantumGraph, butler: Butler, expect_ingested: bool 

662 ) -> None: 

663 """Check reading a log dataset from the butler, and check that the 

664 original log file has been deleted. 

665 

666 Parameters 

667 ---------- 

668 quantum_id : `uuid.UUID` 

669 Unique ID for the quantum this log belongs to. 

670 prov : `lsst.pipe.base.quantum_graph.ProvenanceQuantumGraph` 

671 Provenance quantum graph. 

672 butler : `lsst.daf.butler.Butler` 

673 Client for the data repository. 

674 expect_ingested : `bool` 

675 Whether the metadata dataset should have been ingested. 

676 """ 

677 dataset_id = prov.bipartite_xgraph.nodes[quantum_id]["log_id"] 

678 ref = butler.get_dataset(dataset_id) 

679 if not expect_ingested: 

680 self.assertIsNone(ref) 

681 return 

682 assert ref is not None 

683 log = butler.get(ref) 

684 self.assertIsInstance(log, ButlerLogRecords) 

685 graph_path = butler.getURI("run_provenance") 

686 self.assertEqual(butler.getURI(ref), graph_path) 

687 # We now delete the log dataset, in order let us get the original 

688 # location from the butler and check that there's nothing there. Note 

689 # that this doesn't actually delete the file because the butler knows 

690 # it's shared with other datasets. 

691 butler.pruneDatasets([ref], disassociate=True, unstore=True, purge=True) 

692 original_path = butler.getURI(ref, predict=True) 

693 self.assertTrue(graph_path.exists()) 

694 self.assertNotEqual(graph_path, original_path) 

695 self.assertFalse(original_path.exists()) 

696 

697 def check_configs(self, butler: Butler, prov: ProvenanceQuantumGraph) -> None: 

698 for task_node in prov.pipeline_graph.tasks.values(): 

699 config = butler.get(task_node.init.config_output.dataset_type_name) 

700 self.assertIsInstance(config, Config) 

701 

702 def check_packages(self, butler: Butler) -> None: 

703 """Check fetching package versions from the provenance graph. 

704 

705 Parameters 

706 ---------- 

707 butler : `lsst.daf.butler.Butler` 

708 Client for the data repository. 

709 """ 

710 packages = butler.get("run_provenance.packages") 

711 self.assertIsInstance(packages, Packages) 

712 self.assertIn("pipe_base", packages) 

713 

714 def check_resource_usage_table( 

715 self, prov: ProvenanceQuantumGraph, expect_failure: bool, start_time: float 

716 ) -> None: 

717 """Check building a resource usage table from the provenance graph. 

718 

719 Parameters 

720 ---------- 

721 prov : `lsst.pipe.base.quantum_graph.ProvenanceQuantumGraph` 

722 Reader for the provenance quantum graph. 

723 expect_failure : `bool` 

724 Whether to expect one quantum of 'calibrate' to fail (`True`) or 

725 succeed without writing anything (`False`). 

726 start_time : `float` 

727 A POSIX timestamp that strictly precedes the start time of any 

728 quantum's execution. 

729 """ 

730 tbl = prov.make_task_resource_usage_table("calibrate", include_data_ids=True) 

731 self.assertEqual(len(tbl), prov.header.n_task_quanta["calibrate"]) 

732 self.assertCountEqual( 

733 tbl.colnames, 

734 ["quantum_id"] 

735 + list(prov.pipeline_graph.tasks["calibrate"].dimensions.names) 

736 + list(QuantumResourceUsage.model_fields), 

737 ) 

738 # Check that quantum start times are bounded by the before-execution 

739 # start_time and now. This makes sure we didn't get any timezone 

740 # shenanigans. 

741 end_time = time.time() 

742 for quantum_start_time in tbl["start"]: 

743 self.assertGreater(quantum_start_time, start_time) 

744 self.assertLess(quantum_start_time, end_time) 

745 self.assertTrue(np.all(tbl["init_time"] >= 0.0)) 

746 self.assertTrue(np.all(tbl["prep_time"] > 0.0)) 

747 self.assertTrue(np.all(tbl["run_time"] >= 0.0)) 

748 

749 def check_quantum_table(self, prov: ProvenanceQuantumGraph, expect_failure: bool) -> None: 

750 """Check `ProvenanceQuantumGraph.make_quantum_table`. 

751 

752 Parameters 

753 ---------- 

754 prov : `lsst.pipe.base.quantum_graph.ProvenanceQuantumGraph` 

755 Reader for the provenance quantum graph. 

756 expect_failure : `bool` 

757 Whether to expect one quantum of 'calibrate' to fail (`True`) or 

758 succeed without writing anything (`False`). 

759 """ 

760 t = prov.make_quantum_table() 

761 self.assertEqual(list(t["Task"]), ["calibrate", "consolidate", "resample", "coadd"]) 

762 self.assertEqual(t["TOTAL"][0], 8) 

763 self.assertEqual(t["EXPECTED"][0], 8) 

764 self.assertEqual(t["Blocked"][0], 0) 

765 self.assertEqual(t["TOTAL"][1], 2) 

766 self.assertEqual(t["EXPECTED"][1], 2) 

767 self.assertEqual(t["TOTAL"][2], 10) 

768 self.assertEqual(t["EXPECTED"][2], 10) 

769 if expect_failure: 

770 # calibrate 

771 self.assertEqual(t["Successful"][0], 7) 

772 self.assertEqual(t["Caveats"][0], "") 

773 self.assertEqual(t["Failed"][0], 1) 

774 # consolidate 

775 self.assertEqual(t["Successful"][1], 1) 

776 self.assertEqual(t["Caveats"][1], "") 

777 self.assertEqual(t["Failed"][1], 0) 

778 self.assertEqual(t["Blocked"][1], 1) 

779 # resample 

780 self.assertEqual(t["Successful"][2], 6) 

781 self.assertEqual(t["Caveats"][2], "") 

782 self.assertEqual(t["Failed"][2], 0) 

783 self.assertEqual(t["Blocked"][2], 4) 

784 else: 

785 # calibrate 

786 self.assertEqual(t["Successful"][0], 8) 

787 self.assertEqual(t["Caveats"][0], "*P(1)") 

788 self.assertEqual(t["Failed"][0], 0) 

789 # consolidate 

790 self.assertEqual(t["Successful"][1], 2) 

791 self.assertEqual(t["Caveats"][1], "") 

792 self.assertEqual(t["Failed"][1], 0) 

793 self.assertEqual(t["Blocked"][1], 0) 

794 # resample 

795 self.assertEqual(t["Successful"][2], 10) 

796 self.assertEqual(t["Caveats"][2], "*A(2)") 

797 self.assertEqual(t["Failed"][2], 0) 

798 self.assertEqual(t["Blocked"][2], 0) 

799 

800 def check_exception_table(self, prov: ProvenanceQuantumGraph, expect_failure: bool) -> None: 

801 """Check `ProvenanceQuantumGraph.make_exception_table`. 

802 

803 Parameters 

804 ---------- 

805 prov : `lsst.pipe.base.quantum_graph.ProvenanceQuantumGraph` 

806 Reader for the provenance quantum graph. 

807 expect_failure : `bool` 

808 Whether to expect one quantum of 'calibrate' to fail (`True`) or 

809 succeed without writing anything (`False`). 

810 """ 

811 t = prov.make_exception_table() 

812 self.assertEqual(list(t["Task"]), ["calibrate"]) 

813 self.assertEqual(list(t["Exception"]), ["lsst.pipe.base.tests.mocks.MockAlgorithmError"]) 

814 self.assertEqual(list(t["Successes"]), [int(not expect_failure)]) 

815 self.assertEqual(list(t["Failures"]), [int(expect_failure)]) 

816 

817 def check_report(self, prov: ProvenanceQuantumGraph, expect_failure: bool) -> None: 

818 """Check `ProvenanceQuantumGraph.make_report`. 

819 

820 Parameters 

821 ---------- 

822 prov : `lsst.pipe.base.quantum_graph.ProvenanceQuantumGraph` 

823 Reader for the provenance quantum graph. 

824 expect_failure : `bool` 

825 Whether to expect one quantum of 'calibrate' to fail (`True`) or 

826 succeed without writing anything (`False`). 

827 """ 

828 with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as data_id_table_dir: 

829 report = prov.make_status_report( 

830 also=QuantumAttemptStatus.SUCCESSFUL, data_id_table_dir=data_id_table_dir 

831 ) 

832 task_label = "calibrate" 

833 status_name = "FAILED" if expect_failure else "SUCCESSFUL" 

834 exc_type = "lsst.pipe.base.tests.mocks.MockAlgorithmError" 

835 self.assertEqual(report.root.keys(), {task_label}) 

836 self.assertEqual(report.root[task_label].keys(), {status_name}) 

837 self.assertEqual(report.root[task_label][status_name].keys(), {exc_type}) 

838 self.assertEqual(len(report.root[task_label][status_name][exc_type]), 1) 

839 qr = report.root[task_label][status_name][exc_type][0] 

840 self.assertIsInstance(qr, ProvenanceQuantumReport) 

841 self.assertEqual( 

842 qr.data_id, 

843 { 

844 "instrument": "Cam1", 

845 "visit": 2, 

846 "detector": 2, 

847 "band": "r", 

848 "day_obs": 20210909, 

849 "physical_filter": "Cam1-R1", 

850 }, 

851 ) 

852 tbl = astropy.table.Table.read( 

853 os.path.join(data_id_table_dir, task_label, status_name, f"{exc_type}.ecsv") 

854 ) 

855 self.assertCountEqual(tbl.colnames, ["instrument", "visit", "detector"]) 

856 self.assertEqual(len(tbl), 1) 

857 self.assertEqual(list(tbl["instrument"]), ["Cam1"]) 

858 self.assertEqual(list(tbl["detector"]), [2]) 

859 self.assertEqual(list(tbl["visit"]), [2]) 

860 

861 def test_all_successful(self) -> None: 

862 """Test running a full graph with no failures, and then scanning the 

863 results with incomplete=False. 

864 """ 

865 with self.make_test_repo() as prep: 

866 prep.config.incomplete = False 

867 start_time = time.time() 

868 attempted_quanta = list( 

869 self.iter_graph_execution(prep.butler_path, prep.predicted, raise_on_partial_outputs=False) 

870 ) 

871 self.assertCountEqual(attempted_quanta, prep.predicted.quantum_only_xgraph.nodes.keys()) 

872 aggregate_graph(prep.predicted_path, prep.butler_path, prep.config) 

873 ingest_graph(prep.butler_path, prep.config.output_path, transfer="move", batch_size=10) 

874 prov = self.check_provenance_graph( 

875 prep.predicted, 

876 prep.butler, 

877 expect_failure=False, 

878 start_time=start_time, 

879 ) 

880 self.check_no_original_dirs(prep.butler_path, prov.header.output_run) 

881 for i, quantum_id in enumerate(attempted_quanta): 

882 qinfo: ProvenanceQuantumInfo = prov.quantum_only_xgraph.nodes[quantum_id] 

883 self.assertEqual(qinfo["attempts"][-1].previous_process_quanta, attempted_quanta[:i]) 

884 

885 def test_all_successful_two_phase(self) -> None: 

886 """Test running some of a graph with no failures, scanning with 

887 incomplete=True, then finishing the graph and scanning again. 

888 """ 

889 with self.make_test_repo() as prep: 

890 start_time = time.time() 

891 execution_iter = self.iter_graph_execution( 

892 prep.butler_path, prep.predicted, raise_on_partial_outputs=False 

893 ) 

894 attempted_quanta = list(itertools.islice(execution_iter, 9)) 

895 self.assertEqual(len(attempted_quanta), 9) 

896 # Run the scanner while telling it execution is incomplete, so it 

897 # just abandons incomplete quanta and doesn't write the provenance 

898 # QG. 

899 prep.config.incomplete = True 

900 aggregate_graph(prep.predicted_path, prep.butler_path, prep.config) 

901 self.assertFalse(os.path.exists(cast(str, prep.config.output_path))) 

902 # Finish executing the quanta. 

903 attempted_quanta.extend(execution_iter) 

904 # Scan again, and write the provenance QG. 

905 prep.config.incomplete = False 

906 aggregate_graph(prep.predicted_path, prep.butler_path, prep.config) 

907 ingest_graph(prep.butler_path, prep.config.output_path, transfer="move", batch_size=10) 

908 prov = self.check_provenance_graph( 

909 prep.predicted, 

910 prep.butler, 

911 expect_failure=False, 

912 start_time=start_time, 

913 ) 

914 for i, quantum_id in enumerate(attempted_quanta): 

915 qinfo: ProvenanceQuantumInfo = prov.quantum_only_xgraph.nodes[quantum_id] 

916 self.assertEqual(qinfo["attempts"][-1].previous_process_quanta, attempted_quanta[:i]) 

917 

918 def test_some_failed(self) -> None: 

919 """Test running a full graph with some failures, and then scanning the 

920 results with incomplete=False. 

921 """ 

922 with self.make_test_repo() as prep: 

923 prep.config.incomplete = False 

924 start_time = time.time() 

925 attempted_quanta = list( 

926 self.iter_graph_execution(prep.butler_path, prep.predicted, raise_on_partial_outputs=True) 

927 ) 

928 aggregate_graph(prep.predicted_path, prep.butler_path, prep.config) 

929 ingest_graph(prep.butler_path, prep.config.output_path, transfer="move", batch_size=10) 

930 prov = self.check_provenance_graph( 

931 prep.predicted, 

932 prep.butler, 

933 expect_failure=True, 

934 start_time=start_time, 

935 ) 

936 self.check_no_original_dirs(prep.butler_path, prov.header.output_run) 

937 for i, quantum_id in enumerate(attempted_quanta): 

938 qinfo: ProvenanceQuantumInfo = prov.quantum_only_xgraph.nodes[quantum_id] 

939 self.assertEqual(qinfo["attempts"][-1].previous_process_quanta, attempted_quanta[:i]) 

940 

941 def test_some_failed_two_phase(self) -> None: 

942 """Test running a full graph with some failures, then scanning the 

943 results with incomplete=True, then scanning again with 

944 incomplete=False. 

945 """ 

946 with self.make_test_repo() as prep: 

947 start_time = time.time() 

948 attempted_quanta = list( 

949 self.iter_graph_execution(prep.butler_path, prep.predicted, raise_on_partial_outputs=True) 

950 ) 

951 prep.config.incomplete = True 

952 aggregate_graph(prep.predicted_path, prep.butler_path, prep.config) 

953 prep.config.incomplete = False 

954 aggregate_graph(prep.predicted_path, prep.butler_path, prep.config) 

955 ingest_graph(prep.butler_path, prep.config.output_path, transfer="move", batch_size=10) 

956 prov = self.check_provenance_graph( 

957 prep.predicted, 

958 prep.butler, 

959 expect_failure=True, 

960 start_time=start_time, 

961 ) 

962 for i, quantum_id in enumerate(attempted_quanta): 

963 qinfo: ProvenanceQuantumInfo = prov.quantum_only_xgraph.nodes[quantum_id] 

964 self.assertEqual(qinfo["attempts"][-1].previous_process_quanta, attempted_quanta[:i]) 

965 

966 def test_retry(self) -> None: 

967 """Test running a full graph with some failures, rerunning the quanta 

968 that failed or were blocked in the first attempt, and then scanning 

969 for provenance. 

970 """ 

971 with self.make_test_repo() as prep: 

972 start_time = time.time() 

973 attempted_quanta_1 = list( 

974 self.iter_graph_execution(prep.butler_path, prep.predicted, raise_on_partial_outputs=True) 

975 ) 

976 attempted_quanta_2 = list( 

977 self.iter_graph_execution( 

978 prep.butler_path, prep.predicted, raise_on_partial_outputs=False, is_retry=True 

979 ) 

980 ) 

981 aggregate_graph(prep.predicted_path, prep.butler_path, prep.config) 

982 ingest_graph(prep.butler_path, prep.config.output_path, transfer="move", batch_size=10) 

983 prov = self.check_provenance_graph( 

984 prep.predicted, 

985 prep.butler, 

986 expect_failure=False, 

987 start_time=start_time, 

988 expect_failures_retried=True, 

989 ) 

990 for i, quantum_id in enumerate(attempted_quanta_1): 

991 qinfo: ProvenanceQuantumInfo = prov.quantum_only_xgraph.nodes[quantum_id] 

992 self.assertEqual(qinfo["attempts"][0].previous_process_quanta, attempted_quanta_1[:i]) 

993 expected: list[uuid.UUID] = [] 

994 for quantum_id in attempted_quanta_2: 

995 qinfo: ProvenanceQuantumInfo = prov.quantum_only_xgraph.nodes[quantum_id] 

996 if ( 

997 quantum_id in attempted_quanta_1 

998 and qinfo["attempts"][0].status is QuantumAttemptStatus.SUCCESSFUL 

999 ): 

1000 # These weren't actually attempted twice, since they 

1001 # were already successful in the first round. 

1002 self.assertEqual(len(qinfo["attempts"]), 1) 

1003 else: 

1004 self.assertEqual(qinfo["attempts"][-1].previous_process_quanta, expected) 

1005 expected.append(quantum_id) 

1006 

1007 def test_promise_ingest_graph(self) -> None: 

1008 """Test running with promise_ingest_graph=True.""" 

1009 with self.make_test_repo() as prep: 

1010 prep.config.incomplete = False 

1011 prep.config.promise_ingest_graph = True 

1012 start_time = time.time() 

1013 attempted_quanta = list( 

1014 self.iter_graph_execution(prep.butler_path, prep.predicted, raise_on_partial_outputs=True) 

1015 ) 

1016 aggregate_graph(prep.predicted_path, prep.butler_path, prep.config) 

1017 self.assertFalse(prep.butler.query_datasets("calibrate_metadata", explain=False)) 

1018 self.assertFalse(prep.butler.query_datasets("consolidate_log", explain=False)) 

1019 self.assertFalse(prep.butler.query_datasets("resample_config", explain=False)) 

1020 ingest_graph(prep.butler_path, prep.config.output_path, transfer="move", batch_size=10) 

1021 prov = self.check_provenance_graph( 

1022 prep.predicted, 

1023 prep.butler, 

1024 expect_failure=True, 

1025 start_time=start_time, 

1026 ) 

1027 self.check_no_original_dirs(prep.butler_path, prov.header.output_run) 

1028 for i, quantum_id in enumerate(attempted_quanta): 

1029 qinfo: ProvenanceQuantumInfo = prov.quantum_only_xgraph.nodes[quantum_id] 

1030 self.assertEqual(qinfo["attempts"][-1].previous_process_quanta, attempted_quanta[:i]) 

1031 

1032 def test_worker_failures(self) -> None: 

1033 """Test that if failures occur on (multiple) workers we shut down 

1034 gracefully instead of hanging. 

1035 """ 

1036 with self.make_test_repo() as prep: 

1037 with self.assertRaises(FatalWorkerError): 

1038 aggregate_graph(prep.predicted_path, "nonexistent", prep.config) 

1039 

1040 def test_aggregate_graph_cli_overrides(self) -> None: 

1041 """Test that command-line options override config attributes as 

1042 expected. 

1043 """ 

1044 

1045 def mock_run(predicted_path: str, butler_path: str, config: AggregatorConfig) -> None: 

1046 print(config.model_dump_json(indent=2)) 

1047 

1048 def check(result: Result, **kwargs: Any) -> None: 

1049 self.assertEqual(result.exit_code, 0, msg=result.output) 

1050 self.assertEqual( 

1051 result.output.strip(), AggregatorConfig(**kwargs).model_dump_json(indent=2).strip() 

1052 ) 

1053 

1054 self.maxDiff = None 

1055 runner = CliRunner() 

1056 with unittest.mock.patch("lsst.pipe.base.quantum_graph.aggregator.aggregate_graph", mock_run): 

1057 check(runner.invoke(aggregate_graph_cli, ("pg", "repo"))) 

1058 check(runner.invoke(aggregate_graph_cli, ("pg", "repo", "--output", "out")), output_path="out") 

1059 check(runner.invoke(aggregate_graph_cli, ("pg", "repo", "-j", "4")), n_processes=4) 

1060 check( 

1061 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--incomplete")), 

1062 incomplete=True, 

1063 ) 

1064 check(runner.invoke(aggregate_graph_cli, ("pg", "repo", "--dry-run")), dry_run=True) 

1065 check( 

1066 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--interactive-status")), 

1067 interactive_status=True, 

1068 ) 

1069 check( 

1070 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--log-status-interval", "120")), 

1071 log_status_interval=120, 

1072 ) 

1073 check( 

1074 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--no-register-dataset-types")), 

1075 register_dataset_types=False, 

1076 ) 

1077 check( 

1078 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--no-update-output-chain")), 

1079 update_output_chain=False, 

1080 ) 

1081 check( 

1082 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--worker-log-dir", "wlogs")), 

1083 worker_log_dir="wlogs", 

1084 ) 

1085 check( 

1086 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--worker-log-level", "DEBUG")), 

1087 worker_log_level="DEBUG", 

1088 ) 

1089 check( 

1090 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--zstd-level", "11")), 

1091 zstd_level=11, 

1092 ) 

1093 check( 

1094 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--zstd-dict-size", "143")), 

1095 zstd_dict_size=143, 

1096 ) 

1097 check( 

1098 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--zstd-dict-n-inputs", "2")), 

1099 zstd_dict_n_inputs=2, 

1100 ) 

1101 check( 

1102 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--mock-storage-classes")), 

1103 mock_storage_classes=True, 

1104 ) 

1105 

1106 def check_provenance_report(self, result: click.testing.Result, root: str) -> None: 

1107 self.maxDiff = None 

1108 self.assertEqual(result.exit_code, 0, msg=result.output) 

1109 self.assertEqual( 

1110 result.output, 

1111 " Task Caveats Failed Blocked Successful TOTAL EXPECTED\n" 

1112 "----------- ------- ------ ------- ---------- ----- --------\n" 

1113 " calibrate 1 0 7 8 8\n" 

1114 "consolidate 0 1 1 2 2\n" 

1115 " resample 0 4 6 10 10\n" 

1116 " coadd 0 4 6 10 10\n" 

1117 "\n" 

1118 " Task Exception Successes Failures\n" 

1119 "--------- --------------------------------------------- --------- --------\n" 

1120 "calibrate lsst.pipe.base.tests.mocks.MockAlgorithmError 0 1\n" 

1121 "\n", 

1122 ) 

1123 with open(os.path.join(root, "report.json")) as report_file: 

1124 report = ProvenanceReport.model_validate_json(report_file.read()) 

1125 self.assertEqual(report.root.keys(), {"calibrate"}) 

1126 self.assertEqual(report.root["calibrate"].keys(), {"FAILED"}) 

1127 self.assertEqual( 

1128 report.root["calibrate"]["FAILED"].keys(), {"lsst.pipe.base.tests.mocks.MockAlgorithmError"} 

1129 ) 

1130 self.assertEqual( 

1131 len(report.root["calibrate"]["FAILED"]["lsst.pipe.base.tests.mocks.MockAlgorithmError"]), 1 

1132 ) 

1133 self.assertEqual( 

1134 list(os.walk(os.path.join(root, "data_ids"))), 

1135 [ 

1136 (os.path.join(root, "data_ids"), ["calibrate"], []), 

1137 (os.path.join(root, "data_ids", "calibrate"), ["FAILED"], []), 

1138 ( 

1139 os.path.join(root, "data_ids", "calibrate", "FAILED"), 

1140 [], 

1141 ["lsst.pipe.base.tests.mocks.MockAlgorithmError.ecsv"], 

1142 ), 

1143 ], 

1144 ) 

1145 

1146 def check_no_original_dirs(self, butler_path: str, output_run: str) -> None: 

1147 """Check that there are no config/log/metadata directories in 

1148 the butler's directory for this output run. 

1149 """ 

1150 root = os.path.join(butler_path, output_run) 

1151 for subdir in os.listdir(root): 

1152 if subdir.endswith("_config") or subdir.endswith("_metadata") or subdir.endswith("_log"): 

1153 raise AssertionError(f"Directory {os.path.join(root, subdir)} still exists.") 

1154 

1155 def test_provenance_report_content(self) -> None: 

1156 """Test the provenance-report CLI command.""" 

1157 with self.make_test_repo() as prep: 

1158 prep.config.incomplete = False 

1159 prep.config.promise_ingest_graph = True 

1160 for _ in self.iter_graph_execution( 

1161 prep.butler_path, prep.predicted, raise_on_partial_outputs=True 

1162 ): 

1163 pass 

1164 aggregate_graph(prep.predicted_path, prep.butler_path, prep.config) 

1165 self.assertFalse(prep.butler.query_datasets("calibrate_metadata", explain=False)) 

1166 self.assertFalse(prep.butler.query_datasets("consolidate_log", explain=False)) 

1167 self.assertFalse(prep.butler.query_datasets("resample_config", explain=False)) 

1168 

1169 # First test on a provenance graph file that has not been ingested. 

1170 runner = CliRunner() 

1171 report_root = os.path.join(prep.butler_path, "uningested") 

1172 result = runner.invoke( 

1173 provenance_report_cli, 

1174 ( 

1175 cast(str, prep.config.output_path), 

1176 "--status-report", 

1177 os.path.join(report_root, "report.json"), 

1178 "--data-id-table-dir", 

1179 os.path.join(report_root, "data_ids"), 

1180 ), 

1181 ) 

1182 self.check_provenance_report(result, report_root) 

1183 

1184 ingest_graph(prep.butler_path, prep.config.output_path, transfer="move", batch_size=10) 

1185 

1186 runner = CliRunner() 

1187 report_root = os.path.join(prep.butler_path, "ingested") 

1188 result = runner.invoke( 

1189 provenance_report_cli, 

1190 ( 

1191 prep.butler_path, 

1192 *prep.butler.collections.defaults, 

1193 "--status-report", 

1194 os.path.join(report_root, "report.json"), 

1195 "--data-id-table-dir", 

1196 os.path.join(report_root, "data_ids"), 

1197 ), 

1198 ) 

1199 self.check_provenance_report(result, os.path.join(report_root)) 

1200 

1201 def test_provenance_report_cli_overrides(self) -> None: 

1202 """Test the provenance-report CLI command with a mocked 

1203 implementation. 

1204 """ 

1205 

1206 class MakeManyReportsArgs(pydantic.BaseModel): 

1207 status_report_file: str | None = None 

1208 print_quantum_table: bool = True 

1209 print_exception_table: bool = True 

1210 states: list[QuantumAttemptStatus] = pydantic.Field( 

1211 default_factory=lambda: [ 

1212 QuantumAttemptStatus.FAILED, 

1213 QuantumAttemptStatus.ABORTED, 

1214 QuantumAttemptStatus.ABORTED_SUCCESS, 

1215 ] 

1216 ) 

1217 with_caveats: QuantumSuccessCaveats | None = None 

1218 data_id_table_dir: str | None = None 

1219 

1220 @pydantic.model_validator(mode="after") 

1221 def _sort_states(self) -> MakeManyReportsArgs: 

1222 self.states.sort(key=lambda e: e.value) 

1223 return self 

1224 

1225 class MockProvenanceQuantumGraph(pydantic.BaseModel): 

1226 repo_or_filename: str 

1227 collection: str | None 

1228 quanta: list[uuid.UUID] | None = None 

1229 datasets: list[uuid.UUID] | None = None 

1230 writeable: bool = False 

1231 make_many_reports_args: MakeManyReportsArgs | None = None 

1232 

1233 @classmethod 

1234 @contextmanager 

1235 def from_args( 

1236 cls, repo_or_filename: str, /, **kwargs: Any 

1237 ) -> Iterator[tuple[MockProvenanceQuantumGraph, None]]: 

1238 yield cls(repo_or_filename=repo_or_filename, **kwargs), None 

1239 

1240 def make_many_reports(self, **kwargs: Any) -> None: 

1241 self.make_many_reports_args = MakeManyReportsArgs(**kwargs) 

1242 print(self.model_dump_json(indent=2)) 

1243 

1244 def check( 

1245 result: Result, repo_or_filename: str, collection: str | None = None, **kwargs: Any 

1246 ) -> None: 

1247 self.assertEqual(result.exit_code, 0, msg=result.output) 

1248 self.assertEqual( 

1249 result.output.strip(), 

1250 MockProvenanceQuantumGraph( 

1251 repo_or_filename=repo_or_filename, 

1252 collection=collection, 

1253 datasets=[], 

1254 writeable=False, 

1255 make_many_reports_args=MakeManyReportsArgs(**kwargs), 

1256 ).model_dump_json(indent=2), 

1257 ) 

1258 

1259 self.maxDiff = None 

1260 runner = CliRunner() 

1261 with unittest.mock.patch( 

1262 "lsst.pipe.base.quantum_graph.ProvenanceQuantumGraph", MockProvenanceQuantumGraph 

1263 ): 

1264 check( 

1265 runner.invoke(provenance_report_cli, ("repo", "collection1")), 

1266 repo_or_filename="repo", 

1267 collection="collection1", 

1268 ) 

1269 check( 

1270 runner.invoke(provenance_report_cli, ("filename1",)), 

1271 repo_or_filename="filename1", 

1272 collection=None, 

1273 ) 

1274 check( 

1275 runner.invoke(provenance_report_cli, ("repo", "collection1", "--no-quantum-table")), 

1276 repo_or_filename="repo", 

1277 collection="collection1", 

1278 print_quantum_table=False, 

1279 ) 

1280 check( 

1281 runner.invoke(provenance_report_cli, ("repo", "collection1", "--no-exception-table")), 

1282 repo_or_filename="repo", 

1283 collection="collection1", 

1284 print_exception_table=False, 

1285 ) 

1286 check( 

1287 runner.invoke(provenance_report_cli, ("repo", "collection1", "--status-report", "filename2")), 

1288 repo_or_filename="repo", 

1289 collection="collection1", 

1290 status_report_file="filename2", 

1291 ) 

1292 check( 

1293 runner.invoke(provenance_report_cli, ("repo", "collection1", "--state", "SUCCESSFUL")), 

1294 repo_or_filename="repo", 

1295 collection="collection1", 

1296 states=[ 

1297 QuantumAttemptStatus.SUCCESSFUL, 

1298 QuantumAttemptStatus.FAILED, 

1299 QuantumAttemptStatus.ABORTED, 

1300 QuantumAttemptStatus.ABORTED_SUCCESS, 

1301 ], 

1302 ) 

1303 check( 

1304 runner.invoke(provenance_report_cli, ("repo", "collection1", "--no-state", "FAILED")), 

1305 repo_or_filename="repo", 

1306 collection="collection1", 

1307 states=[ 

1308 QuantumAttemptStatus.ABORTED, 

1309 QuantumAttemptStatus.ABORTED_SUCCESS, 

1310 ], 

1311 ) 

1312 check( 

1313 runner.invoke( 

1314 provenance_report_cli, ("repo", "collection1", "--caveat", "PARTIAL_OUTPUTS_ERROR") 

1315 ), 

1316 repo_or_filename="repo", 

1317 collection="collection1", 

1318 with_caveats=QuantumSuccessCaveats.PARTIAL_OUTPUTS_ERROR, 

1319 states=[ 

1320 QuantumAttemptStatus.SUCCESSFUL, 

1321 QuantumAttemptStatus.FAILED, 

1322 QuantumAttemptStatus.ABORTED, 

1323 QuantumAttemptStatus.ABORTED_SUCCESS, 

1324 ], 

1325 ) 

1326 check( 

1327 runner.invoke(provenance_report_cli, ("repo", "collection1", "--data-id-table-dir", "dir1")), 

1328 repo_or_filename="repo", 

1329 collection="collection1", 

1330 data_id_table_dir="dir1", 

1331 ) 

1332 

1333 

1334if __name__ == "__main__": 

1335 lsst.utils.tests.init() 

1336 unittest.main()