Coverage for tests/test_aggregator.py: 100%
528 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 08:59 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 08:59 +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/>.
28from __future__ import annotations
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
43import astropy.table
44import click.testing
45import numpy as np
46import pydantic
47from click.testing import CliRunner, Result
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
92@dataclasses.dataclass
93class PrepInfo:
94 """Struct of objects used in an aggregator test."""
96 butler: Butler
97 butler_path: str
98 predicted: PredictedQuantumGraph
99 predicted_path: str
100 config: AggregatorConfig
103class AggregatorTestCase(unittest.TestCase):
104 """Unit tests for `lsst.pipe.base.quantum_graph.aggregator`."""
106 @staticmethod
107 @contextmanager
108 def make_test_repo() -> Iterator[PrepInfo]:
109 """Make a test data repository and predicted quantum graph.
111 Returns
112 -------
113 prep_info : `PrepInfo`
114 Objects used in aggregator tests.
116 Notes
117 -----
118 The pipeline graph used by this task looks like this:
120 ■ calibrate: {detector, visit}
121 ╭─┤
122 ■ │ consolidate: {visit}
123 │
124 ■ resample: {patch, visit}
125 │
126 ■ coadd: {band, patch}
128 The data can be visualized via::
130 python -m lsst.daf.butler.tests.registry_data.spatial
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 )
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.
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.
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
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`.
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.
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
520 def _expect_all_exist(self, existence: list[bool], msg: str) -> None:
521 self.assertTrue(all(existence), msg=msg)
523 def _expect_none_exist(self, existence: list[bool], msg: str) -> None:
524 self.assertFalse(any(existence), msg=msg)
526 def _expect_one_missing(self, existence: list[bool], msg: str) -> None:
527 self.assertEqual(existence.count(False), 1, msg=msg)
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)
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)
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)
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.
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.
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
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.
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())
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.
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())
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)
705 def check_packages(self, butler: Butler) -> None:
706 """Check fetching package versions from the provenance graph.
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)
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.
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))
752 def check_quantum_table(self, prov: ProvenanceQuantumGraph, expect_failure: bool) -> None:
753 """Check `ProvenanceQuantumGraph.make_quantum_table`.
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)
803 def check_exception_table(self, prov: ProvenanceQuantumGraph, expect_failure: bool) -> None:
804 """Check `ProvenanceQuantumGraph.make_exception_table`.
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)])
820 def check_report(self, prov: ProvenanceQuantumGraph, expect_failure: bool) -> None:
821 """Check `ProvenanceQuantumGraph.make_report`.
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])
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])
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])
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])
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])
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)
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])
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)
1043 def test_aggregate_graph_cli_overrides(self) -> None:
1044 """Test that command-line options override config attributes as
1045 expected.
1046 """
1048 def mock_run(predicted_path: str, butler_path: str, config: AggregatorConfig) -> None:
1049 print(config.model_dump_json(indent=2))
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 )
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 )
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 )
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)
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.")
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))
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)
1196 ingest_graph(prep.butler_path, prep.config.output_path, transfer="move", batch_size=10)
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))
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))
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)
1240 def test_provenance_report_cli_overrides(self) -> None:
1241 """Test the provenance-report CLI command with a mocked
1242 implementation.
1243 """
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
1259 @pydantic.model_validator(mode="after")
1260 def _sort_states(self) -> MakeManyReportsArgs:
1261 self.states.sort(key=lambda e: e.value)
1262 return self
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
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
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))
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 )
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 )
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"]))
1382if __name__ == "__main__":
1383 lsst.utils.tests.init()
1384 unittest.main()