Skip to content

Commit 44ffbdf

Browse files
authored
Remove archive on import during move mode (#6551)
Fixes #1663. This modifies the `cleanup` method on the `ArchiveImportTask` to remove the original archive file on successful import when `move: yes`. I decided to do this such that partial imports do not destructively remove the archive. Perhaps this would be better as a configuration option, but I think that may go beyond the scope for this initial feature. I am unsure whether my method for "complete import" is strictly correct, I reasoned that we would check that all files had been moved out, but surely this data exists more concretely somewhere. Let me know if this should be polished, or there's more nuance I'm not seeing! Additionally, I added unit tests for this behavior under the *old* test framework since existing helpers are present for the archive backend setup. This way we minimize the diff and maximize probability that my test is behaving correctly. I will gladly port this entire archive test section to pytest in a future PR if this is a dealbreaker.
2 parents 61a4ba9 + 971de16 commit 44ffbdf

5 files changed

Lines changed: 97 additions & 10 deletions

File tree

beets/importer/tasks.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -803,19 +803,27 @@ def _emit_imported(self, lib):
803803
class ArchiveImportTask(SentinelImportTask):
804804
"""An import task that represents the processing of an archive.
805805
806-
`toppath` must be a `zip`, `tar`, or `rar` archive. Archive tasks
807-
serve two purposes:
806+
`toppath` must be a `zip`, `tar`, `rar`, or `7z` archive. Archive tasks
807+
serve three purposes:
808808
- First, it will unarchive the files to a temporary directory and
809809
return it. The client should read tasks from the resulting
810810
directory and send them through the pipeline.
811811
- Second, it will clean up the temporary directory when it proceeds
812812
through the pipeline. The client should send the archive task
813813
after sending the rest of the music tasks to make this work.
814+
- Third, when the import mode is ``move`` and every file in the
815+
archive was successfully imported, it will remove the source
816+
archive itself. Archives are preserved on partial imports and in
817+
non-move modes.
814818
"""
815819

816820
def __init__(self, toppath):
817821
super().__init__(toppath, ())
818822
self.extracted = False
823+
# ``extract()`` reassigns ``self.toppath`` to the temp extraction
824+
# directory; here we track the original archive location so
825+
# ``cleanup()`` can remove it when the import mode demands.
826+
self.archive_path = toppath
819827

820828
@classmethod
821829
def is_archive(cls, path):
@@ -862,13 +870,37 @@ def handlers(cls) -> list[ArchiveHandler]:
862870
return _handlers
863871

864872
def cleanup(self, copy=False, delete=False, move=False):
865-
"""Removes the temporary directory the archive was extracted to."""
866-
if self.extracted and self.toppath:
873+
"""Remove the temporary extraction directory and optionally the archive.
874+
875+
In ``move`` mode, if the extraction directory is empty after the
876+
pipeline has run (i.e. every file in the archive was successfully
877+
imported) also remove the source archive. Archives are preserved on
878+
partial imports and in non-move modes.
879+
"""
880+
if not self.extracted:
881+
return
882+
883+
all_files_imported = move and not any(
884+
files for _, _, files in os.walk(util.syspath(self.toppath))
885+
)
886+
887+
log.debug(
888+
"Removing extracted directory: {}",
889+
util.displayable_path(self.toppath),
890+
)
891+
shutil.rmtree(util.syspath(self.toppath))
892+
893+
if all_files_imported:
894+
log.debug(
895+
"Removing imported archive: {}",
896+
util.displayable_path(self.archive_path),
897+
)
898+
util.remove(self.archive_path)
899+
elif move:
867900
log.debug(
868-
"Removing extracted directory: {}",
869-
util.displayable_path(self.toppath),
901+
"Not removing partially imported archive: {}",
902+
util.displayable_path(self.archive_path),
870903
)
871-
shutil.rmtree(util.syspath(self.toppath))
872904

873905
def extract(self):
874906
"""Extracts the archive to a temporary directory and sets

docs/changelog.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ New features
1919
would be updated"* instead of *"N playlists updated"*. The ``--format`` option
2020
allows customizing the track line format. The ``--pretend-paths`` option was
2121
removed (use ``--format='$path'`` instead). :bug:`6183`
22+
- :ref:`import-cmd`: When importing an archive (zip, tar, rar, or 7z) with
23+
``move: yes``, the source archive is now removed after a successful import.
24+
Archives are preserved if any file in the archive was not imported (e.g.
25+
skipped as a duplicate, or the import was aborted), and in non-move import
26+
modes.
2227

2328
..
2429
Bug fixes

docs/reference/cli.rst

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,11 @@ Optional command flags:
7171

7272
- By default, the command copies files to your library directory and updates the
7373
ID3 tags on your music. In order to move the files, instead of copying, use
74-
the ``-m`` (move) option. If you'd like to leave your music files untouched,
75-
try the ``-C`` (don't copy) and ``-W`` (don't write tags) options. You can
76-
also disable this behavior by default in the configuration file (below).
74+
the ``-m`` (move) option. When importing an archive with ``-m``, if all files
75+
are imported, the archive is removed from disk. If you'd like to leave your
76+
music files untouched, try the ``-C`` (don't copy) and ``-W`` (don't write
77+
tags) options. You can also disable this behavior by default in the
78+
configuration file (below).
7779
- Also, you can disable the autotagging behavior entirely using ``-A`` (don't
7880
autotag)---then your music will be imported with its existing metadata.
7981
- During a long tagging import, it can be useful to keep track of albums that

docs/reference/config.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,9 @@ beets will copy and then delete when a simple rename is impossible.) Moving
581581
files can be risky—it's a good idea to keep a backup in case beets doesn't do
582582
what you expect with your files.
583583

584+
In the case of a ``move`` when importing an archive, the archive will be removed
585+
if all contents were successfully imported.
586+
584587
This option *overrides* ``copy``, so enabling it will always move (and not copy)
585588
files. The ``-c`` switch to the ``beet import`` command, however, still takes
586589
precedence.

test/test_importer.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,9 @@ def setUp(self):
180180
super().setUp()
181181
self.want_resume = False
182182
self.config["incremental"] = False
183+
self.config["copy"] = False
184+
self.config["move"] = False
185+
self.config["delete"] = False
183186
self._old_home = None
184187

185188
def test_rm(self):
@@ -191,6 +194,48 @@ def test_rm(self):
191194
archive_task.finalize(self)
192195
assert not tmp_path.exists()
193196

197+
def test_archive_removed_on_move_complete(self):
198+
zip_path = create_archive(self)
199+
archive_task = importer.ArchiveImportTask(zip_path)
200+
archive_task.extract()
201+
for root, _, files in os.walk(syspath(archive_task.toppath)):
202+
for f in files:
203+
os.remove(os.path.join(root, f))
204+
assert Path(os.fsdecode(zip_path)).exists()
205+
archive_task.cleanup(move=True)
206+
assert not Path(os.fsdecode(zip_path)).exists()
207+
208+
def test_archive_preserved_on_move_partial(self):
209+
zip_path = create_archive(self)
210+
archive_task = importer.ArchiveImportTask(zip_path)
211+
archive_task.extract()
212+
archive_task.cleanup(move=True)
213+
assert Path(os.fsdecode(zip_path)).exists()
214+
215+
def test_archive_preserved_on_copy(self):
216+
zip_path = create_archive(self)
217+
archive_task = importer.ArchiveImportTask(zip_path)
218+
archive_task.extract()
219+
archive_task.cleanup(copy=True)
220+
assert Path(os.fsdecode(zip_path)).exists()
221+
222+
def test_tempdir_removed_in_all_modes(self):
223+
for cleanup_kwargs in (
224+
{},
225+
{"move": True},
226+
{"copy": True},
227+
{"copy": True, "delete": True},
228+
):
229+
zip_path = create_archive(self)
230+
archive_task = importer.ArchiveImportTask(zip_path)
231+
archive_task.extract()
232+
tmp_path = Path(os.fsdecode(archive_task.toppath))
233+
assert tmp_path.exists(), f"extract failed for {cleanup_kwargs}"
234+
archive_task.cleanup(**cleanup_kwargs)
235+
assert not tmp_path.exists(), (
236+
f"tempdir {tmp_path} not removed for {cleanup_kwargs}"
237+
)
238+
194239

195240
class ImportZipTest(AsIsImporterMixin, ImportTestCase):
196241
def test_import_zip(self):

0 commit comments

Comments
 (0)