Coverage for tests/test_formatter_cache.py: 98%
102 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 15:24 -0700
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 15:24 -0700
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 os
15import numpy as np
16import pytest
18from lsst.images import Box, VisitImage
19from lsst.images.serialization import open_archive, read_archive
20from lsst.images.tests import TemporaryButler
22try:
23 # The formatter module requires lsst.daf.butler.
24 from lsst.images.formatters import GenericFormatter, _TreeCache
26 HAVE_BUTLER = True
27except ImportError:
28 HAVE_BUTLER = False
30skip_no_butler = pytest.mark.skipif(not HAVE_BUTLER, reason="lsst.daf.butler could not be imported.")
32LOCAL_DATA_DIR = os.path.join(os.path.dirname(__file__), "data", "schema_v1")
35def _count_ser_opens():
36 """Return a patch that counts calls to lsst.images.serialization.open."""
37 import unittest.mock
39 return unittest.mock.patch("lsst.images.serialization.open_archive", side_effect=open_archive)
42def _reset_cache() -> None:
43 """Reset the GenericFormatter tree cache to an empty state."""
44 GenericFormatter._tree_cache.value = _TreeCache()
47@pytest.fixture(scope="session")
48def visit_image() -> VisitImage:
49 """Return a test VisitImage."""
50 return read_archive(os.path.join(LOCAL_DATA_DIR, "visit_image.json"))
53@skip_no_butler
54def test_free_component_reads_share_one_open(visit_image: VisitImage) -> None:
55 """Verify multiple free component reads share a single
56 serialization.open_archive call.
57 """
58 with TemporaryButler(visit_image="VisitImage") as helper:
59 helper.butler.put(visit_image, helper.visit_image)
60 _reset_cache()
61 with _count_ser_opens() as mocked:
62 summary_stats = helper.butler.get(helper.visit_image.makeComponentRef("summary_stats"))
63 assert mocked.call_count == 1
64 obs_info = helper.butler.get(helper.visit_image.makeComponentRef("obs_info"))
65 sky_projection = helper.butler.get(helper.visit_image.makeComponentRef("sky_projection"))
66 assert mocked.call_count == 1
67 assert summary_stats == visit_image.summary_stats
68 assert obs_info == visit_image.obs_info
69 assert sky_projection is not None
70 _reset_cache()
73@skip_no_butler
74def test_cached_components_are_independent(visit_image: VisitImage) -> None:
75 """Verify repeated component reads return equal but independent objects."""
76 with TemporaryButler(visit_image="VisitImage") as helper:
77 helper.butler.put(visit_image, helper.visit_image)
78 _reset_cache()
79 ref = helper.visit_image.makeComponentRef("summary_stats")
80 first = helper.butler.get(ref)
81 second = helper.butler.get(ref)
82 assert first == second
83 assert first is not second
84 # Mutating one result must not leak into later reads.
85 first.zeroPoint = -100.0
86 third = helper.butler.get(ref)
87 assert third == second
88 assert third != first
89 _reset_cache()
92@skip_no_butler
93def test_pixel_component_falls_back_to_file(visit_image: VisitImage) -> None:
94 """Verify pixel component reads bypass the cache and re-open the file."""
95 with TemporaryButler(visit_image="VisitImage") as helper:
96 helper.butler.put(visit_image, helper.visit_image)
97 _reset_cache()
98 with _count_ser_opens() as mocked:
99 helper.butler.get(helper.visit_image.makeComponentRef("summary_stats"))
100 assert mocked.call_count == 1
101 image = helper.butler.get(helper.visit_image.makeComponentRef("image"))
102 assert mocked.call_count == 2
103 np.testing.assert_array_equal(image.array, visit_image.image.array)
104 _reset_cache()
107@skip_no_butler
108def test_parameterized_read_bypasses_cache(visit_image: VisitImage) -> None:
109 """Verify a parameterized (bbox) component read bypasses the cache."""
110 with TemporaryButler(visit_image="VisitImage") as helper:
111 helper.butler.put(visit_image, helper.visit_image)
112 _reset_cache()
113 bbox = visit_image.bbox
114 cutout_box = Box.factory[bbox.y.start : bbox.y.start + 2, bbox.x.start : bbox.x.start + 2]
115 with _count_ser_opens() as mocked:
116 helper.butler.get(helper.visit_image.makeComponentRef("summary_stats"))
117 assert mocked.call_count == 1
118 cutout = helper.butler.get(
119 helper.visit_image.makeComponentRef("image"), parameters={"bbox": cutout_box}
120 )
121 assert mocked.call_count == 2
122 assert cutout.bbox == cutout_box
123 _reset_cache()
126@skip_no_butler
127def test_full_read_populates_cache(visit_image: VisitImage) -> None:
128 """Verify a full object read populates the cache for subsequent component
129 reads.
130 """
131 with TemporaryButler(visit_image="VisitImage") as helper:
132 helper.butler.put(visit_image, helper.visit_image)
133 _reset_cache()
134 helper.butler.get(helper.visit_image)
135 with _count_ser_opens() as mocked:
136 helper.butler.get(helper.visit_image.makeComponentRef("obs_info"))
137 assert mocked.call_count == 0
138 _reset_cache()
141@skip_no_butler
142def test_cache_invalidated_across_datasets(visit_image: VisitImage) -> None:
143 """Verify the cache is invalidated when switching between dataset
144 references.
145 """
146 with TemporaryButler(vi_a="VisitImage", vi_b="VisitImage") as helper:
147 helper.butler.put(visit_image, helper.vi_a)
148 helper.butler.put(visit_image, helper.vi_b)
149 _reset_cache()
150 with _count_ser_opens() as mocked:
151 helper.butler.get(helper.vi_a.makeComponentRef("summary_stats"))
152 assert mocked.call_count == 1
153 helper.butler.get(helper.vi_b.makeComponentRef("summary_stats"))
154 assert mocked.call_count == 2
155 # vi_b evicted vi_a, so reading vi_a again reopens its file.
156 helper.butler.get(helper.vi_a.makeComponentRef("obs_info"))
157 assert mocked.call_count == 3
158 _reset_cache()