Coverage for tests/test_cliCmdPruneDatasets.py: 38%

110 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-13 01:31 -0800

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 

28"""Unit tests for daf_butler CLI prune-datasets subcommand.""" 

29 

30import unittest 

31from itertools import chain 

32from unittest.mock import patch 

33 

34from astropy.table import Table 

35 

36# Tests require the SqlRegistry 

37import lsst.daf.butler.registry.sql_registry 

38import lsst.daf.butler.script 

39from lsst.daf.butler import CollectionInfo, CollectionType 

40from lsst.daf.butler.cli.butler import cli as butlerCli 

41from lsst.daf.butler.cli.cmd.commands import ( 

42 pruneDatasets_askContinueMsg, 

43 pruneDatasets_didNotRemoveAforementioned, 

44 pruneDatasets_didRemoveAforementioned, 

45 pruneDatasets_didRemoveMsg, 

46 pruneDatasets_errNoCollectionRestriction, 

47 pruneDatasets_errNoOp, 

48 pruneDatasets_errPruneOnNotRun, 

49 pruneDatasets_errPurgeAndDisassociate, 

50 pruneDatasets_errQuietWithDryRun, 

51 pruneDatasets_noDatasetsFound, 

52 pruneDatasets_willRemoveMsg, 

53 pruneDatasets_wouldDisassociateAndRemoveMsg, 

54 pruneDatasets_wouldDisassociateMsg, 

55 pruneDatasets_wouldRemoveMsg, 

56) 

57from lsst.daf.butler.cli.utils import LogCliRunner, astropyTablesToStr, clickResultMsg 

58from lsst.daf.butler.direct_butler import DirectButler 

59from lsst.daf.butler.script import QueryDatasets 

60 

61doFindTables = True 

62 

63 

64def getTables(): 

65 """Return test table.""" 

66 if doFindTables: 

67 yield from (Table(((1, 2, 3),), names=("foo",)),) 

68 return () 

69 

70 

71def getDatasets(): 

72 """Return the datasets string.""" 

73 yield ["datasets"] 

74 

75 

76def getRefs(): 

77 """Return all the datasets as a single list.""" 

78 return list(chain(*getDatasets())) 

79 

80 

81def makeQueryDatasets(*args, **kwargs): 

82 """Return a query datasets object.""" 

83 if not kwargs.get("glob"): 

84 # Use all dataset types if not specified. 

85 kwargs["glob"] = ("*",) 

86 return QueryDatasets(*args, **kwargs) 

87 

88 

89class PruneDatasetsTestCase(unittest.TestCase): 

90 """Tests the ``prune_datasets`` "command" function (in 

91 ``cli/cmd/commands.py``) and the ``pruneDatasets`` "script" function (in 

92 ``scripts/_pruneDatasets.py``). 

93 

94 ``Butler.pruneDatasets`` and a few other functions that get called before 

95 it are mocked, and tests check for expected arguments to those mocks. 

96 """ 

97 

98 def setUp(self): 

99 self.repo = "here" 

100 

101 @staticmethod 

102 def makeQueryDatasetsArgs(*, repo, **kwargs): 

103 expectedArgs = dict( 

104 repo=repo, collections=("*",), where="", find_first=True, show_uri=False, glob=tuple() 

105 ) 

106 expectedArgs.update(kwargs) 

107 return expectedArgs 

108 

109 @staticmethod 

110 def makePruneDatasetsArgs(**kwargs): 

111 expectedArgs = dict(refs=tuple(), disassociate=False, tags=(), purge=False, unstore=False) 

112 expectedArgs.update(kwargs) 

113 return expectedArgs 

114 

115 # Mock the QueryDatasets.getTables function to return a set of Astropy 

116 # tables, similar to what would be returned by a call to 

117 # QueryDatasets.getTables on a repo with real data. 

118 @patch.object(lsst.daf.butler.script._pruneDatasets.QueryDatasets, "getTables", side_effect=getTables) 

119 # Mock the QueryDatasets.getDatasets function. Normally it would return a 

120 # list of queries.DatasetQueryResults, but all we need to do is verify that 

121 # the output of this function is passed into our pruneDatasets magicMock, 

122 # so we can return something arbitrary that we can test is equal.""" 

123 @patch.object(lsst.daf.butler.script._pruneDatasets.QueryDatasets, "getDatasets", side_effect=getDatasets) 

124 # Mock the actual QueryDatasets class, so we can inspect calls to its init 

125 # function. Note that the side_effect returns an instance of QueryDatasets, 

126 # so this mock records and then is a pass-through. 

127 @patch.object(lsst.daf.butler.script._pruneDatasets, "QueryDatasets", side_effect=makeQueryDatasets) 

128 # Mock the pruneDatasets butler command so we can test for expected calls 

129 # to it, without dealing with setting up a full repo with data for it. 

130 @patch.object(DirectButler, "pruneDatasets") 

131 def run_test( 

132 self, 

133 mockPruneDatasets, 

134 mockQueryDatasets_init, 

135 mockQueryDatasets_getDatasets, 

136 mockQueryDatasets_getTables, 

137 cliArgs, 

138 exMsgs, 

139 exPruneDatasetsCallArgs, 

140 exGetTablesCalled, 

141 exQueryDatasetsCallArgs, 

142 invokeInput=None, 

143 exPruneDatasetsExitCode=0, 

144 ): 

145 """Execute the test. 

146 

147 Makes a temporary repo, invokes ``prune-datasets``. Verifies expected 

148 output, exit codes, and mock calls. 

149 

150 Parameters 

151 ---------- 

152 mockPruneDatasets : `MagicMock` 

153 The MagicMock for the ``Butler.pruneDatasets`` function. 

154 mockQueryDatasets_init : `MagicMock` 

155 The MagicMock for the ``QueryDatasets.__init__`` function. 

156 mockQueryDatasets_getDatasets : `MagicMock` 

157 The MagicMock for the ``QueryDatasets.getDatasets`` function. 

158 mockQueryDatasets_getTables : `MagicMock` 

159 The MagicMock for the ``QueryDatasets.getTables`` function. 

160 cliArgs : `list` [`str`] 

161 The arguments to pass to the command line. Do not include the 

162 subcommand name or the repo. 

163 exMsgs : `list` [`str`] or None 

164 A list of text fragments that should appear in the text output 

165 after calling the CLI command, or None if no output should be 

166 produced. 

167 exPruneDatasetsCallArgs : `dict` [`str`, `Any`] 

168 The arguments that ``Butler.pruneDatasets`` should have been called 

169 with, or None if that function should not have been called. 

170 exGetTablesCalled : bool 

171 `True` if ``QueryDatasets.getTables`` should have been called, else 

172 `False`. 

173 exQueryDatasetsCallArgs : `dict` [`str`, `Any`] 

174 The arguments that ``QueryDatasets.__init__`` should have bene 

175 called with, or `None` if the function should not have been called. 

176 invokeInput : `str`, optional. 

177 As string to pass to the ``CliRunner.invoke`` `input` argument. By 

178 default None. 

179 exPruneDatasetsExitCode : `int` 

180 The expected exit code returned from invoking ``prune-datasets``. 

181 """ 

182 runner = LogCliRunner() 

183 with runner.isolated_filesystem(): 

184 # Make a repo so a butler can be created 

185 result = runner.invoke(butlerCli, ["create", self.repo]) 

186 self.assertEqual(result.exit_code, 0, clickResultMsg(result)) 

187 

188 # Run the prune-datasets CLI command, this will call all of our 

189 # mocks: 

190 cliArgs = ["prune-datasets", self.repo] + cliArgs 

191 result = runner.invoke(butlerCli, cliArgs, input=invokeInput) 

192 self.assertEqual(result.exit_code, exPruneDatasetsExitCode, clickResultMsg(result)) 

193 

194 # Verify the Butler.pruneDatasets was called exactly once with 

195 # expected arguments. The datasets argument is the value returned 

196 # by QueryDatasets, which we've mocked with side effect 

197 # ``getDatasets()``. 

198 if exPruneDatasetsCallArgs: 

199 mockPruneDatasets.assert_called_once_with(**exPruneDatasetsCallArgs) 

200 else: 

201 mockPruneDatasets.assert_not_called() 

202 

203 # Less critical, but do a quick verification that the QueryDataset 

204 # member function mocks were called, in this case we expect one 

205 # time each. 

206 if exQueryDatasetsCallArgs: 

207 mockQueryDatasets_init.assert_called_once_with(**exQueryDatasetsCallArgs) 

208 else: 

209 mockQueryDatasets_init.assert_not_called() 

210 # If Butler.pruneDatasets was not called, then 

211 # QueryDatasets.getDatasets also does not get called. 

212 if exPruneDatasetsCallArgs: 

213 mockQueryDatasets_getDatasets.assert_called_once() 

214 else: 

215 mockQueryDatasets_getDatasets.assert_not_called() 

216 if exGetTablesCalled: 

217 mockQueryDatasets_getTables.assert_called_once() 

218 else: 

219 mockQueryDatasets_getTables.assert_not_called() 

220 

221 if exMsgs is None: 

222 self.assertEqual("", result.output) 

223 else: 

224 for expectedMsg in exMsgs: 

225 self.assertIn(expectedMsg, result.output) 

226 

227 def test_defaults_doContinue(self): 

228 """Test running with the default values. 

229 

230 Verify that with the default flags that the subcommand says what it 

231 will do, prompts for input, and says that it's done. 

232 """ 

233 self.run_test( 

234 cliArgs=["myCollection", "--unstore"], 

235 exPruneDatasetsCallArgs=self.makePruneDatasetsArgs(refs=getRefs(), unstore=True), 

236 exQueryDatasetsCallArgs=self.makeQueryDatasetsArgs(repo=self.repo, collections=("myCollection",)), 

237 exGetTablesCalled=True, 

238 exMsgs=( 

239 pruneDatasets_willRemoveMsg, 

240 pruneDatasets_askContinueMsg, 

241 astropyTablesToStr(getTables()), 

242 pruneDatasets_didRemoveAforementioned, 

243 ), 

244 invokeInput="yes", 

245 ) 

246 

247 def test_defaults_doNotContinue(self): 

248 """Test running with the default values but not continuing. 

249 

250 Verify that with the default flags that the subcommand says what it 

251 will do, prompts for input, and aborts when told not to continue. 

252 """ 

253 self.run_test( 

254 cliArgs=["myCollection", "--unstore"], 

255 exPruneDatasetsCallArgs=None, 

256 exQueryDatasetsCallArgs=self.makeQueryDatasetsArgs(repo=self.repo, collections=("myCollection",)), 

257 exGetTablesCalled=True, 

258 exMsgs=( 

259 pruneDatasets_willRemoveMsg, 

260 pruneDatasets_askContinueMsg, 

261 pruneDatasets_didNotRemoveAforementioned, 

262 ), 

263 invokeInput="no", 

264 ) 

265 

266 def test_dryRun_unstore(self): 

267 """Test the --dry-run flag with --unstore. 

268 

269 Verify that with the dry-run flag the subcommand says what it would 

270 remove, but does not remove the datasets. 

271 """ 

272 self.run_test( 

273 cliArgs=["myCollection", "--dry-run", "--unstore"], 

274 exPruneDatasetsCallArgs=None, 

275 exQueryDatasetsCallArgs=self.makeQueryDatasetsArgs(repo=self.repo, collections=("myCollection",)), 

276 exGetTablesCalled=True, 

277 exMsgs=(pruneDatasets_wouldRemoveMsg, astropyTablesToStr(getTables())), 

278 ) 

279 

280 def test_dryRun_disassociate(self): 

281 """Test the --dry-run flag with --disassociate. 

282 

283 Verify that with the dry-run flag the subcommand says what it would 

284 remove, but does not remove the datasets. 

285 """ 

286 collection = "myCollection" 

287 self.run_test( 

288 cliArgs=[collection, "--dry-run", "--disassociate", "tag1"], 

289 exPruneDatasetsCallArgs=None, 

290 exQueryDatasetsCallArgs=self.makeQueryDatasetsArgs(repo=self.repo, collections=(collection,)), 

291 exGetTablesCalled=True, 

292 exMsgs=( 

293 pruneDatasets_wouldDisassociateMsg.format(collections=(collection,)), 

294 astropyTablesToStr(getTables()), 

295 ), 

296 ) 

297 

298 def test_dryRun_unstoreAndDisassociate(self): 

299 """Test the --dry-run flag with --unstore and --disassociate. 

300 

301 Verify that with the dry-run flag the subcommand says what it would 

302 remove, but does not remove the datasets. 

303 """ 

304 collection = "myCollection" 

305 self.run_test( 

306 cliArgs=[collection, "--dry-run", "--unstore", "--disassociate", "tag1"], 

307 exPruneDatasetsCallArgs=None, 

308 exQueryDatasetsCallArgs=self.makeQueryDatasetsArgs(repo=self.repo, collections=(collection,)), 

309 exGetTablesCalled=True, 

310 exMsgs=( 

311 pruneDatasets_wouldDisassociateAndRemoveMsg.format(collections=(collection,)), 

312 astropyTablesToStr(getTables()), 

313 ), 

314 ) 

315 

316 def test_noConfirm(self): 

317 """Test the --no-confirm flag. 

318 

319 Verify that with the no-confirm flag the subcommand does not ask for 

320 a confirmation, prints the did remove message and the tables that were 

321 passed for removal. 

322 """ 

323 self.run_test( 

324 cliArgs=["myCollection", "--no-confirm", "--unstore"], 

325 exPruneDatasetsCallArgs=self.makePruneDatasetsArgs(refs=getRefs(), unstore=True), 

326 exQueryDatasetsCallArgs=self.makeQueryDatasetsArgs(repo=self.repo, collections=("myCollection",)), 

327 exGetTablesCalled=True, 

328 exMsgs=(pruneDatasets_didRemoveMsg, astropyTablesToStr(getTables())), 

329 ) 

330 

331 def test_quiet(self): 

332 """Test the --quiet flag. 

333 

334 Verify that with the quiet flag and the no-confirm flags set that no 

335 output is produced by the subcommand. 

336 """ 

337 self.run_test( 

338 cliArgs=["myCollection", "--quiet", "--unstore"], 

339 exPruneDatasetsCallArgs=self.makePruneDatasetsArgs(refs=getRefs(), unstore=True), 

340 exQueryDatasetsCallArgs=self.makeQueryDatasetsArgs(repo=self.repo, collections=("myCollection",)), 

341 exGetTablesCalled=True, 

342 exMsgs=None, 

343 ) 

344 

345 def test_quietWithDryRun(self): 

346 """Test for an error using the --quiet flag with --dry-run.""" 

347 self.run_test( 

348 cliArgs=["--quiet", "--dry-run", "--unstore"], 

349 exPruneDatasetsCallArgs=None, 

350 exQueryDatasetsCallArgs=None, 

351 exGetTablesCalled=False, 

352 exMsgs=(pruneDatasets_errQuietWithDryRun,), 

353 exPruneDatasetsExitCode=1, 

354 ) 

355 

356 def test_noCollections(self): 

357 """Test for an error if no collections are indicated.""" 

358 self.run_test( 

359 cliArgs=["--find-all", "--unstore"], 

360 exPruneDatasetsCallArgs=None, 

361 exQueryDatasetsCallArgs=None, 

362 exGetTablesCalled=False, 

363 exMsgs=(pruneDatasets_errNoCollectionRestriction,), 

364 exPruneDatasetsExitCode=1, 

365 ) 

366 

367 def test_noDatasets(self): 

368 """Test for expected outputs when no datasets are found.""" 

369 global doFindTables 

370 reset = doFindTables 

371 try: 

372 doFindTables = False 

373 self.run_test( 

374 cliArgs=["myCollection", "--unstore"], 

375 exPruneDatasetsCallArgs=None, 

376 exQueryDatasetsCallArgs=self.makeQueryDatasetsArgs( 

377 repo=self.repo, collections=("myCollection",) 

378 ), 

379 exGetTablesCalled=True, 

380 exMsgs=(pruneDatasets_noDatasetsFound,), 

381 ) 

382 finally: 

383 doFindTables = reset 

384 

385 def test_purgeWithDisassociate(self): 

386 """Verify there is an error when --purge and --disassociate are both 

387 passed in. 

388 """ 

389 self.run_test( 

390 cliArgs=["--purge", "run", "--disassociate", "tag1", "tag2"], 

391 exPruneDatasetsCallArgs=None, 

392 exQueryDatasetsCallArgs=None, # should not make it far enough to call this. 

393 exGetTablesCalled=False, # ...or this. 

394 exMsgs=(pruneDatasets_errPurgeAndDisassociate,), 

395 exPruneDatasetsExitCode=1, 

396 ) 

397 

398 def test_purgeNoOp(self): 

399 """Verify there is an error when none of --purge, --unstore, or 

400 --disassociate are passed. 

401 """ 

402 self.run_test( 

403 cliArgs=[], 

404 exPruneDatasetsCallArgs=None, 

405 exQueryDatasetsCallArgs=None, # should not make it far enough to call this. 

406 exGetTablesCalled=False, # ...or this. 

407 exMsgs=(pruneDatasets_errNoOp,), 

408 exPruneDatasetsExitCode=1, 

409 ) 

410 

411 @patch.object( 

412 lsst.daf.butler.direct_butler._direct_butler_collections.DirectButlerCollections, 

413 "get_info", 

414 side_effect=lambda x: CollectionInfo(name="run", type=CollectionType.RUN), 

415 ) 

416 def test_purgeImpliedArgs(self, mockGetCollectionType): 

417 """Verify the arguments implied by --purge. 

418 

419 --purge <run> implies the following arguments to butler.pruneDatasets: 

420 purge=True, disassociate=True, unstore=True 

421 And for QueryDatasets, if COLLECTIONS is not passed then <run> gets 

422 used as the value of COLLECTIONS (and when there is a COLLECTIONS 

423 value then find_first gets set to True) 

424 """ 

425 self.run_test( 

426 cliArgs=["--purge", "run"], 

427 invokeInput="yes", 

428 exPruneDatasetsCallArgs=self.makePruneDatasetsArgs( 

429 purge=True, refs=getRefs(), disassociate=True, unstore=True 

430 ), 

431 exQueryDatasetsCallArgs=self.makeQueryDatasetsArgs( 

432 repo=self.repo, collections=("run",), find_first=True 

433 ), 

434 exGetTablesCalled=True, 

435 exMsgs=( 

436 pruneDatasets_willRemoveMsg, 

437 pruneDatasets_askContinueMsg, 

438 astropyTablesToStr(getTables()), 

439 pruneDatasets_didRemoveAforementioned, 

440 ), 

441 ) 

442 

443 @patch.object( 

444 lsst.daf.butler.direct_butler._direct_butler_collections.DirectButlerCollections, 

445 "get_info", 

446 side_effect=lambda x: CollectionInfo(name="run", type=CollectionType.RUN), 

447 ) 

448 def test_purgeImpliedArgsWithCollections(self, mockGetCollectionType): 

449 """Verify the arguments implied by --purge, with a COLLECTIONS.""" 

450 self.run_test( 

451 cliArgs=["myCollection", "--purge", "run"], 

452 invokeInput="yes", 

453 exPruneDatasetsCallArgs=self.makePruneDatasetsArgs( 

454 purge=True, disassociate=True, unstore=True, refs=getRefs() 

455 ), 

456 exQueryDatasetsCallArgs=self.makeQueryDatasetsArgs( 

457 repo=self.repo, collections=("myCollection",), find_first=True 

458 ), 

459 exGetTablesCalled=True, 

460 exMsgs=( 

461 pruneDatasets_willRemoveMsg, 

462 pruneDatasets_askContinueMsg, 

463 astropyTablesToStr(getTables()), 

464 pruneDatasets_didRemoveAforementioned, 

465 ), 

466 ) 

467 

468 @patch.object( 

469 lsst.daf.butler.direct_butler._direct_butler_collections.DirectButlerCollections, 

470 "get_info", 

471 side_effect=lambda x: CollectionInfo(name="myTaggedCollection", type=CollectionType.TAGGED), 

472 ) 

473 def test_purgeOnNonRunCollection(self, mockGetCollectionType): 

474 """Verify calling run on a non-run collection fails with expected 

475 error message. 

476 """ 

477 collectionName = "myTaggedCollection" 

478 self.run_test( 

479 cliArgs=["--purge", collectionName], 

480 invokeInput="yes", 

481 exPruneDatasetsCallArgs=None, 

482 exQueryDatasetsCallArgs=None, 

483 exGetTablesCalled=False, 

484 exMsgs=(pruneDatasets_errPruneOnNotRun.format(collection=collectionName),), 

485 exPruneDatasetsExitCode=1, 

486 ) 

487 

488 def test_disassociateImpliedArgs(self): 

489 """Verify the arguments implied by --disassociate. 

490 

491 --disassociate <tags> implies the following arguments to 

492 butler.pruneDatasets: 

493 disassociate=True, tags=<tags> 

494 and if COLLECTIONS is not passed then <tags> gets used as the value 

495 of COLLECTIONS. 

496 

497 Use the --no-confirm flag instead of invokeInput="yes", and check for 

498 the associated output. 

499 """ 

500 self.run_test( 

501 cliArgs=["--disassociate", "tag1", "--disassociate", "tag2", "--no-confirm"], 

502 exPruneDatasetsCallArgs=self.makePruneDatasetsArgs( 

503 tags=("tag1", "tag2"), disassociate=True, refs=getRefs() 

504 ), 

505 exQueryDatasetsCallArgs=self.makeQueryDatasetsArgs( 

506 repo=self.repo, collections=("tag1", "tag2"), find_first=True 

507 ), 

508 exGetTablesCalled=True, 

509 exMsgs=(pruneDatasets_didRemoveMsg, astropyTablesToStr(getTables())), 

510 ) 

511 

512 def test_disassociateImpliedArgsWithCollections(self): 

513 """Verify the arguments implied by --disassociate, with a --collection 

514 flag. 

515 """ 

516 self.run_test( 

517 cliArgs=["myCollection", "--disassociate", "tag1", "--disassociate", "tag2", "--no-confirm"], 

518 exPruneDatasetsCallArgs=self.makePruneDatasetsArgs( 

519 tags=("tag1", "tag2"), disassociate=True, refs=getRefs() 

520 ), 

521 exQueryDatasetsCallArgs=self.makeQueryDatasetsArgs( 

522 repo=self.repo, collections=("myCollection",), find_first=True 

523 ), 

524 exGetTablesCalled=True, 

525 exMsgs=(pruneDatasets_didRemoveMsg, astropyTablesToStr(getTables())), 

526 ) 

527 

528 

529if __name__ == "__main__": 

530 unittest.main()