Coverage for tests/test_sasquatchDatastore.py: 98%
89 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 10:14 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 10:14 +0000
1# This file is part of analysis_tools.
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 program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22"""Tests for SasquatchDatastore and SasquatchDispatcher.
24These catch (1) a put that no longer hands the bundle to the dispatcher,
25(2) a broken or unresponsive Sasquatch proxy failing or hanging the put,
26and with it the pipeline task, and (3) explicit timestamp versions being
27misparsed, or accepted when malformed.
28"""
30import datetime
31import os
32import unittest
33from unittest.mock import MagicMock, patch
35import astropy.units as u
36import requests
38import lsst.daf.butler.tests as butlerTests
39from lsst.analysis.tools.interfaces import MetricMeasurementBundle
40from lsst.analysis.tools.interfaces.datastore import SasquatchDispatcher, SasquatchDispatchFailure
41from lsst.analysis.tools.interfaces.datastore._dispatcher import DEFAULT_TIMEOUT
42from lsst.daf.butler import CollectionType, Config
43from lsst.daf.butler.tests.utils import makeTestTempDir, removeTestTempDir
44from lsst.verify import Measurement
46TESTDIR = os.path.abspath(os.path.dirname(__file__))
47CONFIG_FILE = os.path.join(TESTDIR, "config", "butler-sasquatch.yaml")
49# Where the dispatcher looks up its HTTP session factory, for patching.
50HTTP_CLIENT = "lsst.analysis.tools.interfaces.datastore._dispatcher.http_client"
53def _fakeResponse(status: int = 200, json: object = None, jsonError: Exception | None = None) -> MagicMock:
54 """Make a stand-in for a `requests.Response`.
56 Parameters
57 ----------
58 status : `int`, optional
59 HTTP status code; ``raise_for_status`` raises for 4xx and 5xx.
60 json : `object`, optional
61 Value returned by ``json()``.
62 jsonError : `Exception`, optional
63 Exception raised by ``json()`` in place of returning ``json``.
65 Returns
66 -------
67 response : `unittest.mock.MagicMock`
68 The fake response.
69 """
70 response = MagicMock()
71 response.status_code = status
72 if status >= 400:
73 response.raise_for_status.side_effect = requests.HTTPError(f"HTTP {status}", response=response)
74 if jsonError is not None:
75 response.json.side_effect = jsonError
76 else:
77 response.json.return_value = json
78 return response
81def _fakeSession(getResponse: MagicMock | None = None, postResponse: MagicMock | None = None) -> MagicMock:
82 """Make a stand-in for a `requests.Session` returning canned responses.
84 Parameters
85 ----------
86 getResponse : `unittest.mock.MagicMock`, optional
87 Response returned by ``get``; a bare 200 if not given.
88 postResponse : `unittest.mock.MagicMock`, optional
89 Response returned by ``post``; a bare 200 if not given.
91 Returns
92 -------
93 session : `unittest.mock.MagicMock`
94 The fake session.
95 """
96 session = MagicMock()
97 session.get.return_value = getResponse if getResponse is not None else _fakeResponse()
98 session.post.return_value = postResponse if postResponse is not None else _fakeResponse()
99 return session
102class SasquatchDatastoreTest(unittest.TestCase):
103 def setUp(self):
104 self.root = makeTestTempDir(TESTDIR)
106 config = Config()
107 config["datastore", "cls"] = "lsst.analysis.tools.interfaces.datastore.SasquatchDatastore"
108 config["datastore", "restProxyUrl"] = "https://example.com/sasquatch-rest-proxy"
109 config["datastore", "timeout"] = 5.5
111 dataIds = {
112 "instrument": ["DummyCam"],
113 "physical_filter": ["d-r"],
114 "visit": [42, 43, 44],
115 "detector": [1, 2, 3],
116 }
117 self.butler = butlerTests.makeTestRepo(self.root, dataIds, config=config)
119 butlerTests.addDatasetType(
120 self.butler, "Metrics", {"instrument", "visit", "detector"}, "MetricMeasurementBundle"
121 )
122 self.butler.registry.registerCollection("run1", CollectionType.RUN)
124 def tearDown(self):
125 removeTestTempDir(self.root)
127 def test_put(self):
128 """Simple test for put method."""
129 m = Measurement("nopackage.fancyMetric", 42.2 * u.s)
130 bundle = MetricMeasurementBundle({"m": [m]})
132 # Patch dispatcher method to check parameters.
133 with patch.object(SasquatchDispatcher, "dispatchRef") as mock_method:
134 self.butler.put(bundle, "Metrics", run="run1", instrument="DummyCam", visit=42, detector=2)
136 mock_method.assert_called()
137 self.assertIs(mock_method.call_args[0][0], bundle)
139 def testTimeoutConfiguration(self) -> None:
140 # The configured timeout must reach the dispatcher, and a dispatcher
141 # made without one must still get DEFAULT_TIMEOUT rather than none.
142 self.assertEqual(self.butler._datastore.timeout, 5.5)
143 self.assertEqual(self.butler._datastore._dispatcher.timeout, 5.5)
144 self.assertEqual(SasquatchDispatcher("http://test.local", "na").timeout, DEFAULT_TIMEOUT)
146 def testRequestsUseTimeout(self) -> None:
147 # An HTTP call made without the timeout lets a proxy that accepts
148 # connections but never answers hang the put indefinitely.
149 dispatcher = SasquatchDispatcher("http://test.local", "na", timeout=12.5)
150 session = _fakeSession(getResponse=_fakeResponse(json={"data": [{"cluster_id": "abc"}]}))
152 with patch(HTTP_CLIENT) as mockHttpClient:
153 mockHttpClient.return_value.__enter__.return_value = session
154 self.assertEqual(dispatcher.clusterId, "abc")
155 self.assertTrue(dispatcher._create_topic("some.metric"))
157 self.assertEqual(session.get.call_args.kwargs["timeout"], 12.5)
158 self.assertEqual(session.post.call_args.kwargs["timeout"], 12.5)
160 def testClusterIdFailuresRaiseDispatchFailure(self) -> None:
161 # Each bad response must surface as SasquatchDispatchFailure, the
162 # failure the datastore expects. Parsing the body before checking
163 # the status lets the error document through as a bare KeyError.
164 dispatcher = SasquatchDispatcher("http://test.local", "na")
165 jsonError = requests.exceptions.JSONDecodeError("Expecting value", "<html>", 0)
166 badResponses = {
167 "5xx with an error document": _fakeResponse(503, json={"error_code": 50301}),
168 "2xx with a non-JSON body": _fakeResponse(200, jsonError=jsonError),
169 }
170 for description, response in badResponses.items():
171 with self.subTest(description):
172 with patch(HTTP_CLIENT) as mockHttpClient:
173 mockHttpClient.return_value.__enter__.return_value = _fakeSession(getResponse=response)
174 with self.assertRaises(SasquatchDispatchFailure):
175 _ = dispatcher.clusterId
177 def test_explicit_timestamp_version(self):
178 dispatcher = SasquatchDispatcher("http://test.local", "na")
179 bundle = MetricMeasurementBundle()
180 bundle.timestamp_version = "explicit_timestamp"
181 # verify this raises with no specified time
182 with self.assertRaises(ValueError):
183 dispatcher._handleTimes({}, bundle, "localRun")
184 # verify this raise with a date that can't be parsed
185 bundle.timestamp_version = "explicit_timestamp:123233"
186 with self.assertRaises(ValueError):
187 dispatcher._handleTimes({}, bundle, "localRun")
188 # verify that a correct time gets parsed
189 bundle.timestamp_version = "explicit_timestamp:20230728T165102Z"
190 meta = {}
191 dispatcher._handleTimes(meta, bundle, "localRun")
192 dt = datetime.datetime(2023, 7, 28, 16, 51, 2, tzinfo=datetime.UTC)
193 self.assertEqual(meta["timestamp"], dt.timestamp())
196if __name__ == "__main__": 196 ↛ 197line 196 didn't jump to line 197 because the condition on line 196 was never true
197 unittest.main()