Coverage for tests/test_cliCmdRemoveCollections.py: 20%
97 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-20 01:50 -0800
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-20 01:50 -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/>.
28"""Unit tests for daf_butler CLI prune-collections subcommand."""
30import os
31import unittest
32from collections.abc import Sequence
34from astropy.table import Table
35from numpy import array
37from lsst.daf.butler import Butler, CollectionType
38from lsst.daf.butler.cli.butler import cli as butlerCli
39from lsst.daf.butler.cli.cmd._remove_collections import (
40 abortedMsg,
41 canNotRemoveFoundRuns,
42 didNotRemoveFoundRuns,
43 noNonRunCollectionsMsg,
44 removedCollectionsMsg,
45 willRemoveCollectionChainsMsg,
46 willRemoveCollectionMsg,
47)
48from lsst.daf.butler.cli.utils import LogCliRunner, clickResultMsg
49from lsst.daf.butler.script.removeCollections import removeCollections
50from lsst.daf.butler.tests.utils import (
51 ButlerTestHelper,
52 MetricTestRepo,
53 makeTestTempDir,
54 readTable,
55 removeTestTempDir,
56)
58TESTDIR = os.path.abspath(os.path.dirname(__file__))
60QueryCollectionsRow = tuple[str, str] | tuple[str, str, str]
61RemoveCollectionRow = tuple[str, str]
64class RemoveCollectionTest(unittest.TestCase, ButlerTestHelper):
65 """Test executing remove collection."""
67 def setUp(self):
68 self.runner = LogCliRunner()
70 self.root = makeTestTempDir(TESTDIR)
71 self.testRepo = MetricTestRepo(
72 self.root, configFile=os.path.join(TESTDIR, "config/basic/butler.yaml")
73 )
75 def tearDown(self):
76 removeTestTempDir(self.root)
78 def _verify_remove(
79 self,
80 collection: str,
81 before_rows: Sequence[QueryCollectionsRow],
82 remove_rows: Sequence[RemoveCollectionRow],
83 after_rows: Sequence[QueryCollectionsRow],
84 ):
85 """Remove collections, with verification that expected collections are
86 present before removing, that the command reports expected collections
87 to be removed, and that expected collections are present after removal.
89 Parameters
90 ----------
91 collection : `str`
92 The name of the collection, or glob pattern for collections, to
93 remove.
94 before_rows : `Sequence` [ `QueryCollectionsRow` ]
95 The rows that should be in the table returned by query-collections
96 before removing the collection.
97 remove_rows : `Sequence` [ `RemoveCollectionRow` ]
98 The rows that should be in the "will remove" table while removing
99 collections.
100 after_rows : `Sequence` [ `QueryCollectionsRow` ]
101 The rows that should be in the table returned by query-collections
102 after removing the collection.
103 """
105 def _query_collection_column_names(rows):
106 # If there is a chained collection in the table then there is a
107 # definition column, otherwise there is only the name and type
108 # columns.
109 if len(rows[0]) == 2:
110 return ("Name", "Type")
111 elif len(rows[0]) == 3:
112 return ("Name", "Type", "Children")
113 else:
114 raise RuntimeError(f"Unhandled column count: {len(rows[0])}")
116 result = self.runner.invoke(butlerCli, ["query-collections", self.root, "--chains", "TABLE"])
117 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
118 expected = Table(array(before_rows), names=_query_collection_column_names(before_rows))
119 self.assertAstropyTablesEqual(readTable(result.output), expected, unorderedRows=True)
121 removal = removeCollections(repo=self.root, collection=collection, remove_from_parents=False)
122 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
123 expected = Table(array(remove_rows), names=("Collection", "Collection Type"))
124 self.assertAstropyTablesEqual(removal.removeCollectionsTable, expected)
125 removal.onConfirmation()
127 result = self.runner.invoke(butlerCli, ["query-collections", self.root])
128 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
129 expected = Table(array(after_rows), names=_query_collection_column_names(after_rows))
130 self.assertAstropyTablesEqual(readTable(result.output), expected, unorderedRows=True)
132 def testRemoveScript(self):
133 """Test removing collections.
135 Combining several tests into one case allows us to reuse the test repo,
136 which saves execution time.
137 """
138 # Test wildcard with chained collections:
140 # Add a couple chained collections
141 for parent, child in (
142 ("chained-run-1", "ingest/run"),
143 ("chained-run-2", "ingest/run"),
144 ):
145 result = self.runner.invoke(
146 butlerCli,
147 ["collection-chain", self.root, parent, child],
148 )
149 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
151 self._verify_remove(
152 collection="chained-run-*",
153 before_rows=(
154 ("chained-run-1", "CHAINED", "ingest/run"),
155 ("chained-run-2", "CHAINED", "ingest/run"),
156 ("ingest", "TAGGED", ""),
157 ("ingest/run", "RUN", ""),
158 ),
159 remove_rows=(
160 ("chained-run-1", "CHAINED"),
161 ("chained-run-2", "CHAINED"),
162 ),
163 after_rows=(
164 ("ingest", "TAGGED"),
165 ("ingest/run", "RUN"),
166 ),
167 )
169 # Test a single tagged collection:
171 self._verify_remove(
172 collection="ingest",
173 before_rows=(
174 ("ingest", "TAGGED"),
175 ("ingest/run", "RUN"),
176 ),
177 remove_rows=(("ingest", "TAGGED"),),
178 after_rows=(("ingest/run", "RUN"),),
179 )
181 def testRemoveCmd(self):
182 """Test remove command outputs."""
183 # Test expected output with a non-existent collection:
185 result = self.runner.invoke(butlerCli, ["remove-collections", self.root, "fake_collection"])
186 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
187 self.assertIn(noNonRunCollectionsMsg, result.stdout)
189 # Add a couple chained collections
190 for parent, child in (
191 ("chained-run-1", "ingest/run"),
192 ("chained-run-2", "ingest/run"),
193 ):
194 result = self.runner.invoke(
195 butlerCli,
196 ["collection-chain", self.root, parent, child],
197 )
198 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
200 # Test aborting a removal
202 result = self.runner.invoke(
203 butlerCli,
204 ["remove-collections", self.root, "chained-run-1"],
205 input="no",
206 )
207 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
208 self.assertIn(abortedMsg, result.stdout)
210 # Remove with --no-confirm, it's expected to run silently.
212 result = self.runner.invoke(
213 butlerCli, ["remove-collections", self.root, "chained-run-1", "--no-confirm"]
214 )
215 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
216 self.assertIn(removedCollectionsMsg, result.stdout)
217 self.assertIn("chained-run-1", result.stdout)
219 # verify chained-run-1 was removed:
221 butler = Butler.from_config(self.root)
222 collections = butler.registry.queryCollections(
223 collectionTypes=frozenset(
224 (
225 CollectionType.RUN,
226 CollectionType.TAGGED,
227 CollectionType.CHAINED,
228 CollectionType.CALIBRATION,
229 )
230 ),
231 )
232 self.assertCountEqual(["ingest/run", "ingest", "chained-run-2"], collections)
234 # verify chained-run-2 can be removed with prompting and expected CLI
235 # output
237 result = self.runner.invoke(
238 butlerCli,
239 ["remove-collections", self.root, "chained-run-2"],
240 input="yes",
241 )
242 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
243 self.assertIn(willRemoveCollectionMsg, result.stdout)
244 self.assertIn("chained-run-2 CHAINED", result.stdout)
246 # try to remove a run table, check for the "can not remove run" message
248 result = self.runner.invoke(butlerCli, ["collection-chain", self.root, "run-chain", child])
249 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
250 result = self.runner.invoke(
251 # removes run-chain (chained collection), but can not remove the
252 # run collection, and emits a message that says so.
253 butlerCli,
254 ["remove-collections", self.root, "*run*"],
255 input="yes",
256 )
257 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
258 self.assertIn(canNotRemoveFoundRuns, result.stdout)
259 self.assertIn("ingest/run", result.stdout)
261 # try to remove a run table with --no-confirm, check for the "did not
262 # remove run" message
264 result = self.runner.invoke(butlerCli, ["collection-chain", self.root, "run-chain", child])
265 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
266 result = self.runner.invoke(
267 # removes run-chain (chained collection), but can not remove the
268 # run collection, and emits a message that says so.
269 butlerCli,
270 ["remove-collections", self.root, "*run*", "--no-confirm"],
271 )
272 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
273 self.assertIn(didNotRemoveFoundRuns, result.stdout)
274 self.assertIn("ingest/run", result.stdout)
276 def testRemoveFromParents(self) -> None:
277 butler = Butler(self.root, writeable=True)
278 butler.collections.register("tag1", CollectionType.TAGGED)
279 butler.collections.register("tag2", CollectionType.TAGGED)
280 butler.collections.register("chain1", CollectionType.CHAINED)
281 butler.collections.register("chain2", CollectionType.CHAINED)
282 butler.collections.register("chain3", CollectionType.CHAINED)
283 butler.collections.redefine_chain("chain1", ["tag1", "tag2", "chain3"])
284 butler.collections.redefine_chain("chain2", ["tag1"])
286 # Make sure the printed output is correct.
287 removal = removeCollections(repo=self.root, collection="tag*", remove_from_parents=True)
288 table = [tuple(row) for row in removal.removeChainsTable]
289 self.assertEqual(table, [("tag1", "chain1"), ("", "chain2"), ("tag2", "chain1")])
290 result = self.runner.invoke(
291 butlerCli,
292 ["remove-collections", "--remove-from-parents", self.root, "tag*"],
293 input="yes",
294 )
295 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
296 self.assertIn(willRemoveCollectionChainsMsg, result.stdout)
297 # Make sure the collections are deleted as expected.
298 self.assertEqual(
299 sorted(butler.collections.query("*")), ["chain1", "chain2", "chain3", "ingest", "ingest/run"]
300 )
301 self.assertEqual(butler.collections.get_info("chain1").children, ("chain3",))
304if __name__ == "__main__":
305 unittest.main()