Coverage for tests/test_aggregator.py: 100%

528 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-20 09:01 +0000

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 json 

33import os 

34import re 

35import tempfile 

36import time 

37import unittest.mock 

38import uuid 

39from collections.abc import Iterator 

40from contextlib import contextmanager 

41from typing import Any, cast 

42 

43import astropy.table 

44import click.testing 

45import numpy as np 

46import pydantic 

47from click.testing import CliRunner, Result 

48 

49import lsst.utils.tests 

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

51from lsst.pex.config import Config 

52from lsst.pipe.base import ( 

53 AlgorithmError, 

54 QuantumAttemptStatus, 

55 QuantumSuccessCaveats, 

56 TaskMetadata, 

57) 

58from lsst.pipe.base import automatic_connection_constants as acc 

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

60 aggregate_graph as aggregate_graph_cli, 

61) 

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

63 provenance_report as provenance_report_cli, 

64) 

65from lsst.pipe.base.graph_walker import GraphWalker 

66from lsst.pipe.base.pipeline_graph import Edge 

67from lsst.pipe.base.quantum_graph import ( 

68 FORMAT_VERSION, 

69 PredictedDatasetInfo, 

70 PredictedQuantumGraph, 

71 PredictedQuantumInfo, 

72 ProvenanceDatasetInfo, 

73 ProvenanceQuantumGraph, 

74 ProvenanceQuantumInfo, 

75 ProvenanceQuantumReport, 

76 ProvenanceReport, 

77 ProvenanceTaskMetadataModel, 

78) 

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

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

81from lsst.pipe.base.resource_usage import QuantumResourceUsage 

82from lsst.pipe.base.single_quantum_executor import SingleQuantumExecutor 

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

84 DirectButlerRepo, 

85 DynamicConnectionConfig, 

86 DynamicTestPipelineTaskConfig, 

87) 

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

89from lsst.utils.packages import Packages 

90 

91 

92@dataclasses.dataclass 

93class PrepInfo: 

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

95 

96 butler: Butler 

97 butler_path: str 

98 predicted: PredictedQuantumGraph 

99 predicted_path: str 

100 config: AggregatorConfig 

101 

102 

103class AggregatorTestCase(unittest.TestCase): 

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

105 

106 @staticmethod 

107 @contextmanager 

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

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

110 

111 Returns 

112 ------- 

113 prep_info : `PrepInfo` 

114 Objects used in aggregator tests. 

115 

116 Notes 

117 ----- 

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

119 

120 ■ calibrate: {detector, visit} 

121 ╭─┤ 

122 ■ │ consolidate: {visit} 

123 │ 

124 ■ resample: {patch, visit} 

125 │ 

126 ■ coadd: {band, patch} 

127 

128 The data can be visualized via:: 

129 

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

131 

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

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

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

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

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

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

138 'coadd' tasks, too. 

139 """ 

140 with patch_deterministic_uuid4(100): 

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

142 calibrate_config = DynamicTestPipelineTaskConfig() 

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

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

145 helper.add_task( 

146 "calibrate", 

147 config=calibrate_config, 

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

149 inputs={ 

150 "input_image": DynamicConnectionConfig( 

151 dataset_type_name="raw", 

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

153 ) 

154 }, 

155 prerequisite_inputs={ 

156 "refcat": DynamicConnectionConfig( 

157 dataset_type_name="references", 

158 dimensions=["htm7"], 

159 multiple=True, 

160 ) 

161 }, 

162 init_outputs={ 

163 "output_schema": DynamicConnectionConfig( 

164 dataset_type_name="source_schema", 

165 ) 

166 }, 

167 outputs={ 

168 "output_image": DynamicConnectionConfig( 

169 dataset_type_name="image", 

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

171 ), 

172 "output_table": DynamicConnectionConfig( 

173 dataset_type_name="source_detector", 

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

175 ), 

176 }, 

177 ) 

178 helper.add_task( 

179 "consolidate", 

180 dimensions=["visit"], 

181 init_inputs={ 

182 "input_schema": DynamicConnectionConfig( 

183 dataset_type_name="source_schema", 

184 ) 

185 }, 

186 inputs={ 

187 "input_table": DynamicConnectionConfig( 

188 dataset_type_name="source_detector", 

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

190 multiple=True, 

191 ) 

192 }, 

193 outputs={ 

194 "output_table": DynamicConnectionConfig( 

195 dataset_type_name="source", 

196 dimensions=["visit"], 

197 ) 

198 }, 

199 ) 

200 helper.add_task( 

201 "resample", 

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

203 inputs={ 

204 "input_image": DynamicConnectionConfig( 

205 dataset_type_name="image", 

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

207 multiple=True, 

208 ) 

209 }, 

210 outputs={ 

211 "output_image": DynamicConnectionConfig( 

212 dataset_type_name="warp", 

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

214 ) 

215 }, 

216 ) 

217 helper.add_task( 

218 "coadd", 

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

220 inputs={ 

221 "input_image": DynamicConnectionConfig( 

222 dataset_type_name="warp", 

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

224 multiple=True, 

225 ) 

226 }, 

227 outputs={ 

228 "output_image": DynamicConnectionConfig( 

229 dataset_type_name="coadd", 

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

231 ), 

232 }, 

233 ) 

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

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

236 # a convenient temporary directory. 

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

238 pqgc.write(predicted_path) 

239 config = AggregatorConfig( 

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

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

242 # affect large graphs. 

243 ingest_batch_size=10, 

244 zstd_dict_size=256, 

245 zstd_dict_n_inputs=16, 

246 ) 

247 yield PrepInfo( 

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

249 butler_path=root, 

250 predicted=pqgc.assemble(), 

251 predicted_path=predicted_path, 

252 config=config, 

253 ) 

254 

255 def iter_graph_execution( 

256 self, 

257 repo: str, 

258 qg: PredictedQuantumGraph, 

259 raise_on_partial_outputs: bool, 

260 is_retry: bool = False, 

261 ) -> Iterator[uuid.UUID]: 

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

263 

264 Parameters 

265 ---------- 

266 repo : `str` 

267 Butler repository path or alias. 

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

269 Predicted quantum graph. Must have datastore records attached, 

270 since execution uses a quantum-backed butler. 

271 raise_on_partial_outputs : `bool` 

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

273 or treat it as a success with caveats. 

274 is_retry : `bool`, optional 

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

276 already be present; skip successes and reprocess failures. 

277 

278 Returns 

279 ------- 

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

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

282 """ 

283 qbb = qg.make_init_qbb(repo) 

284 self.enterContext(qbb) 

285 qg.init_output_run(qbb) 

286 sqe = SingleQuantumExecutor( 

287 limited_butler_factory=lambda quantum: QuantumBackedButler.initialize( 

288 repo, 

289 quantum, 

290 qg.pipeline_graph.universe, 

291 ), 

292 assume_no_existing_outputs=not is_retry, 

293 skip_existing=is_retry, 

294 clobber_outputs=is_retry, 

295 raise_on_partial_outputs=raise_on_partial_outputs, 

296 ) 

297 qg.build_execution_quanta() 

298 xgraph = qg.quantum_only_xgraph 

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

300 for ready in walker: 

301 for quantum_id in ready: 

302 info = xgraph.nodes[quantum_id] 

303 try: 

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

305 except AlgorithmError: 

306 walker.fail(quantum_id) 

307 else: 

308 walker.finish(quantum_id) 

309 yield quantum_id 

310 

311 def check_provenance_graph( 

312 self, 

313 pred: PredictedQuantumGraph, 

314 butler: Butler, 

315 expect_failure: bool, 

316 start_time: float, 

317 expect_failures_retried: bool = False, 

318 ) -> ProvenanceQuantumGraph: 

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

320 scanning the graph created by `make_test_repo`. 

321 

322 Parameters 

323 ---------- 

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

325 Predicted quantum graph. 

326 prov_reader : \ 

327 `lsst.pipe.base.quantum_graph.ProvenanceQuantumGraphReader` 

328 Reader for the provenance quantum graph. 

329 butler : `lsst.daf.butler.Butler` 

330 Client for the data repository. 

331 expect_failure : `bool` 

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

333 succeed without writing anything (`False`). 

334 start_time : `float` 

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

336 quantum's execution. 

337 expect_failures_retried : `bool`, optional 

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

339 most recent attempt. 

340 

341 Returns 

342 ------- 

343 prov : `ProvenanceQuantumGraph` 

344 The full provenance quantum graph. 

345 """ 

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

347 self.maxDiff = None 

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

349 self.assertEqual( 

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

351 [prov.header.output_run] 

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

353 ) 

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

355 for task_label in pred.quanta_by_task: 

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

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

358 for dataset_type_name in prov.datasets_by_type: 

359 self.assertEqual( 

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

361 ) 

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

363 for quantum_id in pred: 

364 # Check consistency between the predicted and provenance quantum 

365 # node attributes. 

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

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

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

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

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

371 # Check consistency between the predicted and provenance dataset 

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

373 # use later. 

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

375 pipeline_edges: list[Edge] 

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

377 quantum_id, data="pipeline_edges" 

378 ): 

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

380 for edge in pipeline_edges: 

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

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

383 ) 

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

385 quantum_id, data="pipeline_edges" 

386 ): 

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

388 for edge in pipeline_edges: 

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

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

391 ) 

392 # Check quantum status and dataset existence against the known 

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

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

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

396 # This is the quantum that can directly raise. 

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

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

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

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

401 if expect_failure: 

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

403 else: 

404 self._expect_successful( 

405 prov_qinfo, 

406 existence, 

407 caveats=( 

408 QuantumSuccessCaveats.PARTIAL_OUTPUTS_ERROR 

409 | QuantumSuccessCaveats.ALL_OUTPUTS_MISSING 

410 | QuantumSuccessCaveats.ANY_OUTPUTS_MISSING 

411 ), 

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

413 msg=msg, 

414 ) 

415 if expect_failures_retried: 

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

417 self.assertEqual( 

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

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

420 ) 

421 else: 

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

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

424 # This quantum will succeed (with one predicted input 

425 # missing) or be blocked. 

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

427 if expect_failure: 

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

429 else: 

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

431 self.assertEqual( 

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

433 ) 

434 case ( 

435 "resample" | "coadd", 

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

437 ): 

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

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

440 # run. 

441 if expect_failure: 

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

443 else: 

444 self._expect_successful( 

445 prov_qinfo, 

446 existence, 

447 caveats=( 

448 QuantumSuccessCaveats.ADJUST_QUANTUM_RAISED 

449 | QuantumSuccessCaveats.NO_WORK 

450 | QuantumSuccessCaveats.ALL_OUTPUTS_MISSING 

451 | QuantumSuccessCaveats.ANY_OUTPUTS_MISSING 

452 ), 

453 msg=msg, 

454 ) 

455 self.assertEqual( 

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

457 ) 

458 case ( 

459 "resample", 

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

461 ): 

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

463 # regardless. 

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

465 if expect_failure: 

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

467 else: 

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

469 self.assertEqual( 

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

471 ) 

472 case ( 

473 "coadd", 

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

475 ): 

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

477 # with one input missing. 

478 if expect_failure: 

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

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

481 else: 

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

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

484 self.assertEqual( 

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

486 ) 

487 case _: 

488 # All other quanta should succeed and have all inputs 

489 # present. 

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

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

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

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

494 self.check_metadata( 

495 quantum_id, 

496 prov, 

497 butler, 

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

499 ) 

500 self.check_log( 

501 quantum_id, 

502 prov, 

503 butler, 

504 expect_ingested=( 

505 prov_qinfo["status"] 

506 in ( 

507 QuantumAttemptStatus.SUCCESSFUL, 

508 QuantumAttemptStatus.FAILED, 

509 ) 

510 ), 

511 ) 

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

513 self.check_packages(butler) 

514 self.check_configs(butler, prov) 

515 self.check_quantum_table(prov, expect_failure=expect_failure) 

516 self.check_exception_table(prov, expect_failure=expect_failure) 

517 self.check_report(prov, expect_failure=expect_failure) 

518 return prov 

519 

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

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

522 

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

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

525 

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

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

528 

529 def _expect_successful( 

530 self, 

531 info: ProvenanceQuantumInfo, 

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

533 caveats: QuantumSuccessCaveats = QuantumSuccessCaveats.NO_CAVEATS, 

534 exception_type: str | None = None, 

535 *, 

536 msg: str, 

537 ) -> None: 

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

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

540 if exception_type is None: 

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

542 else: 

543 assert info["exception"] is not None 

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

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

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

547 if not (caveats & QuantumSuccessCaveats.ANY_OUTPUTS_MISSING): 

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

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

550 if caveats & QuantumSuccessCaveats.ALL_OUTPUTS_MISSING: 

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

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

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

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

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

556 

557 def _expect_failure( 

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

559 ) -> None: 

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

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

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

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

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

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

566 

567 def _expect_blocked( 

568 self, 

569 info: ProvenanceQuantumInfo, 

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

571 msg: str, 

572 ) -> None: 

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

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

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

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

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

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

579 

580 def check_dataset( 

581 self, 

582 dataset_id: uuid.UUID, 

583 pred: PredictedQuantumGraph, 

584 prov: ProvenanceQuantumGraph, 

585 butler: Butler, 

586 ) -> bool: 

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

588 counterpart. 

589 

590 Parameters 

591 ---------- 

592 dataset_id : `uuid.UUID` 

593 Unique ID for the dataset. 

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

595 Predicted quantum graph. 

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

597 Provenance quantum graph. 

598 butler : `lsst.daf.butler.Butler` 

599 Client for the data repository. 

600 

601 Returns 

602 ------- 

603 exists : `bool` 

604 Whether the dataset was marked as existing in the provenance 

605 quantum graph. 

606 """ 

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

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

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

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

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

612 exists = prov_info["produced"] 

613 dataset_type_name = prov_info["dataset_type_name"] 

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

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

616 self.assertEqual( 

617 butler.get_dataset(dataset_id) is not None, 

618 exists, 

619 msg=( 

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

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

622 ), 

623 ) 

624 return exists 

625 

626 def check_metadata( 

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

628 ) -> None: 

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

630 original metadata file has been deleted. 

631 

632 Parameters 

633 ---------- 

634 quantum_id : `uuid.UUID` 

635 Unique ID for the quantum this metadata belongs to. 

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

637 Provenance quantum graph. 

638 butler : `lsst.daf.butler.Butler` 

639 Client for the data repository. 

640 expect_ingested : `bool` 

641 Whether the metadata dataset should have been ingested. 

642 """ 

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

644 ref = butler.get_dataset(dataset_id) 

645 if not expect_ingested: 

646 self.assertIsNone(ref) 

647 return 

648 assert ref is not None 

649 metadata = butler.get(ref) 

650 self.assertIsInstance(metadata, TaskMetadata) 

651 graph_path = butler.getURI("run_provenance") 

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

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

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

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

656 # it's shared with other datasets. 

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

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

659 self.assertTrue(graph_path.exists()) 

660 self.assertNotEqual(graph_path, original_path) 

661 self.assertFalse(original_path.exists()) 

662 

663 def check_log( 

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

665 ) -> None: 

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

667 original log file has been deleted. 

668 

669 Parameters 

670 ---------- 

671 quantum_id : `uuid.UUID` 

672 Unique ID for the quantum this log belongs to. 

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

674 Provenance quantum graph. 

675 butler : `lsst.daf.butler.Butler` 

676 Client for the data repository. 

677 expect_ingested : `bool` 

678 Whether the metadata dataset should have been ingested. 

679 """ 

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

681 ref = butler.get_dataset(dataset_id) 

682 if not expect_ingested: 

683 self.assertIsNone(ref) 

684 return 

685 assert ref is not None 

686 log = butler.get(ref) 

687 self.assertIsInstance(log, ButlerLogRecords) 

688 graph_path = butler.getURI("run_provenance") 

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

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

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

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

693 # it's shared with other datasets. 

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

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

696 self.assertTrue(graph_path.exists()) 

697 self.assertNotEqual(graph_path, original_path) 

698 self.assertFalse(original_path.exists()) 

699 

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

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

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

703 self.assertIsInstance(config, Config) 

704 

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

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

707 

708 Parameters 

709 ---------- 

710 butler : `lsst.daf.butler.Butler` 

711 Client for the data repository. 

712 """ 

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

714 self.assertIsInstance(packages, Packages) 

715 self.assertIn("pipe_base", packages) 

716 

717 def check_resource_usage_table( 

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

719 ) -> None: 

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

721 

722 Parameters 

723 ---------- 

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

725 Reader for the provenance quantum graph. 

726 expect_failure : `bool` 

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

728 succeed without writing anything (`False`). 

729 start_time : `float` 

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

731 quantum's execution. 

732 """ 

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

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

735 self.assertCountEqual( 

736 tbl.colnames, 

737 ["quantum_id"] 

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

739 + list(QuantumResourceUsage.model_fields), 

740 ) 

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

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

743 # shenanigans. 

744 end_time = time.time() 

745 for quantum_start_time in tbl["start"]: 

746 self.assertGreater(quantum_start_time, start_time) 

747 self.assertLess(quantum_start_time, end_time) 

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

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

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

751 

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

753 """Check `ProvenanceQuantumGraph.make_quantum_table`. 

754 

755 Parameters 

756 ---------- 

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

758 Reader for the provenance quantum graph. 

759 expect_failure : `bool` 

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

761 succeed without writing anything (`False`). 

762 """ 

763 t = prov.make_quantum_table() 

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

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

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

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

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

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

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

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

772 if expect_failure: 

773 # calibrate 

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

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

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

777 # consolidate 

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

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

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

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

782 # resample 

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

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

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

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

787 else: 

788 # calibrate 

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

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

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

792 # consolidate 

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

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

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

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

797 # resample 

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

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

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

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

802 

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

804 """Check `ProvenanceQuantumGraph.make_exception_table`. 

805 

806 Parameters 

807 ---------- 

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

809 Reader for the provenance quantum graph. 

810 expect_failure : `bool` 

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

812 succeed without writing anything (`False`). 

813 """ 

814 t = prov.make_exception_table() 

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

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

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

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

819 

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

821 """Check `ProvenanceQuantumGraph.make_report`. 

822 

823 Parameters 

824 ---------- 

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

826 Reader for the provenance quantum graph. 

827 expect_failure : `bool` 

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

829 succeed without writing anything (`False`). 

830 """ 

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

832 report = prov.make_status_report( 

833 also=QuantumAttemptStatus.SUCCESSFUL, data_id_table_dir=data_id_table_dir 

834 ) 

835 task_label = "calibrate" 

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

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

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

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

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

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

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

843 self.assertIsInstance(qr, ProvenanceQuantumReport) 

844 self.assertEqual( 

845 qr.data_id, 

846 { 

847 "instrument": "Cam1", 

848 "visit": 2, 

849 "detector": 2, 

850 "band": "r", 

851 "day_obs": 20210909, 

852 "physical_filter": "Cam1-R1", 

853 }, 

854 ) 

855 tbl = astropy.table.Table.read( 

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

857 ) 

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

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

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

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

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

863 

864 def test_all_successful(self) -> None: 

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

866 results with incomplete=False. 

867 """ 

868 with self.make_test_repo() as prep: 

869 prep.config.incomplete = False 

870 start_time = time.time() 

871 attempted_quanta = list( 

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

873 ) 

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

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

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

877 prov = self.check_provenance_graph( 

878 prep.predicted, 

879 prep.butler, 

880 expect_failure=False, 

881 start_time=start_time, 

882 ) 

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

884 for i, quantum_id in enumerate(attempted_quanta): 

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

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

887 

888 def test_all_successful_two_phase(self) -> None: 

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

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

891 """ 

892 with self.make_test_repo() as prep: 

893 start_time = time.time() 

894 execution_iter = self.iter_graph_execution( 

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

896 ) 

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

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

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

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

901 # QG. 

902 prep.config.incomplete = True 

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

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

905 # Finish executing the quanta. 

906 attempted_quanta.extend(execution_iter) 

907 # Scan again, and write the provenance QG. 

908 prep.config.incomplete = False 

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

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

911 prov = self.check_provenance_graph( 

912 prep.predicted, 

913 prep.butler, 

914 expect_failure=False, 

915 start_time=start_time, 

916 ) 

917 for i, quantum_id in enumerate(attempted_quanta): 

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

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

920 

921 def test_some_failed(self) -> None: 

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

923 results with incomplete=False. 

924 """ 

925 with self.make_test_repo() as prep: 

926 prep.config.incomplete = False 

927 start_time = time.time() 

928 attempted_quanta = list( 

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

930 ) 

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

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

933 prov = self.check_provenance_graph( 

934 prep.predicted, 

935 prep.butler, 

936 expect_failure=True, 

937 start_time=start_time, 

938 ) 

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

940 for i, quantum_id in enumerate(attempted_quanta): 

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

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

943 

944 def test_some_failed_two_phase(self) -> None: 

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

946 results with incomplete=True, then scanning again with 

947 incomplete=False. 

948 """ 

949 with self.make_test_repo() as prep: 

950 start_time = time.time() 

951 attempted_quanta = list( 

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

953 ) 

954 prep.config.incomplete = True 

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

956 prep.config.incomplete = False 

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

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

959 prov = self.check_provenance_graph( 

960 prep.predicted, 

961 prep.butler, 

962 expect_failure=True, 

963 start_time=start_time, 

964 ) 

965 for i, quantum_id in enumerate(attempted_quanta): 

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

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

968 

969 def test_retry(self) -> None: 

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

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

972 for provenance. 

973 """ 

974 with self.make_test_repo() as prep: 

975 start_time = time.time() 

976 attempted_quanta_1 = list( 

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

978 ) 

979 attempted_quanta_2 = list( 

980 self.iter_graph_execution( 

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

982 ) 

983 ) 

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

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

986 prov = self.check_provenance_graph( 

987 prep.predicted, 

988 prep.butler, 

989 expect_failure=False, 

990 start_time=start_time, 

991 expect_failures_retried=True, 

992 ) 

993 for i, quantum_id in enumerate(attempted_quanta_1): 

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

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

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

997 for quantum_id in attempted_quanta_2: 

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

999 if ( 

1000 quantum_id in attempted_quanta_1 

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

1002 ): 

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

1004 # were already successful in the first round. 

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

1006 else: 

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

1008 expected.append(quantum_id) 

1009 

1010 def test_promise_ingest_graph(self) -> None: 

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

1012 with self.make_test_repo() as prep: 

1013 prep.config.incomplete = False 

1014 prep.config.promise_ingest_graph = True 

1015 start_time = time.time() 

1016 attempted_quanta = list( 

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

1018 ) 

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

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

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

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

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

1024 prov = self.check_provenance_graph( 

1025 prep.predicted, 

1026 prep.butler, 

1027 expect_failure=True, 

1028 start_time=start_time, 

1029 ) 

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

1031 for i, quantum_id in enumerate(attempted_quanta): 

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

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

1034 

1035 def test_worker_failures(self) -> None: 

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

1037 gracefully instead of hanging. 

1038 """ 

1039 with self.make_test_repo() as prep: 

1040 with self.assertRaises(FatalWorkerError): 

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

1042 

1043 def test_aggregate_graph_cli_overrides(self) -> None: 

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

1045 expected. 

1046 """ 

1047 

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

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

1050 

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

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

1053 self.assertEqual( 

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

1055 ) 

1056 

1057 self.maxDiff = None 

1058 runner = CliRunner() 

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

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

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

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

1063 check( 

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

1065 incomplete=True, 

1066 ) 

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

1068 check( 

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

1070 interactive_status=True, 

1071 ) 

1072 check( 

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

1074 log_status_interval=120, 

1075 ) 

1076 check( 

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

1078 register_dataset_types=False, 

1079 ) 

1080 check( 

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

1082 update_output_chain=False, 

1083 ) 

1084 check( 

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

1086 worker_log_dir="wlogs", 

1087 ) 

1088 check( 

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

1090 worker_log_level="DEBUG", 

1091 ) 

1092 check( 

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

1094 zstd_level=11, 

1095 ) 

1096 check( 

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

1098 zstd_dict_size=143, 

1099 ) 

1100 check( 

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

1102 zstd_dict_n_inputs=2, 

1103 ) 

1104 check( 

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

1106 mock_storage_classes=True, 

1107 ) 

1108 

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

1110 self.maxDiff = None 

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

1112 self.assertRegex( 

1113 result.output.replace("\n", ""), 

1114 re.compile( 

1115 r"\s*Task\s+Caveats\s+Failed\s+Blocked\s+Successful\s+TOTAL EXPECTED" 

1116 r"[-\s]*" 

1117 r"\s*calibrate \s* 1 \s* 0 \s* 7 \s* 8 \s* 8" 

1118 r"\s*consolidate \s* 0 \s* 1 \s* 1 \s* 2 \s* 2" 

1119 r"\s*resample \s* 0 \s* 4 \s* 6 \s* 10 \s* 10" 

1120 r"\s*coadd \s* 0 \s* 4 \s* 6 \s* 10 \s* 10" 

1121 r".*(Caveats[-\s]*)?.*" 

1122 r"\s*Task\s+Exception\s+Successes\s+Failures" 

1123 r"[-\s]*" 

1124 r"calibrate\s*lsst.pipe.base.tests.mocks.MockAlgorithmError \s* 0 \s* 1" 

1125 ), 

1126 ) 

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

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

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

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

1131 self.assertEqual( 

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

1133 ) 

1134 self.assertEqual( 

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

1136 ) 

1137 self.assertEqual( 

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

1139 [ 

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

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

1142 ( 

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

1144 [], 

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

1146 ), 

1147 ], 

1148 ) 

1149 

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

1151 """Check that the json output is valid and has the correct number of 

1152 rows. 

1153 """ 

1154 loaded_json = json.loads(result.output) 

1155 self.assertEqual(len(loaded_json["tasks"]), 4) 

1156 self.assertEqual(len(loaded_json["exceptions"]), 1) 

1157 

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

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

1160 the butler's directory for this output run. 

1161 """ 

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

1163 for subdir in os.listdir(root): 

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

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

1166 

1167 def test_provenance_report_content(self) -> None: 

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

1169 with self.make_test_repo() as prep: 

1170 prep.config.incomplete = False 

1171 prep.config.promise_ingest_graph = True 

1172 for _ in self.iter_graph_execution( 

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

1174 ): 

1175 pass 

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

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

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

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

1180 

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

1182 runner = CliRunner() 

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

1184 result = runner.invoke( 

1185 provenance_report_cli, 

1186 ( 

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

1188 "--status-report", 

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

1190 "--data-id-table-dir", 

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

1192 ), 

1193 ) 

1194 self.check_provenance_report(result, report_root) 

1195 

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

1197 

1198 runner = CliRunner() 

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

1200 result = runner.invoke( 

1201 provenance_report_cli, 

1202 ( 

1203 prep.butler_path, 

1204 *prep.butler.collections.defaults, 

1205 "--status-report", 

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

1207 "--data-id-table-dir", 

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

1209 ), 

1210 ) 

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

1212 

1213 def test_provenance_report_cli_json_output(self) -> None: 

1214 """Test the json output option with a mocked implementation.""" 

1215 with self.make_test_repo() as prep: 

1216 prep.config.incomplete = False 

1217 prep.config.promise_ingest_graph = True 

1218 for _ in self.iter_graph_execution( 

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

1220 ): 

1221 pass 

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

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

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

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

1226 

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

1228 runner = CliRunner() 

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

1230 result = runner.invoke( 

1231 provenance_report_cli, 

1232 ( 

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

1234 "--format", 

1235 "json", 

1236 ), 

1237 ) 

1238 self.check_provenance_report_json(result, report_root) 

1239 

1240 def test_provenance_report_cli_overrides(self) -> None: 

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

1242 implementation. 

1243 """ 

1244 

1245 class MakeManyReportsArgs(pydantic.BaseModel): 

1246 status_report_file: str | None = None 

1247 print_quantum_table: bool = True 

1248 print_exception_table: bool = True 

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

1250 default_factory=lambda: [ 

1251 QuantumAttemptStatus.FAILED, 

1252 QuantumAttemptStatus.ABORTED, 

1253 QuantumAttemptStatus.ABORTED_SUCCESS, 

1254 ] 

1255 ) 

1256 with_caveats: QuantumSuccessCaveats | None = None 

1257 data_id_table_dir: str | None = None 

1258 

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

1260 def _sort_states(self) -> MakeManyReportsArgs: 

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

1262 return self 

1263 

1264 class MockProvenanceQuantumGraph(pydantic.BaseModel): 

1265 repo_or_filename: str 

1266 collection: str | None 

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

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

1269 writeable: bool = False 

1270 make_many_reports_args: MakeManyReportsArgs | None = None 

1271 

1272 @classmethod 

1273 @contextmanager 

1274 def from_args( 

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

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

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

1278 

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

1280 self.make_many_reports_args = MakeManyReportsArgs(**kwargs) 

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

1282 

1283 def check( 

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

1285 ) -> None: 

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

1287 self.assertEqual( 

1288 result.output.strip(), 

1289 MockProvenanceQuantumGraph( 

1290 repo_or_filename=repo_or_filename, 

1291 collection=collection, 

1292 datasets=[], 

1293 writeable=False, 

1294 make_many_reports_args=MakeManyReportsArgs(**kwargs), 

1295 ).model_dump_json(indent=2), 

1296 ) 

1297 

1298 self.maxDiff = None 

1299 runner = CliRunner() 

1300 with unittest.mock.patch( 

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

1302 ): 

1303 check( 

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

1305 repo_or_filename="repo", 

1306 collection="collection1", 

1307 ) 

1308 check( 

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

1310 repo_or_filename="filename1", 

1311 collection=None, 

1312 ) 

1313 check( 

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

1315 repo_or_filename="repo", 

1316 collection="collection1", 

1317 print_quantum_table=False, 

1318 ) 

1319 check( 

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

1321 repo_or_filename="repo", 

1322 collection="collection1", 

1323 print_exception_table=False, 

1324 ) 

1325 check( 

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

1327 repo_or_filename="repo", 

1328 collection="collection1", 

1329 status_report_file="filename2", 

1330 ) 

1331 check( 

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

1333 repo_or_filename="repo", 

1334 collection="collection1", 

1335 states=[ 

1336 QuantumAttemptStatus.SUCCESSFUL, 

1337 QuantumAttemptStatus.FAILED, 

1338 QuantumAttemptStatus.ABORTED, 

1339 QuantumAttemptStatus.ABORTED_SUCCESS, 

1340 ], 

1341 ) 

1342 check( 

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

1344 repo_or_filename="repo", 

1345 collection="collection1", 

1346 states=[ 

1347 QuantumAttemptStatus.ABORTED, 

1348 QuantumAttemptStatus.ABORTED_SUCCESS, 

1349 ], 

1350 ) 

1351 check( 

1352 runner.invoke( 

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

1354 ), 

1355 repo_or_filename="repo", 

1356 collection="collection1", 

1357 with_caveats=QuantumSuccessCaveats.PARTIAL_OUTPUTS_ERROR, 

1358 states=[ 

1359 QuantumAttemptStatus.SUCCESSFUL, 

1360 QuantumAttemptStatus.FAILED, 

1361 QuantumAttemptStatus.ABORTED, 

1362 QuantumAttemptStatus.ABORTED_SUCCESS, 

1363 ], 

1364 ) 

1365 check( 

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

1367 repo_or_filename="repo", 

1368 collection="collection1", 

1369 data_id_table_dir="dir1", 

1370 ) 

1371 

1372 def test_bad_metadata_readable(self) -> None: 

1373 """Test that consolidated metadata accidentally written with floats 

1374 transformed to JSON null are now readable. 

1375 """ 

1376 with open(os.path.join(os.path.dirname(__file__), "data", "DM-54057.json")) as stream: 

1377 data = stream.read() 

1378 prov_md = ProvenanceTaskMetadataModel.model_validate_json(data) 

1379 self.assertTrue(np.isnan(prov_md.attempts[0]["calibrateImage:psf_measure_psf"]["spatialFitChi2"])) 

1380 

1381 

1382if __name__ == "__main__": 

1383 lsst.utils.tests.init() 

1384 unittest.main()