Coverage for tests/test_cli.py: 91%
365 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-29 09:58 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-29 09:58 +0000
1# This file is part of lsst-images.
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# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
11from __future__ import annotations
13import json
14import os
15import shutil
16import subprocess
17import sys
18from pathlib import Path
19from unittest import mock
21import astropy.io.fits
22import click
23import click.testing
24import numpy as np
25import pytest
26from cli_schema_doubles import CliFixtureDouble
27from click.testing import CliRunner
29from lsst.images import Box, Image
30from lsst.images import fits as images_fits
31from lsst.images import json as images_json
32from lsst.images.cli import main
33from lsst.images.serialization import backend_for_path, read_archive
34from lsst.images.tests import current_fixture_path
36FIXTURE_DIR = Path(__file__).parent / "data" / "schemas"
39def _copy_fixture_tree(tmp_path: Path, *, source: Path = FIXTURE_DIR, name: str = "schemas") -> Path:
40 """Copy a fixture tree so a mutating CLI test cannot touch its source."""
41 destination = tmp_path / name
42 shutil.copytree(source, destination)
43 return destination
46@pytest.fixture(scope="session")
47def external_data_dir() -> str:
48 """Return the external test-data directory path, skipping if unset."""
49 if (result := os.environ.get("TESTDATA_IMAGES_DIR")) is None: 49 ↛ 51line 49 didn't jump to line 51 because the condition on line 49 was always true
50 pytest.skip("TESTDATA_IMAGES_DIR is not set.")
51 return result
54def _make_cli_input(tmp_path: Path) -> str:
55 """Return the path to a minimal FITS file written under tmp_path."""
56 path = str(tmp_path / "in.fits")
57 astropy.io.fits.PrimaryHDU().writeto(path)
58 return path
61def _make_detect_file(tmp_path: Path, dataset_type: str | None) -> str:
62 """Return a path to a FITS file with LSST BUTLER DATASETTYPE set to
63 dataset_type.
64 """
65 name = dataset_type.replace(" ", "_") if dataset_type is not None else "none"
66 path = str(tmp_path / f"detect_{name}.fits")
67 hdu = astropy.io.fits.PrimaryHDU()
68 with images_fits.suppress_fits_card_warnings():
69 if dataset_type is not None:
70 hdu.header["LSST BUTLER DATASETTYPE"] = dataset_type
71 hdu.writeto(path)
72 return path
75def test_group_help() -> None:
76 """Test that the root CLI group loads and lists core subcommands."""
77 result = CliRunner().invoke(main, ["--help"])
78 assert result.exit_code == 0, result.output
79 assert "convert" in result.output
80 assert "inspect" in result.output
83def test_python_m_entry_point() -> None:
84 """Test that python -m lsst.images runs the same CLI group."""
85 result = subprocess.run(
86 [sys.executable, "-m", "lsst.images", "--help"],
87 capture_output=True,
88 text=True,
89 )
90 assert result.returncode == 0, result.stderr
91 assert "convert" in result.stdout
92 assert "inspect" in result.stdout
95def test_inspect_fits(tmp_path: Path) -> None:
96 """Test 'inspect' on a FITS file."""
97 path = str(tmp_path / "x.fits")
98 image = Image(np.zeros((4, 4), dtype=np.float32), bbox=Box.factory[0:4, 0:4])
99 images_fits.write(image, path)
100 result = CliRunner().invoke(main, ["inspect", path])
101 assert result.exit_code == 0, result.output
102 assert "https://images.lsst.io/schemas/image-1.0.0" in result.output
103 assert "format version: 1" in result.output
104 assert "python class:" in result.output
105 assert "lsst.images.Image" in result.output
108def test_inspect_json(tmp_path: Path) -> None:
109 """Test 'inspect' on a JSON file."""
110 path = str(tmp_path / "x.json")
111 image = Image(np.zeros((4, 4), dtype=np.float32), bbox=Box.factory[0:4, 0:4])
112 images_json.write(image, path)
113 result = CliRunner().invoke(main, ["inspect", path])
114 assert result.exit_code == 0, result.output
115 assert "image-1.0.0" in result.output
116 assert "n/a" in result.output
117 assert "python class:" in result.output
118 assert "lsst.images.Image" in result.output
121def test_inspect_unregistered_schema(tmp_path: Path) -> None:
122 """Test that 'inspect' succeeds and reports an unregistered schema name."""
123 path = str(tmp_path / "fake.json")
124 with open(path, "w") as f:
125 f.write(
126 '{"schema_url": "https://images.lsst.io/schemas/no-such-schema-99.0.0",'
127 ' "schema_version": "99.0.0", "min_read_version": 1, "indirect": []}'
128 )
129 result = CliRunner().invoke(main, ["inspect", path])
130 assert result.exit_code == 0, result.output
131 assert "python class:" in result.output
132 assert "<unregistered: no-such-schema>" in result.output
135def test_inspect_unknown_extension(tmp_path: Path) -> None:
136 """Test that 'inspect' fails with a non-zero exit code for an unsupported
137 file extension.
138 """
139 path = str(tmp_path / "x.txt")
140 with open(path, "w") as stream:
141 stream.write("nope")
142 result = CliRunner().invoke(main, ["inspect", path])
143 assert result.exit_code != 0
144 assert ".fits" in result.output
147def test_reformat_round_trip_json_fits_json(tmp_path: Path) -> None:
148 """Test that reformat JSON→FITS→JSON preserves the image data."""
149 image = Image(np.arange(16, dtype=np.float32).reshape(4, 4), bbox=Box.factory[0:4, 0:4])
150 src = str(tmp_path / "in.json")
151 mid = str(tmp_path / "mid.fits")
152 out = str(tmp_path / "out.json")
153 images_json.write(image, src)
155 result = CliRunner().invoke(main, ["reformat", src, mid])
156 assert result.exit_code == 0, result.output
157 assert backend_for_path(mid).input_archive.get_basic_info(mid).schema_name == "image"
159 result = CliRunner().invoke(main, ["reformat", mid, out])
160 assert result.exit_code == 0, result.output
162 np.testing.assert_array_equal(read_archive(out, Image).array, image.array)
165def test_reformat_refuses_existing_output(tmp_path: Path) -> None:
166 """Test that reformat refuses to overwrite an existing output file without
167 --overwrite.
168 """
169 image = Image(np.arange(16, dtype=np.float32).reshape(4, 4), bbox=Box.factory[0:4, 0:4])
170 src = str(tmp_path / "in.json")
171 out = str(tmp_path / "out.fits")
172 images_json.write(image, src)
173 images_fits.write(image, out)
174 result = CliRunner().invoke(main, ["reformat", src, out])
175 assert result.exit_code != 0
176 assert "--overwrite" in result.output
179def test_reformat_unknown_output_extension(tmp_path: Path) -> None:
180 """Test that reformat fails for an unsupported output file extension."""
181 image = Image(np.arange(16, dtype=np.float32).reshape(4, 4), bbox=Box.factory[0:4, 0:4])
182 src = str(tmp_path / "in.json")
183 images_json.write(image, src)
184 result = CliRunner().invoke(main, ["reformat", src, str(tmp_path / "out.txt")])
185 assert result.exit_code != 0
186 assert ".fits" in result.output
189def test_detect_visit_image(tmp_path: Path) -> None:
190 """Test that detect_legacy_type identifies visit_image and
191 preliminary_visit_image.
192 """
193 from lsst.images.cli._convert import detect_legacy_type
195 assert detect_legacy_type(_make_detect_file(tmp_path, "visit_image")) == "visit_image"
196 assert detect_legacy_type(_make_detect_file(tmp_path, "preliminary_visit_image")) == "visit_image"
199def test_detect_cell_coadd(tmp_path: Path) -> None:
200 """Test that detect_legacy_type identifies deep_coadd_cell_predetection as
201 cell_coadd.
202 """
203 from lsst.images.cli._convert import detect_legacy_type
205 assert detect_legacy_type(_make_detect_file(tmp_path, "deep_coadd_cell_predetection")) == "cell_coadd"
208def test_detect_indeterminate(tmp_path: Path) -> None:
209 """Test that detect_legacy_type returns None for unknown or absent dataset-
210 type headers.
211 """
212 from lsst.images.cli._convert import detect_legacy_type
214 assert detect_legacy_type(_make_detect_file(tmp_path, None)) is None
215 assert detect_legacy_type(_make_detect_file(tmp_path, "camera")) is None
218def test_detect_visit_image_fixture(tmp_path: Path, external_data_dir: str) -> None:
219 """Test that detect_legacy_type detects a real legacy visit-image fixture
220 file.
221 """
222 from lsst.images.cli._convert import detect_legacy_type
224 path = os.path.join(external_data_dir, "dp2", "legacy", "visit_image.fits")
225 assert detect_legacy_type(path) == "visit_image"
228def test_convert_visit_image_to_json(tmp_path: Path, external_data_dir: str) -> None:
229 """Test that convert produces a valid visit_image JSON file from a legacy
230 FITS fixture.
231 """
232 pytest.importorskip("lsst.afw.image")
233 src = os.path.join(external_data_dir, "dp2", "legacy", "visit_image.fits")
234 out = str(tmp_path / "converted.json")
235 result = CliRunner().invoke(main, ["convert", src, out])
236 assert result.exit_code == 0, result.output
237 info = backend_for_path(out).input_archive.get_basic_info(out)
238 assert info.schema_name == "visit_image"
241def test_convert_refuses_existing_output(tmp_path: Path, external_data_dir: str) -> None:
242 """Test that convert refuses to overwrite an existing output file without
243 --overwrite.
244 """
245 pytest.importorskip("lsst.afw.image")
246 src = os.path.join(external_data_dir, "dp2", "legacy", "visit_image.fits")
247 out = str(tmp_path / "exists.json")
248 with open(out, "w") as stream:
249 stream.write("{}")
250 result = CliRunner().invoke(main, ["convert", src, out])
251 assert result.exit_code != 0
252 assert "--overwrite" in result.output
255def test_convert_cell_coadd_to_json(tmp_path: Path, external_data_dir: str) -> None:
256 """Test that convert produces a valid cell_coadd JSON file from a legacy
257 FITS MultipleCellCoadd.
258 """
259 pytest.importorskip("lsst.cell_coadds")
260 legacy_dir = os.path.join(external_data_dir, "dp2", "legacy")
261 src = os.path.join(legacy_dir, "deep_coadd_cell_predetection.fits")
262 skymap = os.path.join(legacy_dir, "skyMap.pickle")
263 out = str(tmp_path / "coadd.json")
264 result = CliRunner().invoke(main, ["convert", src, out, "--type", "cell_coadd", "--skymap", skymap])
265 assert result.exit_code == 0, result.output
266 info = backend_for_path(out).input_archive.get_basic_info(out)
267 assert info.schema_name == "cell_coadd"
270def test_convert_cell_coadd_requires_skymap(tmp_path: Path, external_data_dir: str) -> None:
271 """Test that convert fails with a helpful message when --skymap is missing
272 for cell_coadd.
273 """
274 pytest.importorskip("lsst.cell_coadds")
275 src = os.path.join(external_data_dir, "dp2", "legacy", "deep_coadd_cell_predetection.fits")
276 out = str(tmp_path / "coadd.json")
277 result = CliRunner().invoke(main, ["convert", src, out, "--type", "cell_coadd"])
278 assert result.exit_code != 0
279 assert "--skymap" in result.output
282def test_preserve_quantization_default_is_true() -> None:
283 """Test that the --preserve-quantization option defaults to True."""
284 from lsst.images.cli._convert import convert
286 option = next(p for p in convert.params if p.name == "preserve_quantization")
287 assert option.default is True
290def test_preserve_quantization_explicit_flag_rejected_for_cell_coadd(tmp_path: Path) -> None:
291 """Test that explicitly passing --preserve-quantization is rejected for
292 cell_coadd conversions.
293 """
294 src = _make_cli_input(tmp_path)
295 out = str(tmp_path / "out.json")
296 result = CliRunner().invoke(
297 main, ["convert", src, out, "--type", "cell_coadd", "--preserve-quantization"]
298 )
299 assert result.exit_code != 0
300 assert "preserve-quantization" in result.output
303def test_preserve_quantization_default_does_not_reject_cell_coadd(tmp_path: Path) -> None:
304 """Test that the --preserve-quantization option default doesn't get in the
305 way of cell-coadd conversion.
306 """
307 src = _make_cli_input(tmp_path)
308 out = str(tmp_path / "out.json")
309 result = CliRunner().invoke(main, ["convert", src, out, "--type", "cell_coadd"])
310 assert "preserve-quantization" not in result.output
313def test_preserve_quantization_forwarded_to_read_legacy() -> None:
314 """Test that _read_legacy forwards preserve_quantization=True to
315 VisitImage.read_legacy.
316 """
317 from lsst.images.cli._convert import _read_legacy
319 with mock.patch("lsst.images.VisitImage.read_legacy") as read_legacy:
320 _read_legacy("in.fits", "visit_image", None, None, None, True)
321 read_legacy.assert_called_once_with("in.fits", preserve_quantization=True)
324def test_rejects_identical_paths(tmp_path: Path) -> None:
325 """Test that 'convert' rejects identical src and dst paths even with
326 --overwrite.
327 """
328 path = _make_cli_input(tmp_path)
329 result = CliRunner().invoke(main, ["convert", path, path, "--type", "visit_image", "--overwrite"])
330 assert result.exit_code != 0
331 assert "different" in result.output
332 assert os.path.exists(path)
335def test_preserves_existing_output_on_read_failure(tmp_path: Path) -> None:
336 """Test that 'convert' leaves the existing output file intact when
337 read_legacy raises.
338 """
339 src = _make_cli_input(tmp_path)
340 out = str(tmp_path / "out.json")
341 with open(out, "w") as stream:
342 stream.write("ORIGINAL")
343 with mock.patch(
344 "lsst.images.cli._convert._read_legacy",
345 side_effect=click.ClickException("boom"),
346 ):
347 result = CliRunner().invoke(main, ["convert", src, out, "--type", "visit_image", "--overwrite"])
348 assert result.exit_code != 0
349 with open(out) as stream:
350 assert stream.read() == "ORIGINAL"
353def test_subcommands_present() -> None:
354 """Test that the minify, reformat, extract-test-data, verify-rewrite, and
355 fuzz-masked-image are listed by --help.
356 """
357 result = CliRunner().invoke(main, ["--help"])
358 assert result.exit_code == 0, result.output
359 assert "minify" in result.output
360 assert "reformat" in result.output
361 assert "extract-test-data" in result.output
362 assert "verify-rewrite" in result.output
363 assert "fuzz-masked-image" in result.output
366def test_minify_help() -> None:
367 """Verify minify --help exits cleanly."""
368 result = CliRunner().invoke(main, ["minify", "--help"])
369 assert result.exit_code == 0, result.output
372def test_extract_test_data_help() -> None:
373 """Verify extract-test-data --help exits cleanly."""
374 result = CliRunner().invoke(main, ["extract-test-data", "--help"])
375 assert result.exit_code == 0, result.output
378def test_verify_rewrite_help() -> None:
379 """Verify verify-rewrite loads with core deps only."""
380 result = CliRunner().invoke(main, ["verify-rewrite", "--help"])
381 assert result.exit_code == 0, result.output
384@pytest.mark.parametrize(
385 "args",
386 [
387 ["-h"],
388 ["convert", "-h"],
389 ["inspect", "-h"],
390 ["minify", "-h"],
391 ["reformat", "-h"],
392 ["extract-test-data", "-h"],
393 ["extract-test-data", "dp2", "-h"],
394 ["verify-rewrite", "-h"],
395 ["fuzz-masked-image", "-h"],
396 ["schemas", "-h"],
397 ["schemas", "write", "-h"],
398 ["schemas", "check", "-h"],
399 ["fixtures", "-h"],
400 ["fixtures", "check", "-h"],
401 ["fixtures", "refresh", "-h"],
402 ["fixtures", "freeze", "-h"],
403 ["fixtures", "coverage", "-h"],
404 ],
405 ids=[
406 "root",
407 "convert",
408 "inspect",
409 "minify",
410 "reformat",
411 "extract-test-data",
412 "extract-test-data-dp2",
413 "verify-rewrite",
414 "fuzz-masked-image",
415 "schemas",
416 "schemas-write",
417 "schemas-check",
418 "fixtures",
419 "fixtures-check",
420 "fixtures-refresh",
421 "fixtures-freeze",
422 "fixtures-coverage",
423 ],
424)
425def test_short_help_alias(args: list[str]) -> None:
426 """Test that -h is an alias for --help on the group and every
427 subcommand.
428 """
429 result = CliRunner().invoke(main, args)
430 assert result.exit_code == 0, result.output
431 assert "Usage:" in result.output
434def test_schemas_write_and_check(tmp_path: Path) -> None:
435 """Verify schemas write populates a directory that schemas check
436 accepts.
437 """
438 runner = CliRunner()
439 result = runner.invoke(main, ["schemas", "write", "--dir", str(tmp_path)])
440 assert result.exit_code == 0, result.output
441 assert list(tmp_path.glob("image/image-*.json"))
442 result = runner.invoke(main, ["schemas", "check", "--dir", str(tmp_path)])
443 assert result.exit_code == 0, result.output
446def test_schemas_write_package_option(tmp_path: Path) -> None:
447 """Verify --package freezes only schemas defined under that package."""
448 runner = CliRunner()
449 result = runner.invoke(
450 main, ["schemas", "write", "--dir", str(tmp_path), "--package", "lsst.images.cells"]
451 )
452 assert result.exit_code == 0, result.output
453 names = sorted(p.name for p in tmp_path.rglob("*.json"))
454 assert any(n.startswith("cell_coadd-") for n in names)
455 assert not any(n.startswith("image-") for n in names)
458def test_schemas_check_fails_when_stale(tmp_path: Path) -> None:
459 """Verify schemas check exits nonzero and names the fix when stale."""
460 runner = CliRunner()
461 runner.invoke(main, ["schemas", "write", "--dir", str(tmp_path)])
462 (path,) = tmp_path.glob("image/image-*.json")
463 path.unlink()
464 result = runner.invoke(main, ["schemas", "check", "--dir", str(tmp_path)])
465 assert result.exit_code != 0
466 assert "schemas write" in result.output
469def test_cli_describe_visit_image() -> None:
470 """The describe command renders a deserialized VisitImage."""
471 path = current_fixture_path(FIXTURE_DIR, "visit_image")
472 result = CliRunner().invoke(main, ["describe", str(path)])
473 assert result.exit_code == 0, result.output
474 assert "VisitImage" in result.output
477def test_cli_describe_coadd_provenance() -> None:
478 """The describe command renders a deserialized CoaddProvenance.
480 Provenance is describable in its own right, not only as part of a coadd,
481 so the command must not fall back to the default object repr for it.
482 """
483 path = current_fixture_path(FIXTURE_DIR, "coadd_provenance")
484 result = CliRunner().invoke(main, ["describe", str(path)])
485 assert result.exit_code == 0, result.output
486 assert "CoaddProvenance" in result.output
487 assert "input images" in result.output
490def test_fixtures_check_reports_a_clean_tree() -> None:
491 """Verify 'fixtures check' exits zero on the committed tree."""
492 runner = click.testing.CliRunner()
493 result = runner.invoke(
494 main,
495 [
496 "fixtures",
497 "check",
498 "--dir",
499 str(Path(__file__).parent / "data" / "schemas"),
500 "--schema-dir",
501 str(Path(__file__).parent.parent / "schemas"),
502 "--exempt",
503 "psfex_psf",
504 ],
505 )
506 assert result.exit_code == 0, result.output
509def test_fixtures_coverage_reports_positions_for_a_composite() -> None:
510 """Verify 'fixtures coverage --schema' reports a composite's positions.
512 Asserted on structure rather than on any particular gap, so adding a model
513 or widening a fixture cannot turn this into a failure.
514 """
515 result = click.testing.CliRunner().invoke(
516 main, ["fixtures", "coverage", "--dir", str(FIXTURE_DIR), "--schema", "cell_coadd"]
517 )
518 assert result.exit_code == 0, result.output
519 assert result.output.startswith("cell_coadd ")
520 assert "holds .psf [cell_psf]" in result.output
523def test_fixtures_coverage_emits_parseable_json() -> None:
524 """Verify 'fixtures coverage --format json' emits a JSON object."""
525 result = click.testing.CliRunner().invoke(
526 main,
527 ["fixtures", "coverage", "--dir", str(FIXTURE_DIR), "--schema", "cell_coadd", "--format", "json"],
528 )
529 assert result.exit_code == 0, result.output
530 parsed = json.loads(result.output)
531 (entry,) = parsed.values()
532 assert {"sources", "expressed", "absent", "positions"} == set(entry)
533 assert any(position["path"] == ".psf" for position in entry["positions"])
536def test_fixtures_coverage_reports_an_unknown_schema_cleanly() -> None:
537 """Verify a schema name that matches nothing says so and exits zero."""
538 result = click.testing.CliRunner().invoke(
539 main, ["fixtures", "coverage", "--dir", str(FIXTURE_DIR), "--schema", "no_such_schema"]
540 )
541 assert result.exit_code == 0, result.output
542 assert "no schema matches 'no_such_schema'" in result.output
545def test_fixtures_check_requires_a_schema_directory() -> None:
546 """Verify pairing cannot be accidentally omitted from the CLI check."""
547 result = click.testing.CliRunner().invoke(main, ["fixtures", "check"])
548 assert result.exit_code != 0
549 assert "Missing option '--schema-dir'" in result.output
552def test_fixtures_check_reports_a_problem(tmp_path: Path) -> None:
553 """Verify 'fixtures check' reports why a malformed fixture fails.
555 The empty object fails model validation, which must be reported as a
556 validation failure on this exact file, not merely as some problem
557 somewhere; a fixture tree with no ``image`` file at all would already
558 report that file as missing, so the assertion has to name the failure
559 mode to tell the two apart.
560 """
561 name = current_fixture_path(FIXTURE_DIR, "image").name
562 directory = tmp_path / "image"
563 directory.mkdir(parents=True)
564 (directory / name).write_text("{}\n")
565 runner = click.testing.CliRunner()
566 result = runner.invoke(
567 main,
568 [
569 "fixtures",
570 "check",
571 "--dir",
572 str(tmp_path),
573 "--schema-dir",
574 str(Path(__file__).parent.parent / "schemas"),
575 ],
576 )
577 assert result.exit_code != 0
578 assert f"{name}: does not validate" in result.output
581def test_fixtures_refresh_reports_no_change(tmp_path: Path) -> None:
582 """Verify 'fixtures refresh' would be a no-op on the committed tree.
584 The command is intentionally mutating, so run it on a fresh copy. If a
585 committed development fixture is dirty, every test run must copy and
586 detect that same dirty input rather than repairing the repository on the
587 first run and passing on the second.
588 """
589 directory = _copy_fixture_tree(tmp_path)
590 runner = click.testing.CliRunner()
591 result = runner.invoke(main, ["fixtures", "refresh", "--dir", str(directory)])
592 assert result.exit_code == 0, result.output
593 assert "already up to date" in result.output
596def test_fixtures_freeze_reports_nothing_to_freeze(tmp_path: Path) -> None:
597 """Verify 'fixtures freeze' would be a no-op on the committed tree.
599 Run the destructive command on a copy so a newly finalized schema makes
600 this test fail on every run without moving or deleting the developer's
601 fixture.
602 """
603 directory = _copy_fixture_tree(tmp_path)
604 runner = click.testing.CliRunner()
605 result = runner.invoke(main, ["fixtures", "freeze", "--dir", str(directory)])
606 assert result.exit_code == 0, result.output
607 assert "nothing to freeze" in result.output
610def test_fixtures_refresh_detection_is_repeatable_without_mutating_source(tmp_path: Path) -> None:
611 """Verify a dirty fixture is detected repeatedly without being repaired.
613 Dirty here means valid but not canonically formatted, which is what
614 refresh regenerates. The fixture belongs to `CliFixtureDouble` because
615 refresh acts only on a schema still in development.
616 """
617 name = CliFixtureDouble.SCHEMA_NAME
618 source = tmp_path / "dirty-source"
619 (source / name).mkdir(parents=True)
620 fixture = source / name / f"{name}-1.0.0.dev.json"
621 dirty_text = json.dumps({"schema_version": "1.0.0.dev0", "min_read_version": 1, "value": "x"})
622 fixture.write_text(dirty_text)
624 for run in range(2):
625 directory = _copy_fixture_tree(tmp_path, source=source, name=f"run-{run}")
626 result = click.testing.CliRunner().invoke(
627 main,
628 ["fixtures", "refresh", "--dir", str(directory), "--package", CliFixtureDouble.__module__],
629 )
630 assert result.exit_code == 0, result.output
631 assert "already up to date" not in result.output
632 assert "wrote" in result.output
633 assert fixture.read_text() == dirty_text
636def test_fixtures_refresh_reports_finalized_conflict_cleanly(tmp_path: Path) -> None:
637 """Verify 'fixtures refresh' turns a finalized-fixture conflict into a
638 clean error instead of a raw traceback.
639 """
640 source = current_fixture_path(FIXTURE_DIR, "image")
641 name = source.name
642 data = json.loads(source.read_text())
643 directory = tmp_path / "image"
644 directory.mkdir(parents=True)
645 # Valid but not canonically formatted, so it differs from the text
646 # refresh would regenerate for this already-finalized version.
647 (directory / name).write_text(json.dumps(data))
648 runner = click.testing.CliRunner()
649 result = runner.invoke(main, ["fixtures", "refresh", "--dir", str(tmp_path)])
650 assert result.exit_code != 0
651 assert name in result.output
652 assert "bump SCHEMA_VERSION" in result.output
653 assert "Traceback" not in result.output
656def test_fixtures_freeze_reports_existing_target_cleanly(tmp_path: Path) -> None:
657 """Verify 'fixtures freeze' turns a pre-existing target conflict into a
658 clean error instead of a raw traceback.
659 """
660 target = current_fixture_path(FIXTURE_DIR, "image")
661 target_name = target.name
662 dev_name = target_name.removesuffix(".json") + ".dev.json"
663 text = target.read_text()
664 directory = tmp_path / "image"
665 directory.mkdir(parents=True)
666 (directory / dev_name).write_text(text)
667 (directory / target_name).write_text(text)
668 runner = click.testing.CliRunner()
669 result = runner.invoke(main, ["fixtures", "freeze", "--dir", str(tmp_path)])
670 assert result.exit_code != 0
671 assert target_name in result.output
672 assert "already exists" in result.output
673 assert "Traceback" not in result.output
676def test_fixtures_freeze_reports_a_validation_failure_cleanly(tmp_path: Path) -> None:
677 """Verify 'fixtures freeze' turns a fixture that no longer validates into
678 a clean error instead of a raw traceback.
680 freeze_schema_fixtures reads each fixture through its live model
681 (read_fixture_tree) as it freezes it; a fixture that fails that read
682 raises pydantic.ValidationError or ArchiveReadError, neither of which is
683 SchemaFixtureError, so the CLI must catch them too rather than let a
684 traceback escape.
685 """
686 directory = tmp_path / "image"
687 directory.mkdir(parents=True)
688 (directory / "image-1.0.0.dev.json").write_text("{}\n")
689 runner = click.testing.CliRunner()
690 result = runner.invoke(main, ["fixtures", "freeze", "--dir", str(tmp_path)])
691 assert result.exit_code != 0
692 assert "Traceback" not in result.output
693 # A raw pydantic.ValidationError escaping uncaught propagates out of
694 # CliRunner.invoke() as result.exception with no "Error: ..." line ever
695 # written to result.output; only a caught-and-reraised
696 # click.ClickException produces that line and a clean SystemExit.
697 assert "Error:" in result.output
698 assert isinstance(result.exception, SystemExit)
701def test_fixtures_refresh_reports_a_validation_failure_cleanly(tmp_path: Path) -> None:
702 """Verify 'fixtures refresh' turns a fixture that no longer validates
703 into a clean error instead of a raw traceback.
705 refresh_schema_fixtures reads a development fixture through
706 read_fixture_tree to canonicalize it; a fixture that fails that read
707 raises pydantic.ValidationError or ArchiveReadError, neither of which is
708 SchemaFixtureError, so the CLI must catch them too rather than let a
709 traceback escape. Refresh acts only on a schema still in development, so
710 this uses `CliFixtureDouble` rather than a real schema that would be
711 finalized eventually, and is finalized during every release.
712 """
713 name = CliFixtureDouble.SCHEMA_NAME
714 directory = tmp_path / name
715 directory.mkdir(parents=True)
716 (directory / f"{name}-1.0.0.dev.json").write_text("{}\n")
717 runner = click.testing.CliRunner()
718 result = runner.invoke(
719 main, ["fixtures", "refresh", "--dir", str(tmp_path), "--package", CliFixtureDouble.__module__]
720 )
721 assert result.exit_code != 0
722 assert "Traceback" not in result.output
723 # A raw pydantic.ValidationError escaping uncaught propagates out of
724 # CliRunner.invoke() as result.exception with no "Error: ..." line ever
725 # written to result.output; only a caught-and-reraised
726 # click.ClickException produces that line and a clean SystemExit.
727 assert "Error:" in result.output
728 assert isinstance(result.exception, SystemExit)