Skip to content

Conversation

@willburden
Copy link

@willburden willburden commented Dec 12, 2025

Description

This is my first contribution here, hope it makes sense!

When running the Replace Plugin it fails due to the plugin's callback method having the wrong signature.

➜ ~ beet replace bowie changes ~/Downloads/changes.flac
Traceback (most recent call last):
  File "/home/will/.local/bin/beet", line 7, in <module>
    sys.exit(main())
             ~~~~^^
  File "/home/will/.local/share/pipx/venvs/beets/lib64/python3.13/site-packages/beets/ui/__init__.py", line 1713, in main
    _raw_main(args)
    ~~~~~~~~~^^^^^^
  File "/home/will/.local/share/pipx/venvs/beets/lib64/python3.13/site-packages/beets/ui/__init__.py", line 1692, in _raw_main
    subcommand.func(lib, suboptions, subargs)
    ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: ReplacePlugin.run() takes 3 positional arguments but 4 were given

On fixing this, I noticed that when replacing a file, the tags in the database are kept intact but are not written to the newly swapped-in file's metadata.

$ beet ls hunky dory changes
David Bowie - Hunky Dory - Changes

$ beet replace hunky dory changes ~/Downloads/changes_no_metadata.mp3 
Matching songs:
1. David Bowie - Hunky Dory - Changes
Which song would you like to replace? [1-1] (0 to cancel): 1

Replacing: /home/will/Downloads/changes_no_metadata.mp3 -> /home/will/Music/library/shared/David Bowie/Hunky Dory/01 Changes.mp3
Are you sure you want to replace this track? (y/N): y
Replacement successful.

$ beet ls hunky dory changes
David Bowie - Hunky Dory - Changes   (the database still has the tags)

$ beet write -p hunky dory changes   (but the file doesn't, so the user needs to run beet write)
 -  - 
  title:  -> Changes
  artist:  -> David Bowie
  artists:  -> David Bowie
  artist_sort:  -> David Bowie
  [...]

So I've updated it to call Item.write() immediately after replacing. To me this is a more intuitive behaviour but if it's preferred that the user should have to manually run beet write, I'm happy to undo this second change and just update the docs to reflect that.

I've written a test for the replacement behaviour.

To Do

  • Decide if automatically writing the metadata to the new file is okay.
  • Documentation.
  • Tests.
  • Changelog.

@willburden willburden requested a review from a team as a code owner December 12, 2025 23:25
@github-actions
Copy link

Thank you for the PR! The changelog has not been updated, so here is a friendly reminder to check if you need to add an entry.

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • In ReplacePlugin.run, the new opts parameter is unused; consider renaming it to _opts (or similar) to make the intent explicit and avoid future confusion about whether options are meant to be handled here.
  • In replace_file, both the delete and metadata-write paths catch a broad Exception and wrap it in UserError, which hides the original traceback; consider narrowing the exception types or logging the original exception so that unexpected errors remain debuggable while still providing a user-friendly message.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `ReplacePlugin.run`, the new `opts` parameter is unused; consider renaming it to `_opts` (or similar) to make the intent explicit and avoid future confusion about whether options are meant to be handled here.
- In `replace_file`, both the delete and metadata-write paths catch a broad `Exception` and wrap it in `UserError`, which hides the original traceback; consider narrowing the exception types or logging the original exception so that unexpected errors remain debuggable while still providing a user-friendly message.

## Individual Comments

### Comment 1
<location> `test/plugins/test_replace.py:16` </location>
<code_context>
 replace = ReplacePlugin()


 class TestReplace:
-    @pytest.fixture(autouse=True)
-    def _fake_dir(self, tmp_path):
</code_context>

<issue_to_address>
**suggestion (testing):** Add a test that exercises `ReplacePlugin.run` via the command interface to guard against future signature regressions and usage handling issues

The original bug was a wrong callback signature on `ReplacePlugin.run`, and we still don’t have a test that exercises it the way the CLI does, so both the signature and the `len(args) < 2` branch are untested.

Please add tests using `TestHelper`/`ui` helpers to either:
- Register the plugin and invoke the `replace` command as the CLI would, or
- Directly call `replace.run(lib, opts, args)` with a realistic `optparse.Values` and argument list.

Suggested cases:
1) A usage test: too few args → assert `ui.UserError` with the expected message.
2) A happy-path smoke test: valid query + replacement path, with `replace.replace_file` monkeypatched to avoid I/O, just to ensure the main path runs without errors.

This will protect against future regressions in the command callback signature and usage handling.

Suggested implementation:

```python
import shutil
from pathlib import Path
from optparse import Values

import pytest

from beets import ui
from beets.library import Item, Library
from beets.test import _common
from beets.test.helper import TestHelper
from beetsplug.replace import ReplacePlugin

```

```python
replace = ReplacePlugin()


class TestReplace:
    def test_run_usage_error_with_too_few_args(self):
        opts = Values({})

        with pytest.raises(ui.UserError) as excinfo:
            # Too few arguments: CLI requires at least a query and a replacement
            replace.run(None, opts, [])

        # Ensure we get a usage-style error message
        assert "Usage" in str(excinfo.value)

    def test_run_happy_path_smoke(self, monkeypatch, tmp_path):
        # Avoid any real filesystem operations
        monkeypatch.setattr(replace, "replace_file", lambda *args, **kwargs: None)

        # Minimal realistic library; we don't care about matches,
        # just that the main path executes without error.
        lib = Library(str(tmp_path / "test.db"))
        opts = Values({})

        # Two arguments as the CLI would provide: query and replacement
        replace.run(lib, opts, ["artist:foo", "bar"])

```
</issue_to_address>

### Comment 2
<location> `test/plugins/test_replace.py:125-126` </location>
<code_context>

         assert replace.confirm_replacement("test", song) is False
+
+    def test_replace_file(
+        self, mp3_file: Path, opus_file: Path, library: Library
+    ):
+        old_mediafile = MediaFile(mp3_file)
</code_context>

<issue_to_address>
**suggestion (testing):** Add a negative test for when `song.write()` fails to ensure the new error handling path is covered

Right now only the successful path of `replace_file` is exercised. Please add a test (e.g. `test_replace_file_write_error`) that monkeypatches `Item.write` to raise an `Exception("boom")`, calls `replace.replace_file(...)`, and asserts that a `ui.UserError` is raised with the expected error message. This will validate the new error-handling branch around `song.write()`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@willburden willburden marked this pull request as draft December 12, 2025 23:28
@codecov
Copy link

codecov bot commented Dec 12, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 68.06%. Comparing base (ffede9d) to head (6a14c2a).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #6215      +/-   ##
==========================================
+ Coverage   67.90%   68.06%   +0.16%     
==========================================
  Files         137      137              
  Lines       18689    18695       +6     
  Branches     3160     3160              
==========================================
+ Hits        12690    12724      +34     
+ Misses       5332     5302      -30     
- Partials      667      669       +2     
Files with missing lines Coverage Δ
beetsplug/replace.py 92.20% <100.00%> (+38.68%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@willburden willburden marked this pull request as ready for review December 13, 2025 00:06
@github-actions
Copy link

Thank you for the PR! The changelog has not been updated, so here is a friendly reminder to check if you need to add an entry.

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • In replace_file, both the unlink and write paths catch a bare Exception and wrap it in UserError, which makes debugging harder and may hide programming errors; consider catching more specific exceptions (e.g., OSError / mediafile.FileTypeError) or re-raising unexpected ones.
  • The current order in replace_file updates and stores song.path before attempting song.write(), so a write failure will leave the database pointing at the new file even though its tags were not updated; consider writing tags first and only updating/storing song.path once all file operations have succeeded to avoid inconsistent state.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `replace_file`, both the unlink and write paths catch a bare `Exception` and wrap it in `UserError`, which makes debugging harder and may hide programming errors; consider catching more specific exceptions (e.g., `OSError` / `mediafile.FileTypeError`) or re-raising unexpected ones.
- The current order in `replace_file` updates and stores `song.path` before attempting `song.write()`, so a write failure will leave the database pointing at the new file even though its tags were not updated; consider writing tags first and only updating/storing `song.path` once all file operations have succeeded to avoid inconsistent state.

## Individual Comments

### Comment 1
<location> `beetsplug/replace.py:122-128` </location>
<code_context>
             except Exception as e:
                 raise ui.UserError(f"Could not delete original file: {e}")

+        # Store the new path in the database.
         song.path = str(dest).encode()
         song.store()

+        # Write the metadata in the database to the song file's tags.
+        try:
+            song.write()
+        except Exception as e:
+            raise ui.UserError(f"Error writing metadata to file: {e}")
</code_context>

<issue_to_address>
**issue (bug_risk):** Consider writing tags before storing the new path to keep the DB and file state consistent if `song.write()` fails.

Because the DB is updated before `song.write()`, a failure in `song.write()` (wrapped as `UserError`) leaves the user with a failed operation but a DB that already points to the new path. That means the library can reference a moved file whose on-disk tags don’t match the DB. Updating tags before calling `song.store()`, or delaying the `song.path` update until after a successful `song.write()`, would avoid this inconsistent state.
</issue_to_address>

### Comment 2
<location> `test/plugins/test_replace.py:51-60` </location>
<code_context>
+    def test_run_replace(self, monkeypatch, mp3_file, opus_file, library):
</code_context>

<issue_to_address>
**suggestion (testing):** Strengthen `test_run_replace` by asserting interactions rather than only checking that it doesn’t crash.

As written, this test will pass as long as `run` doesn’t raise, even if it ignores its inputs or skips key steps. Since you’re already monkeypatching `file_check`, `replace_file`, and `confirm_replacement`, consider wrapping them in simple spies and asserting they’re called with the expected arguments (e.g., `file_check(opus_file)`, `replace_file(item)`, and that `select_song` is invoked). This will better verify that the new `run(lib, _opts, args)` signature is correctly integrated.

Suggested implementation:

```python
    def test_run_replace(self, monkeypatch, mp3_file, opus_file, library):
        def always(x):
            return lambda *args, **kwargs: x

        # Simple spies to capture interactions with the replace helper functions.
        file_check_calls = []
        replace_file_calls = []
        confirm_replacement_calls = []
        select_song_calls = []

        def file_check_spy(path, *args, **kwargs):
            file_check_calls.append((path, args, kwargs))
            return None

        def replace_file_spy(item, *args, **kwargs):
            replace_file_calls.append((item, args, kwargs))
            return None

        def confirm_replacement_spy(item, *args, **kwargs):
            confirm_replacement_calls.append((item, args, kwargs))
            return True

        original_select_song = replace.select_song

        def select_song_spy(lib, query, *args, **kwargs):
            select_song_calls.append((lib, query, args, kwargs))
            return original_select_song(lib, query, *args, **kwargs)

        monkeypatch.setattr(replace, "file_check", file_check_spy)
        monkeypatch.setattr(replace, "replace_file", replace_file_spy)
        monkeypatch.setattr(replace, "confirm_replacement", confirm_replacement_spy)
        monkeypatch.setattr(replace, "select_song", select_song_spy)

        mediafile = MediaFile(mp3_file)
        mediafile.title = "BBB"
        mediafile.save()

```

To fully implement the interaction-based assertions, adjust the body of `test_run_replace` *after* the call to `replace.run(...)` (which is not shown in the snippet) as follows:

1. Ensure `test_run_replace` actually calls `replace.run` with the library, options, and arguments corresponding to your new signature:
   ```python
   opts = optparse.Values()
   # set any options required by replace.run here, if applicable
   replace.run(library, opts, [mp3_file, opus_file])
   ```

2. After the `replace.run(...)` call, add assertions that verify the spies were invoked as expected. For example:
   ```python
   # file_check should be called at least once with the replacement file
   assert file_check_calls, "file_check was not called"
   assert any(call[0] == opus_file for call in file_check_calls)

   # replace_file should be called at least once
   assert replace_file_calls, "replace_file was not called"

   # confirm_replacement should be called at least once
   assert confirm_replacement_calls, "confirm_replacement was not called"

   # select_song should be called at least once with the library
   assert select_song_calls, "select_song was not called"
   assert any(call[0] is library for call in select_song_calls)
   ```

3. If `replace.run` is expected to call `replace_file` with a specific `Item` instance (e.g., the item corresponding to `mp3_file`), you can refine the assertion by comparing paths or IDs obtained from `library.items()` to the first element of `replace_file_calls`.

You may need to tailor the argument checks (`opus_file`, `library`, etc.) to match the exact behavior and types used in your `replace` plugin implementation.
</issue_to_address>

### Comment 3
<location> `test/plugins/test_replace.py:167-176` </location>
<code_context>
+        item = Item.from_path(mp3_file)
+        library.add(item)
+
+        replace.replace_file(opus_file, item)
+
+        # Check that the file has been replaced.
+        assert opus_file.exists()
+        assert not mp3_file.exists()
+
+        # Check that the database path has been updated.
+        assert item.path == bytes(opus_file)
+
+        # Check that the new file has the old file's metadata.
+        new_mediafile = MediaFile(opus_file)
+        assert new_mediafile.albumartist == old_mediafile.albumartist
+        assert new_mediafile.disctitle == old_mediafile.disctitle
+        assert new_mediafile.genre == old_mediafile.genre
</code_context>

<issue_to_address>
**suggestion (testing):** Add a test case for the error path where `song.write()` fails and a `ui.UserError` should be raised.

There’s no test exercising the new `try/except` around `song.write()`. Please add one that monkeypatches `Item.write` (or `song.write`) to raise an exception and asserts that `replace.replace_file` raises `ui.UserError` with the expected message, so the error handling is verified and protected against regressions.

Suggested implementation:

```python
        # Check that the new file has the old file's metadata.
        new_mediafile = MediaFile(opus_file)
        assert new_mediafile.albumartist == old_mediafile.albumartist
        assert new_mediafile.disctitle == old_mediafile.disctitle
        assert new_mediafile.genre == old_mediafile.genre


def test_replace_file_write_error(monkeypatch, library, mp3_file, opus_file):
    """If writing tags fails, replace_file should raise ui.UserError."""
    # Prepare an item in the library as in the success case.
    item = Item.from_path(mp3_file)
    library.add(item)

    # Force the write operation to fail.
    def fail_write(_self, *args, **kwargs):
        raise Exception("simulated write failure")

    # replace_file currently calls Item.write, so patch that.
    monkeypatch.setattr(Item, "write", fail_write, raising=True)

    # When the underlying write fails, replace_file should convert it into a UserError.
    with pytest.raises(ui.UserError) as excinfo:
        replace.replace_file(opus_file, item)

    message = str(excinfo.value)
    # Ensure the error message is helpful and mentions the write failure and target file.
    assert "write" in message.lower()
    assert str(opus_file) in message or str(bytes(opus_file)) in message

```

1. Ensure the following are available in this test module (they likely already are):
   - `import pytest`
   - `from beets import ui`
   - `from beets.library import Item`
   - `from beetsplug import replace` (or whatever the existing import is for the plugin under test).
2. Update the assertions on `message` to match the exact wording used in the `ui.UserError` raised inside `replace.replace_file` (for example, asserting that a specific phrase like `"could not write tags"` is present).
3. If `replace.replace_file` wraps a different write call (e.g., a `song.write` object returned from `item.get_file()`), change the `monkeypatch.setattr` target accordingly (for example, patch the appropriate class or function that provides `song.write` instead of `Item.write`).
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@willburden
Copy link
Author

willburden commented Dec 13, 2025

  • Refined the exception types being caught.
  • Improved test coverage for the run function itself, which should catch a regression of the initial bug.
  • Improved test coverage for replace.replace_file, covering IO error paths.
  • Sourcery suggested saving the path only after successfully writing metadata, but I think it makes more sense to update the path either way. Making that change would just mean that failing to write metadata also causes the path in the database to be wrong.

I'll add the changelog entry after review since it depends on the decision made.

@JOJ0
Copy link
Member

JOJ0 commented Dec 14, 2025

regarding also including write in the plugin, please research if you find something about it in the original implementation or in the docs. i don't know if that was intentional back then. HTH

#5644

@henry-oberholtzer henry-oberholtzer linked an issue Jan 4, 2026 that may be closed by this pull request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replace plugin does not work

2 participants