Coverage for python/lsst/daf/butler/tests/utils.py: 99%

122 statements  

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

1# This file is part of daf_butler. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://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 <http://www.gnu.org/licenses/>. 

27 

28from __future__ import annotations 

29 

30from unittest.mock import patch 

31 

32__all__ = () 

33 

34import os 

35import shutil 

36import tempfile 

37from collections.abc import Callable, Iterator, Sequence 

38from contextlib import contextmanager 

39from typing import TYPE_CHECKING, Any 

40 

41import astropy 

42from astropy.table import Table as AstropyTable 

43 

44from lsst.resources import ResourcePathExpression 

45 

46from .. import Butler, ButlerConfig, Config, DatasetRef, StorageClassFactory, Timespan 

47from .._collection_type import CollectionType 

48from ..datastore import NullDatastore 

49from ..dimensions import DimensionConfig 

50from ..direct_butler import DirectButler 

51from ..registry.sql_registry import RegistryConfig, SqlRegistry 

52from ..tests import MetricsExample, addDatasetType 

53from ._repo_template_cache import make_repo_for_test 

54 

55if TYPE_CHECKING: 

56 import unittest 

57 

58 from lsst.daf.butler import DatasetType 

59 

60 class TestCaseMixin(unittest.TestCase): 

61 """Base class for mixin test classes that use TestCase methods.""" 

62 

63 pass 

64 

65else: 

66 

67 class TestCaseMixin: 

68 """Do-nothing definition of mixin base class for regular execution.""" 

69 

70 pass 

71 

72 

73def makeTestTempDir(default_base: str) -> str: 

74 """Create a temporary directory for test usage. 

75 

76 The directory will be created within ``DAF_BUTLER_TEST_TMP`` if that 

77 environment variable is set, falling back to ``default_base`` if it is 

78 not. 

79 

80 Parameters 

81 ---------- 

82 default_base : `str` 

83 Default parent directory. 

84 

85 Returns 

86 ------- 

87 dir : `str` 

88 Name of the new temporary directory. 

89 """ 

90 base = os.environ.get("DAF_BUTLER_TEST_TMP", default_base) 

91 return tempfile.mkdtemp(dir=base) 

92 

93 

94def removeTestTempDir(root: str | None) -> None: 

95 """Attempt to remove a temporary test directory, but do not raise if 

96 unable to. 

97 

98 Unlike `tempfile.TemporaryDirectory`, this passes ``ignore_errors=True`` 

99 to ``shutil.rmtree`` at close, making it safe to use on NFS. 

100 

101 Parameters 

102 ---------- 

103 root : `str`, optional 

104 Name of the directory to be removed. If `None`, nothing will be done. 

105 """ 

106 if root is not None and os.path.exists(root): 106 ↛ exitline 106 didn't return from function 'removeTestTempDir' because the condition on line 106 was always true

107 shutil.rmtree(root, ignore_errors=True) 

108 

109 

110@contextmanager 

111def safeTestTempDir(default_base: str) -> Iterator[str]: 

112 """Return a context manager that creates a temporary directory and then 

113 attempts to remove it. 

114 

115 Parameters 

116 ---------- 

117 default_base : `str` 

118 Default parent directory, forwarded to `makeTestTempDir`. 

119 

120 Returns 

121 ------- 

122 context : `contextlib.ContextManager` 

123 A context manager that returns the new directory name on ``__enter__`` 

124 and removes the temporary directory (via `removeTestTempDir`) on 

125 ``__exit__``. 

126 """ 

127 root = makeTestTempDir(default_base) 

128 try: 

129 yield root 

130 finally: 

131 removeTestTempDir(root) 

132 

133 

134def create_populated_sqlite_registry( 

135 *args: ResourcePathExpression, 

136 registry_config: RegistryConfig | None = None, 

137 dimension_config: DimensionConfig | None = None, 

138) -> Butler: 

139 """Create an in-memory registry-only sqlite butler and populate it. 

140 

141 Parameters 

142 ---------- 

143 *args : convertible to `lsst.resources.ResourcePath` 

144 Paths to export YAML files that should be imported. 

145 registry_config : ``RegistryConfig``, optional 

146 Registry configuration to use as the basis for the Butler 

147 configuration. 

148 dimension_config : ``DimensionConfig``, optional 

149 Dimension universe configuration. 

150 

151 Returns 

152 ------- 

153 butler : `Butler` 

154 New butler populated with the specified import files. 

155 """ 

156 config = ButlerConfig() 

157 if registry_config is not None: 

158 config["registry"] = registry_config 

159 config[".registry.db"] = "sqlite://" 

160 registry = SqlRegistry.createFromConfig(config["registry"], dimension_config) 

161 butler = DirectButler( 

162 config=config, 

163 registry=registry, 

164 datastore=NullDatastore(None, None), 

165 storageClasses=StorageClassFactory(), 

166 ) 

167 for arg in args: 

168 butler.import_(filename=arg, without_datastore=True) 

169 return butler 

170 

171 

172class ButlerTestHelper: 

173 """Mixin with helpers for unit tests.""" 

174 

175 assertEqual: Callable 

176 assertIsInstance: Callable 

177 maxDiff: int | None 

178 

179 def assertAstropyTablesEqual( 

180 self, 

181 tables: AstropyTable | Sequence[AstropyTable], 

182 expectedTables: AstropyTable | Sequence[AstropyTable], 

183 filterColumns: bool = False, 

184 unorderedRows: bool = False, 

185 ) -> None: 

186 """Verify that a list of astropy tables matches a list of expected 

187 astropy tables. 

188 

189 Parameters 

190 ---------- 

191 tables : `astropy.table.Table` or iterable [`astropy.table.Table`] 

192 The table or tables that should match the expected tables. 

193 expectedTables : `astropy.table.Table` 

194 or iterable [`astropy.table.Table`] 

195 The tables with expected values to which the tables under test will 

196 be compared. 

197 filterColumns : `bool` 

198 If `True` then only compare columns that exist in 

199 ``expectedTables``. 

200 unorderedRows : `bool`, optional 

201 If `True` (`False` is default), don't require tables to have their 

202 rows in the same order. 

203 """ 

204 # If a single table is passed in for tables or expectedTables, put it 

205 # in a list. 

206 if isinstance(tables, AstropyTable): 

207 tables = [tables] 

208 if isinstance(expectedTables, AstropyTable): 

209 expectedTables = [expectedTables] 

210 self.assertEqual(len(tables), len(expectedTables)) 

211 for table, expected in zip(tables, expectedTables, strict=True): 

212 # Assert that we are testing what we think we are testing: 

213 self.assertIsInstance(table, AstropyTable) 

214 self.assertIsInstance(expected, AstropyTable) 

215 if filterColumns: 

216 table = table.copy() 

217 table.keep_columns(expected.colnames) 

218 if unorderedRows: 

219 table = table.copy() 

220 table.sort(table.colnames) 

221 expected = expected.copy() 

222 expected.sort(expected.colnames) 

223 # Assert that they match. 

224 # Recommendation from Astropy Slack is to format the table into 

225 # lines for comparison. We do not compare column data types. 

226 table1 = table.pformat() 

227 expected1 = expected.pformat() 

228 original_max = self.maxDiff 

229 self.maxDiff = None # This is required to get the full diff. 

230 try: 

231 self.assertEqual(table1, expected1, f"Table:\n{table}\n\nvs Expected:\n{expected}") 

232 finally: 

233 self.maxDiff = original_max 

234 

235 

236def readTable(textTable: str) -> AstropyTable: 

237 """Read an astropy table from formatted text. 

238 

239 Contains formatting that causes the astropy table to print an empty string 

240 instead of "--" for missing/unpopulated values in the text table. 

241 

242 Parameters 

243 ---------- 

244 textTable : `str` 

245 The text version of the table to read. 

246 

247 Returns 

248 ------- 

249 table : `astropy.table.Table` 

250 The table as an astropy table. 

251 """ 

252 return AstropyTable.read( 

253 textTable, 

254 format="ascii", 

255 data_start=2, # skip the header row and the header row underlines. 

256 fill_values=[("", 0, "")], 

257 ) 

258 

259 

260class MetricTestRepo: 

261 """Creates and manage a test repository on disk with datasets that 

262 may be queried and modified for unit tests. 

263 

264 Parameters 

265 ---------- 

266 root : `str` 

267 The location of the repository, to pass to ``Butler.makeRepo``. 

268 configFile : `str` 

269 The path to the config file, to pass to ``Butler.makeRepo``. 

270 forceConfigRoot : `bool`, optional 

271 If `False`, any values present in the supplied ``config`` that 

272 would normally be reset are not overridden and will appear 

273 directly in the output config. Passed to ``Butler.makeRepo``. 

274 storageClassName : `bool` or `None`, optional 

275 Name of storage class to use for datasets added to the test repository. 

276 A default will be used if none is specified. 

277 """ 

278 

279 METRICS_EXAMPLE_SUMMARY = {"AM1": 5.2, "AM2": 30.6} 

280 """The summary data included in ``MetricsExample`` objects stored in the 

281 test repo 

282 """ 

283 

284 _DEFAULT_RUN = "ingest/run" 

285 _DEFAULT_TAG = "ingest" 

286 _DEFAULT_STORAGE_CLASS = "StructuredCompositeReadComp" 

287 

288 @staticmethod 

289 def _makeExampleMetrics() -> MetricsExample: 

290 """Make an object to put into the repository.""" 

291 return MetricsExample( 

292 MetricTestRepo.METRICS_EXAMPLE_SUMMARY, 

293 {"a": [1, 2, 3], "b": {"blue": 5, "red": "green"}}, 

294 [563, 234, 456.7, 752, 8, 9, 27], 

295 ) 

296 

297 def __init__( 

298 self, 

299 root: str, 

300 configFile: str, 

301 forceConfigRoot: bool = True, 

302 storageClassName: str | None = None, 

303 ) -> None: 

304 self.root = root 

305 butlerConfigFile = make_repo_for_test( 

306 self.root, config=Config(configFile), forceConfigRoot=forceConfigRoot 

307 ) 

308 butler = Butler.from_config(butlerConfigFile, run=self._DEFAULT_RUN, collections=[self._DEFAULT_TAG]) 

309 self._do_init(butler, butlerConfigFile, storageClassName) 

310 

311 @classmethod 

312 def create_from_butler( 

313 cls, butler: Butler, butler_config_file: str | Config, storageClassName: str | None = None 

314 ) -> MetricTestRepo: 

315 """Create a MetricTestRepo from an existing Butler instance. 

316 

317 Parameters 

318 ---------- 

319 butler 

320 `Butler` instance used for setting up the repository. 

321 butler_config_file 

322 Path to the config file or the `Config` instance used to set up 

323 that Butler instance. 

324 storageClassName 

325 Name of storage class to use for datasets added to the test 

326 repository. A default will be used if none is specified. 

327 

328 Returns 

329 ------- 

330 repo 

331 New instance of `MetricTestRepo` using the provided `Butler` 

332 instance. 

333 """ 

334 self = cls.__new__(cls) 

335 butler = butler.clone(run=self._DEFAULT_RUN, collections=[self._DEFAULT_TAG]) 

336 self._do_init(butler, butler_config_file, storageClassName) 

337 return self 

338 

339 def _do_init( 

340 self, butler: Butler, butlerConfigFile: str | Config, storageClassName: str | None = None 

341 ) -> None: 

342 self.butler = butler 

343 self.storageClassFactory = StorageClassFactory() 

344 self.storageClassFactory.addFromConfig(butlerConfigFile) 

345 

346 # New datasets will be added to run and tag, but we will only look in 

347 # tag when looking up datasets. 

348 self.butler.collections.register(self._DEFAULT_TAG, CollectionType.TAGGED) 

349 

350 if storageClassName is None: 

351 storageClassName = self._DEFAULT_STORAGE_CLASS 

352 

353 # Create and register a DatasetType 

354 self.datasetType = addDatasetType( 

355 self.butler, "test_metric_comp", {"instrument", "visit"}, storageClassName 

356 ) 

357 

358 # Add needed Dimensions 

359 self.butler.registry.insertDimensionData("instrument", {"name": "DummyCamComp"}) 

360 self.butler.registry.insertDimensionData( 

361 "physical_filter", {"instrument": "DummyCamComp", "name": "d-r", "band": "R"} 

362 ) 

363 self.butler.registry.insertDimensionData("day_obs", {"instrument": "DummyCamComp", "id": 20200101}) 

364 self.butler.registry.insertDimensionData( 

365 "visit_system", {"instrument": "DummyCamComp", "id": 1, "name": "default"} 

366 ) 

367 visitStart = astropy.time.Time("2020-01-01 08:00:00.123456789", scale="tai") 

368 visitEnd = astropy.time.Time("2020-01-01 08:00:36.66", scale="tai") 

369 self.butler.registry.insertDimensionData( 

370 "visit", 

371 dict( 

372 instrument="DummyCamComp", 

373 id=423, 

374 name="fourtwentythree", 

375 physical_filter="d-r", 

376 timespan=Timespan(visitStart, visitEnd), 

377 day_obs=20200101, 

378 ), 

379 ) 

380 self.butler.registry.insertDimensionData( 

381 "visit", 

382 dict( 

383 instrument="DummyCamComp", 

384 id=424, 

385 name="fourtwentyfour", 

386 physical_filter="d-r", 

387 day_obs=20200101, 

388 ), 

389 ) 

390 

391 self.ref1 = self.addDataset({"instrument": "DummyCamComp", "visit": 423}) 

392 self.ref2 = self.addDataset({"instrument": "DummyCamComp", "visit": 424}) 

393 

394 def addDataset( 

395 self, dataId: dict[str, Any], run: str | None = None, datasetType: DatasetType | None = None 

396 ) -> DatasetRef: 

397 """Create a new example metric and add it to the named run with the 

398 given dataId. 

399 

400 Overwrites tags, so this does not try to associate the new dataset with 

401 existing tags. (If/when tags are needed this can be added to the 

402 arguments of this function.) 

403 

404 Parameters 

405 ---------- 

406 dataId : `dict` 

407 The dataId for the new metric. 

408 run : `str`, optional 

409 The name of the run to create and add a dataset to. If `None`, the 

410 dataset will be added to the root butler. 

411 datasetType : ``DatasetType``, optional 

412 The dataset type of the added dataset. If `None`, will use the 

413 default dataset type. 

414 

415 Returns 

416 ------- 

417 datasetRef : `DatasetRef` 

418 A reference to the added dataset. 

419 """ 

420 if run: 

421 self.butler.collections.register(run) 

422 else: 

423 run = self._DEFAULT_RUN 

424 metric = self._makeExampleMetrics() 

425 return self.butler.put( 

426 metric, self.datasetType if datasetType is None else datasetType, dataId, run=run 

427 ) 

428 

429 

430@contextmanager 

431def mock_env(new_environment: dict[str, str]) -> Iterator[None]: 

432 """Context manager to clear the process environment variables, replace them 

433 with new values, and restore them at the end of the test. 

434 

435 Parameters 

436 ---------- 

437 new_environment : `dict` [`str`, `str`] 

438 New environment variable values. 

439 """ 

440 with patch.dict(os.environ, new_environment, clear=True): 

441 yield