Skip to content

pathlib migration: migrate Library.directory to pathlib.Path - #6991

Open
snejus wants to merge 2 commits into
masterfrom
migrate-library-directory-path
Open

snejus wants to merge 2 commits into
masterfrom
migrate-library-directory-path

Conversation

@snejus

@snejus snejus commented Sep 5, 2026 •

Copy link
Copy Markdown
Member

Fixes #6967

Summary

  • Library.directory is now a pathlib.Path instead of bytes, and the constructor accepts any path-like value for it.
  • New Library.contains_path() and Library.is_in_directory() replace the lib.directory in util.ancestry(item.path) idiom, which was repeated across thirteen call sites in the importer, update, mbsync, bpsync, plugins.apply_item_changes and Item.try_sync.
  • permissions and unimported now work with Path throughout. Both of them matched the library root by string prefix before, so /music also matched /music2 and ignore_subdirectories: [pod] also skipped podcasts/ - both fixed here.
  • Code which still needs bytes converts at the boundary with os.fsencode() instead of threading the conversion through.

Tests

  • New TestLibraryDirectory covers Path, str and bytes directory arguments.
  • New test/plugins/test_unimported.py covering ignore_extensions, including compound ones like tar.gz.
  • test_permissions.py updated for the Path signatures.

Notes

  • There's no compatibility shim here, unlike in the art utils migration - plugins that join lib.directory with os.path helpers, or compare it against bytes, will break. Changelog entry is under "For plugin developers".
  • ignore_extensions matching became case-insensitive as a side effect, so jpg now covers cover.JPG too. Also in the changelog.
  • get_music_dir() still returns bytes, so that part of Make Library.directory and music-directory context Path-based #6967 isn't done. I'd rather leave it for the follow-up which migrates the dbcore path queries, since nothing else can consume a Path from it yet.

Copilot AI lite review requested due to automatic review settings September 5, 2026 01:34
@snejus
snejus requested a review from a team as a code owner September 5, 2026 01:34
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

grug see real behavior bugs in unimported ignore-subdirectory traversal and permissions directory selection that can touch wrong folders, plus small message/doc fixes.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

PR make Library.directory be pathlib.Path at runtime, and push “is this inside library?” logic into Library helper methods. This help reduce mixed bytes/str/path mess across importer, UI, models, and plugins.

Changes:

  • Change Library.directory type to Path, add contains_path() / is_in_directory() helpers, update callers to use them.
  • Update plugins and commands to use Path operations (is_dir(), stat(), chmod(), joins) and only os.fsencode(...) at legacy boundaries.
  • Update tests/helpers to pass Path library directories.
File summaries
File Description
test/test_library.py Switch test setup to Path(...) for lib.directory.
test/plugins/test_permissions.py Update permissions tests to use Path APIs and .filepath fields.
beetsplug/unimported.py Migrate ignore/path logic to Path and Path joins.
beetsplug/permissions.py Migrate permissions plugin to Path-based stat/chmod and path handling.
beetsplug/mbsync.py Replace ancestry check with lib.is_in_directory(...).
beetsplug/ipfs.py Construct temp library with directory=Path("/ipfs/").
beetsplug/bpsync.py Replace ancestry check with lib.is_in_directory(...).
beets/ui/commands/update.py Use lib.is_in_directory(...) and Path.is_dir() for directory check.
beets/ui/init.py Open library using config["directory"].as_path() and log Path values.
beets/test/helper.py Pass Path into Library(...) in test harness.
beets/plugins.py Replace ancestry check with lib.is_in_directory(...).
beets/library/models.py Encode Path directory to bytes at legacy destination/move boundaries.
beets/library/library.py Make directory: Path and add library path helper APIs.
beets/importer/tasks.py Use session.lib.contains_path(...) for in-library checks.
beets/context.py Accept PathLike for music-dir context, encode with os.fsencode.
Review details
  • Files reviewed: 15/15 changed files
  • Comments generated: 4
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread beetsplug/permissions.py Outdated
Comment thread beetsplug/unimported.py
Comment thread beets/library/library.py Outdated
Comment thread beets/ui/__init__.py Outdated
@codecov

codecov Bot commented Sep 5, 2026 •

Copy link
Copy Markdown

❌ 4 Tests Failed:

Tests completed Failed Passed Skipped
2908 4 2904 120
View the top 3 failed test(s) by shortest run time
test\test_library.py::test_library::TestPluginDestination::test_plugin_value_sanitized
Stack Traces | 0.012s run time
self = <test.test_library.TestPluginDestination object at 0x0000020B7BFA2DE0>
item = Item(encoder='', disctitle='', mb_albumid='someID-2', mtime=12345, bitrate_mode='', albumtype='', composers_ids=[], al..._gain=None, artist='the artist', discogs_albumid=0, lyricists=['the lyricist'], acoustid_id='', release_group_title='')

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mtest_plugin_value_sanitized#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m, item):#x1B[90m#x1B[39;49;00m
        #x1B[96mself#x1B[39;49;00m._tv_map = {#x1B[33m"#x1B[39;49;00m#x1B[33mfoo#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mbar/baz#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m}#x1B[90m#x1B[39;49;00m
>       #x1B[96mself#x1B[39;49;00m._assert_dest(#x1B[33mb#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33mthe artist bar_baz#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, item)#x1B[90m#x1B[39;49;00m

#x1B[1m#x1B[31mtest\test_library.py#x1B[0m:1289: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <test.test_library.TestPluginDestination object at 0x0000020B7BFA2DE0>
dest = b'the artist bar_baz'
item = Item(encoder='', disctitle='', mb_albumid='someID-2', mtime=12345, bitrate_mode='', albumtype='', composers_ids=[], al..._gain=None, artist='the artist', discogs_albumid=0, lyricists=['the lyricist'], acoustid_id='', release_group_title='')

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92m_assert_dest#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m, dest, item):#x1B[90m#x1B[39;49;00m
        #x1B[94mwith#x1B[39;49;00m _common.platform_posix():#x1B[90m#x1B[39;49;00m
            the_dest = item.destination()#x1B[90m#x1B[39;49;00m
>       #x1B[94massert#x1B[39;49;00m the_dest == #x1B[33mb#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33m/base/#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m + dest#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mE       AssertionError: assert b'D:\\a\\beet...rtist bar_baz' == b'/base/the artist bar_baz'#x1B[0m
#x1B[1m#x1B[31mE         #x1B[0m
#x1B[1m#x1B[31mE         At index 0 diff: #x1B[0m#x1B[33mb#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m#x1B[33mD#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m#x1B[90m#x1B[39;49;00m != #x1B[0m#x1B[33mb#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m#x1B[33m/#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m#x1B[90m#x1B[39;49;00m#x1B[0m
#x1B[1m#x1B[31mE         #x1B[0m
#x1B[1m#x1B[31mE         Full diff:#x1B[0m
#x1B[1m#x1B[31mE         #x1B[0m#x1B[91m- (b'/base/the artist bar_baz')#x1B[39;49;00m#x1B[90m#x1B[39;49;00m#x1B[0m
#x1B[1m#x1B[31mE         #x1B[92m+ (b'D:\\a\\beets\\beets/\\base/the artist bar_baz')#x1B[39;49;00m#x1B[90m#x1B[39;49;00m#x1B[0m

#x1B[1m#x1B[31mtest\test_library.py#x1B[0m:1274: AssertionError
test\test_library.py::test_library::TestPluginDestination::test_plugin_value_overrides_attribute
Stack Traces | 0.013s run time
self = <test.test_library.TestPluginDestination object at 0x0000020B7BFA2840>
item = Item(encoder='', disctitle='', mb_albumid='someID-2', mtime=12345, bitrate_mode='', albumtype='', composers_ids=[], al...g_track_gain=None, artist='bar', discogs_albumid=0, lyricists=['the lyricist'], acoustid_id='', release_group_title='')

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mtest_plugin_value_overrides_attribute#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m, item):#x1B[90m#x1B[39;49;00m
        #x1B[96mself#x1B[39;49;00m._tv_map = {#x1B[33m"#x1B[39;49;00m#x1B[33martist#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mbar#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m}#x1B[90m#x1B[39;49;00m
>       #x1B[96mself#x1B[39;49;00m._assert_dest(#x1B[33mb#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33mbar $foo#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, item)#x1B[90m#x1B[39;49;00m

#x1B[1m#x1B[31mtest\test_library.py#x1B[0m:1285: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <test.test_library.TestPluginDestination object at 0x0000020B7BFA2840>
dest = b'bar $foo'
item = Item(encoder='', disctitle='', mb_albumid='someID-2', mtime=12345, bitrate_mode='', albumtype='', composers_ids=[], al...g_track_gain=None, artist='bar', discogs_albumid=0, lyricists=['the lyricist'], acoustid_id='', release_group_title='')

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92m_assert_dest#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m, dest, item):#x1B[90m#x1B[39;49;00m
        #x1B[94mwith#x1B[39;49;00m _common.platform_posix():#x1B[90m#x1B[39;49;00m
            the_dest = item.destination()#x1B[90m#x1B[39;49;00m
>       #x1B[94massert#x1B[39;49;00m the_dest == #x1B[33mb#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33m/base/#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m + dest#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mE       AssertionError: assert b'D:\\a\\beet...base/bar $foo' == b'/base/bar $foo'#x1B[0m
#x1B[1m#x1B[31mE         #x1B[0m
#x1B[1m#x1B[31mE         At index 0 diff: #x1B[0m#x1B[33mb#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m#x1B[33mD#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m#x1B[90m#x1B[39;49;00m != #x1B[0m#x1B[33mb#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m#x1B[33m/#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m#x1B[90m#x1B[39;49;00m#x1B[0m
#x1B[1m#x1B[31mE         #x1B[0m
#x1B[1m#x1B[31mE         Full diff:#x1B[0m
#x1B[1m#x1B[31mE         #x1B[0m#x1B[91m- (b'/base/bar $foo')#x1B[39;49;00m#x1B[90m#x1B[39;49;00m#x1B[0m
#x1B[1m#x1B[31mE         #x1B[92m+ (b'D:\\a\\beets\\beets/\\base/bar $foo')#x1B[39;49;00m#x1B[90m#x1B[39;49;00m#x1B[0m

#x1B[1m#x1B[31mtest\test_library.py#x1B[0m:1274: AssertionError
test\test_library.py::test_library::TestPluginDestination::test_undefined_value_not_substituted
Stack Traces | 0.013s run time
self = <test.test_library.TestPluginDestination object at 0x0000020B7BFA1310>
item = Item(encoder='', disctitle='', mb_albumid='someID-2', mtime=12345, bitrate_mode='', albumtype='', composers_ids=[], al..._gain=None, artist='the artist', discogs_albumid=0, lyricists=['the lyricist'], acoustid_id='', release_group_title='')

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mtest_undefined_value_not_substituted#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m, item):#x1B[90m#x1B[39;49;00m
>       #x1B[96mself#x1B[39;49;00m._assert_dest(#x1B[33mb#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33mthe artist $foo#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, item)#x1B[90m#x1B[39;49;00m

#x1B[1m#x1B[31mtest\test_library.py#x1B[0m:1277: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <test.test_library.TestPluginDestination object at 0x0000020B7BFA1310>
dest = b'the artist $foo'
item = Item(encoder='', disctitle='', mb_albumid='someID-2', mtime=12345, bitrate_mode='', albumtype='', composers_ids=[], al..._gain=None, artist='the artist', discogs_albumid=0, lyricists=['the lyricist'], acoustid_id='', release_group_title='')

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92m_assert_dest#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m, dest, item):#x1B[90m#x1B[39;49;00m
        #x1B[94mwith#x1B[39;49;00m _common.platform_posix():#x1B[90m#x1B[39;49;00m
            the_dest = item.destination()#x1B[90m#x1B[39;49;00m
>       #x1B[94massert#x1B[39;49;00m the_dest == #x1B[33mb#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33m/base/#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m + dest#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mE       AssertionError: assert b'D:\\a\\beet...e artist $foo' == b'/base/the artist $foo'#x1B[0m
#x1B[1m#x1B[31mE         #x1B[0m
#x1B[1m#x1B[31mE         At index 0 diff: #x1B[0m#x1B[33mb#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m#x1B[33mD#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m#x1B[90m#x1B[39;49;00m != #x1B[0m#x1B[33mb#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m#x1B[33m/#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m#x1B[90m#x1B[39;49;00m#x1B[0m
#x1B[1m#x1B[31mE         #x1B[0m
#x1B[1m#x1B[31mE         Full diff:#x1B[0m
#x1B[1m#x1B[31mE         #x1B[0m#x1B[91m- (b'/base/the artist $foo')#x1B[39;49;00m#x1B[90m#x1B[39;49;00m#x1B[0m
#x1B[1m#x1B[31mE         #x1B[92m+ (b'D:\\a\\beets\\beets/\\base/the artist $foo')#x1B[39;49;00m#x1B[90m#x1B[39;49;00m#x1B[0m

#x1B[1m#x1B[31mtest\test_library.py#x1B[0m:1274: AssertionError
test\test_library.py::test_library::TestPluginDestination::test_plugin_value_not_substituted
Stack Traces | 0.04s run time
self = <test.test_library.TestPluginDestination object at 0x0000020B7BFA0260>
item = Item(encoder='', disctitle='', mb_albumid='someID-2', mtime=12345, bitrate_mode='', albumtype='', composers_ids=[], al..._gain=None, artist='the artist', discogs_albumid=0, lyricists=['the lyricist'], acoustid_id='', release_group_title='')

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mtest_plugin_value_not_substituted#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m, item):#x1B[90m#x1B[39;49;00m
        #x1B[96mself#x1B[39;49;00m._tv_map = {#x1B[33m"#x1B[39;49;00m#x1B[33mfoo#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mbar#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m}#x1B[90m#x1B[39;49;00m
>       #x1B[96mself#x1B[39;49;00m._assert_dest(#x1B[33mb#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33mthe artist bar#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, item)#x1B[90m#x1B[39;49;00m

#x1B[1m#x1B[31mtest\test_library.py#x1B[0m:1281: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <test.test_library.TestPluginDestination object at 0x0000020B7BFA0260>
dest = b'the artist bar'
item = Item(encoder='', disctitle='', mb_albumid='someID-2', mtime=12345, bitrate_mode='', albumtype='', composers_ids=[], al..._gain=None, artist='the artist', discogs_albumid=0, lyricists=['the lyricist'], acoustid_id='', release_group_title='')

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92m_assert_dest#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m, dest, item):#x1B[90m#x1B[39;49;00m
        #x1B[94mwith#x1B[39;49;00m _common.platform_posix():#x1B[90m#x1B[39;49;00m
            the_dest = item.destination()#x1B[90m#x1B[39;49;00m
>       #x1B[94massert#x1B[39;49;00m the_dest == #x1B[33mb#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33m/base/#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m + dest#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mE       AssertionError: assert b'D:\\a\\beet...he artist bar' == b'/base/the artist bar'#x1B[0m
#x1B[1m#x1B[31mE         #x1B[0m
#x1B[1m#x1B[31mE         At index 0 diff: #x1B[0m#x1B[33mb#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m#x1B[33mD#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m#x1B[90m#x1B[39;49;00m != #x1B[0m#x1B[33mb#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m#x1B[33m/#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m#x1B[90m#x1B[39;49;00m#x1B[0m
#x1B[1m#x1B[31mE         #x1B[0m
#x1B[1m#x1B[31mE         Full diff:#x1B[0m
#x1B[1m#x1B[31mE         #x1B[0m#x1B[91m- (b'/base/the artist bar')#x1B[39;49;00m#x1B[90m#x1B[39;49;00m#x1B[0m
#x1B[1m#x1B[31mE         #x1B[92m+ (b'D:\\a\\beets\\beets/\\base/the artist bar')#x1B[39;49;00m#x1B[90m#x1B[39;49;00m#x1B[0m

#x1B[1m#x1B[31mtest\test_library.py#x1B[0m:1274: AssertionError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@snejus
snejus force-pushed the migrate-library-directory-path branch 2 times, most recently from a6166c4 to 8e5b774 Compare September 5, 2026 20:03
@snejus
snejus force-pushed the migrate-art-to-path branch 3 times, most recently from 12f4622 to 011800c Compare September 28, 2026 01:09
@snejus
snejus force-pushed the migrate-art-to-path branch 4 times, most recently from 5731142 to ad3ab58 Compare September 29, 2026 05:40
@snejus
snejus force-pushed the migrate-art-to-path branch from ad3ab58 to 670dde8 Compare October 9, 2026 00:34
@snejus
snejus force-pushed the migrate-library-directory-path branch from 8e5b774 to 7498ef1 Compare October 9, 2026 00:34
@snejus
snejus force-pushed the migrate-art-to-path branch from 670dde8 to 78f8964 Compare October 9, 2026 00:44
Base automatically changed from migrate-art-to-path to master October 9, 2026 00:50
@snejus
snejus force-pushed the migrate-library-directory-path branch 2 times, most recently from 9799eb0 to 05306d0 Compare October 9, 2026 01:38
@snejus

snejus commented Oct 9, 2026 •

Copy link
Copy Markdown
Member Author

Dear maintainers,

I just realised that this PR would break your plugins/scripts. There is no easy to way to make Library.directory str -> Path migration backwards compatible and I'm not really keen to release a major version for this.

Would you be able to update your plugins/scripts to handle Path type? You'd just need to wrap Library.directory within os.fsencode.

Thank you!

@snejus
snejus force-pushed the migrate-library-directory-path branch 2 times, most recently from eedea2e to 08aa064 Compare October 10, 2026 03:19
@snejus
snejus force-pushed the migrate-library-directory-path branch from 08aa064 to 86279b7 Compare October 10, 2026 06:08

This branch has not been deployed

No deployments
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.

Make Library.directory and music-directory context Path-based

2 participants