Skip to content

Conversation

@snejus
Copy link
Member

@snejus snejus commented Feb 1, 2026

PR Summary: Typed MusicBrainz payloads + parsing refactor + factory-based tests

Why this change exists

The MusicBrainz integration was historically treated as loosely-typed JSON, which made it easy to:

  • accidentally depend on fields that are sometimes missing/renamed
  • leak dashed MB keys ('release-group', 'artist-credit') into downstream code
  • build brittle tests with hand-written dicts that drift from the real API shape

This PR makes MusicBrainz payloads first-class typed models (via TypedDict), normalizes API payload shape consistently, and refactors parsing into smaller, reusable units. Tests are updated to use factory_boy factories to generate payloads that match the typed contract.


High-level architecture changes

1) Introduce a typed domain model for MusicBrainz payloads

beetsplug/_utils/musicbrainz.py now defines a comprehensive set of TypedDict models: Release, Recording, Medium, Track, ArtistCredit, etc.

Impact

  • MusicBrainzAPI.get_release() / get_recording() return typed Release/Recording instead of untyped JSONDict.
  • Call sites (notably beetsplug/musicbrainz.py and beetsplug/mbpseudo.py) are updated to accept and operate on these typed payloads.

This turns the MB payload into a stable internal contract and lets mypy enforce correctness in parsing code.


2) Normalize MusicBrainz API responses at the boundary

The API wrapper normalizes two key aspects:

  1. Key naming: dashes → underscores (e.g. text-representationtext_representation)
  2. Relationship fields: collapses generic 'relations' into typed buckets like artist_relations, url_relations, work_relations

Conceptually:

MusicBrainz API JSON
  ↓ `MusicBrainzAPI._normalize_data()`
Normalized payload (underscored keys + grouped relations)
  ↓ used by parsing layer
`MusicBrainzPlugin.album_info()` / `track_info()`

Impact

  • Parsing code can assume consistent underscore keys and consistent relation containers.
  • mbpseudo interception logic is simplified because it no longer needs to handle dashed keys.

3) Parsing refactor: smaller helpers, clearer responsibilities

beetsplug/musicbrainz.py moves from one large album_info() routine with repeated inline logic to a more structured approach:

  • MusicBrainzPlugin._parse_artist_credits(...) centralizes "artist-credit flattening" into a single helper that outputs both:

    • single-string tags (artist, artist_credit, artist_sort)
    • list forms (artists, artists_credit, artists_sort, plus artist_id/artists_ids)
  • _parse_release_group(...), _parse_label_infos(...), _parse_external_ids(...), _parse_genre(...) each encapsulate a single extraction responsibility and return dict fragments that are merged into AlbumInfo.

  • Medium/track parsing is split out into get_tracks_from_medium(...), and config access is cached (ignored_media, ignore_data_tracks, ignore_video_tracks).

Before (simplified)

  • album_info() handled:
    • ignored media logic
    • data tracks logic
    • video track skipping
    • track overrides (track vs recording)
    • medium metadata shaping
    • track indexing
    • computing info.media

After

  • album_info() orchestrates:
    • filter valid media
    • track_infos.extend(get_tracks_from_medium(medium))
    • assign TrackInfo.index in a single pass
    • compute album-level media from track infos (single value vs 'Media')

This is effectively a "pipeline": ReleaseMediumTrackInfo + album metadata.


4) Tests re-architected around factories (stable data-shape contract)

A new test factory module is introduced: test/plugins/factories/musicbrainz.py using factory_boy.

Key changes:

  • Tests stop manually building giant dicts and instead use RecordingFactory, TrackFactory, MediumFactory, ReleaseFactory, etc.
  • ReleaseFactory is introduced and then made the default basis for test releases; it provides realistic defaults (release events, label info, text representation, ids, etc.).
  • Track positions are now guaranteed via a @factory.post_generation hook that sets track['position'] sequentially.

Impact

  • Tests are more declarative and closer to the real API shape.
  • The typed contract is continuously validated by construction: when code expects Release/Medium fields, factories provide them.

Reviewer guide: what to focus on

  1. Public/semantic behavior

    • Parsing behavior should remain consistent, but now relies on normalized/typed fields and extracted helpers.
    • AlbumInfo.media is now computed from track infos, not directly from release['media'] (still yields the same 'Media' vs single-format behavior, but via TrackInfo.media).
  2. Boundary normalization correctness

    • MusicBrainzAPI._normalize_data() is the foundation: if it regresses, everything downstream breaks in subtle ways.
  3. Medium/track parsing extraction

    • get_tracks_from_medium() now owns pregap/data track inclusion and filtering. Ensure the config semantics match previous behavior.
  4. Test changes are mostly structural

    • Assertions changed because factory defaults are different (e.g., 'Album' vs 'ALBUM TITLE', deterministic UUID-like ids).
    • The new assert album additions fix typing concerns when album_for_id() could return None.

High-level impact

  • Stronger correctness: typed MB payloads + strict mypy in beetsplug.musicbrainz, beetsplug.mbpseudo, and beetsplug._utils.
  • Reduced parsing complexity: parsing is decomposed into reusable helpers and a MediumTrackInfo pipeline.
  • More maintainable tests: factories encode the payload schema; tests express intent by overriding only what matters.
  • New dev dependencies: pytest-factoryboy (and transitive factory_boy, faker, inflection) added for test data generation.

@snejus snejus requested a review from asardaes as a code owner February 1, 2026 16:15
@snejus snejus requested review from JOJ0 and Copilot February 1, 2026 16:15
@snejus snejus requested a review from a team as a code owner February 1, 2026 16:15
@github-actions
Copy link

github-actions bot commented Feb 1, 2026

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

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces comprehensive typing for MusicBrainz API payloads through TypedDict models, refactors parsing logic into smaller helpers, normalizes API responses at the boundary (converting dashes to underscores and grouping relations), and replaces hand-written test dictionaries with factory-based data generation.

Changes:

  • Introduced typed domain models (Release, Recording, Medium, etc.) in beetsplug/_utils/musicbrainz.py
  • Normalized MusicBrainz API responses by converting dashed keys to underscores and grouping relations
  • Refactored parsing into focused helper methods (_parse_artist_credits, _parse_release_group, etc.)

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test/rsrc/mbpseudo/pseudo_release.json Updated test fixture to use underscored keys matching normalized payload format
test/rsrc/mbpseudo/official_release.json Updated test fixture to use underscored keys matching normalized payload format
test/plugins/utils/test_musicbrainz.py Renamed test from test_group_relations to test_normalize_data reflecting the renamed internal method
test/plugins/test_musicbrainz.py Replaced manual test data construction with factory-based approach and updated assertions to match factory defaults
test/plugins/test_mbpseudo.py Updated references from dashed keys to underscored keys
test/plugins/factories/musicbrainz.py Added factory classes for generating typed MusicBrainz test data
setup.cfg Enabled strict mypy checking for musicbrainz-related modules
pyproject.toml Added pytest-factoryboy test dependency
beetsplug/musicbrainz.py Refactored parsing logic into typed helper methods and updated to consume normalized payloads
beetsplug/mbpseudo.py Updated to use typed Release structures and underscored keys
beetsplug/_utils/musicbrainz.py Added comprehensive TypedDict models and renamed/enhanced _group_relations to _normalize_data
Comments suppressed due to low confidence (2)

beetsplug/_utils/musicbrainz.py:1

  • The length checks are redundant. If map(int, date_str.split('-')) produces fewer than three parts, accessing parts[1] or parts[2] will raise an IndexError rather than returning None. The current implementation doesn't actually prevent the error. Consider using parts[i] if i < len(parts) else None or a more concise approach like padding the list.
"""Helpers for communicating with the MusicBrainz webservice.

beetsplug/musicbrainz.py:1

  • Corrected spelling of 'attribute_credits' to 'attribute_values' to match the actual field name in ArtistRelation TypedDict.
# This file is part of beets.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

"Recording name": "5d564c8f-97de-3572-94bb-7f40ad661499",
"Release group name": "156e24ca-8746-3cfc-99ae-0a867c765c67",
"Release name": "df187855-059b-3514-9d5e-d240de0b4228",
"Search hint": "abc2db8a-7386-354d-82f4-252c0213cbe4",
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

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

Corrected 'Search hint' type_id which appears to be a typo (should match the type_id pattern used elsewhere, likely '829662f2-a781-3ec8-8b46-fbcea6196f81' based on the JSON fixtures).

Suggested change
"Search hint": "abc2db8a-7386-354d-82f4-252c0213cbe4",
"Search hint": "829662f2-a781-3ec8-8b46-fbcea6196f81",

Copilot uses AI. Check for mistakes.
if not date_str:
return None, None, None

parts = list(map(int, date_str.split("-")))
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

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

The function _get_date attempts to convert all date parts to integers without error handling. If the date string contains non-numeric values (e.g., malformed dates from the API), this will raise a ValueError. Consider wrapping the conversion in a try-except block or validating the input first.

Suggested change
parts = list(map(int, date_str.split("-")))
raw_parts = date_str.split("-")
parts: list[int | None] = []
for raw in raw_parts[:3]:
try:
parts.append(int(raw))
except ValueError:
parts.append(None)

Copilot uses AI. Check for mistakes.
@codecov
Copy link

codecov bot commented Feb 1, 2026

❌ 8 Tests Failed:

Tests completed Failed Passed Skipped
2216 8 2208 139
View the top 3 failed test(s) by shortest run time
test\plugins\test_mpdstats.py::plugins::test_mpdstats::MPDStatsTest::test_get_item
Stack Traces | 0.02s run time
self = <test.plugins.test_mpdstats.MPDStatsTest testMethod=test_get_item>

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mtest_get_item#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m):#x1B[90m#x1B[39;49;00m
        item_path = util.normpath(#x1B[33m"#x1B[39;49;00m#x1B[33m/foo/bar.flac#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m)#x1B[90m#x1B[39;49;00m
        item = Item(title=#x1B[33m"#x1B[39;49;00m#x1B[33mtitle#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, path=item_path, #x1B[96mid#x1B[39;49;00m=#x1B[94m1#x1B[39;49;00m)#x1B[90m#x1B[39;49;00m
        item.add(#x1B[96mself#x1B[39;49;00m.lib)#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        log = Mock()#x1B[90m#x1B[39;49;00m
>       mpdstats = MPDStats(#x1B[96mself#x1B[39;49;00m.lib, log)#x1B[90m#x1B[39;49;00m
                   ^^^^^^^^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m

#x1B[1m#x1B[31mtest\plugins\test_mpdstats.py#x1B[0m:44: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
#x1B[1m#x1B[31mbeetsplug\mpdstats.py#x1B[0m:146: in __init__
    #x1B[0m#x1B[96mself#x1B[39;49;00m.do_rating = mpd_config[#x1B[33m"#x1B[39;49;00m#x1B[33mrating#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m].get(#x1B[96mbool#x1B[39;49;00m)#x1B[90m#x1B[39;49;00m
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mC:\Users\runneradmin\AppData\Local\pypoetry\Cache\virtualenvs\beets-9oFyfY5n-py3.11\Lib\site-packages\confuse\core.py#x1B[0m:303: in get
    #x1B[0m#x1B[94mreturn#x1B[39;49;00m templates.as_template(template).value(#x1B[96mself#x1B[39;49;00m, template)#x1B[90m#x1B[39;49;00m
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mC:\Users\runneradmin\AppData\Local\pypoetry\Cache\virtualenvs\beets-9oFyfY5n-py3.11\Lib\site-packages\confuse\templates.py#x1B[0m:53: in value
    #x1B[0m#x1B[94mreturn#x1B[39;49;00m #x1B[96mself#x1B[39;49;00m.get_default_value(view.name)#x1B[90m#x1B[39;49;00m
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = TypeTemplate(), key_name = 'mpd.rating'

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mget_default_value#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m, key_name=#x1B[33m'#x1B[39;49;00m#x1B[33mdefault#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m):#x1B[90m#x1B[39;49;00m
    #x1B[90m    #x1B[39;49;00m#x1B[33m"""Get the default value to return when the value is missing.#x1B[39;49;00m
    #x1B[33m#x1B[39;49;00m
    #x1B[33m    May raise a `NotFoundError` if the value is required.#x1B[39;49;00m
    #x1B[33m    """#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        #x1B[94mif#x1B[39;49;00m #x1B[95mnot#x1B[39;49;00m #x1B[96mhasattr#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m, #x1B[33m'#x1B[39;49;00m#x1B[33mdefault#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m) #x1B[95mor#x1B[39;49;00m #x1B[96mself#x1B[39;49;00m.default #x1B[95mis#x1B[39;49;00m REQUIRED:#x1B[90m#x1B[39;49;00m
            #x1B[90m# The value is required. A missing value is an error.#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
>           #x1B[94mraise#x1B[39;49;00m exceptions.NotFoundError(#x1B[33m"#x1B[39;49;00m#x1B[33m{}#x1B[39;49;00m#x1B[33m not found#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m.format(key_name))#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mE           confuse.exceptions.NotFoundError: mpd.rating not found#x1B[0m

#x1B[1m#x1B[31mC:\Users\runneradmin\AppData\Local\pypoetry\Cache\virtualenvs\beets-9oFyfY5n-py3.11\Lib\site-packages\confuse\templates.py#x1B[0m:62: NotFoundError
test\plugins\test_mbcollection.py::plugins::test_mbcollection::TestMbCollectionPlugin::test_get_collection_validation[invalid ID]
Stack Traces | 0.021s run time
self = <test.plugins.test_mbcollection.TestMbCollectionPlugin object at 0x00000202E1F1A450>
requests_mock = <requests_mock.mocker.Mocker object at 0x0000020284175F90>
user_collections = [{'entity-type': 'release', 'id': 'c1'}]
expectation = RaisesExc(UserError, match='invalid collection ID')

    #x1B[0m#x1B[37m@pytest#x1B[39;49;00m.mark.parametrize(#x1B[90m#x1B[39;49;00m
        #x1B[33m"#x1B[39;49;00m#x1B[33muser_collections,expectation#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
        [#x1B[90m#x1B[39;49;00m
            (#x1B[90m#x1B[39;49;00m
                [],#x1B[90m#x1B[39;49;00m
                pytest.raises(#x1B[90m#x1B[39;49;00m
                    UserError, match=#x1B[33mr#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33mno collections exist for user#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                ),#x1B[90m#x1B[39;49;00m
            ),#x1B[90m#x1B[39;49;00m
            (#x1B[90m#x1B[39;49;00m
                [{#x1B[33m"#x1B[39;49;00m#x1B[33mid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mc1#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33mentity-type#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mevent#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m}],#x1B[90m#x1B[39;49;00m
                pytest.raises(UserError, match=#x1B[33mr#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33mNo release collection found.#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m),#x1B[90m#x1B[39;49;00m
            ),#x1B[90m#x1B[39;49;00m
            (#x1B[90m#x1B[39;49;00m
                [{#x1B[33m"#x1B[39;49;00m#x1B[33mid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mc1#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33mentity-type#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mrelease#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m}],#x1B[90m#x1B[39;49;00m
                pytest.raises(UserError, match=#x1B[33mr#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33minvalid collection ID#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m),#x1B[90m#x1B[39;49;00m
            ),#x1B[90m#x1B[39;49;00m
            (#x1B[90m#x1B[39;49;00m
                [{#x1B[33m"#x1B[39;49;00m#x1B[33mid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: COLLECTION_ID, #x1B[33m"#x1B[39;49;00m#x1B[33mentity-type#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mrelease#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m}],#x1B[90m#x1B[39;49;00m
                does_not_raise(),#x1B[90m#x1B[39;49;00m
            ),#x1B[90m#x1B[39;49;00m
        ],#x1B[90m#x1B[39;49;00m
        ids=[#x1B[33m"#x1B[39;49;00m#x1B[33mno collections#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33mno release collections#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33minvalid ID#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33mvalid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m],#x1B[90m#x1B[39;49;00m
    )#x1B[90m#x1B[39;49;00m
    #x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mtest_get_collection_validation#x1B[39;49;00m(#x1B[90m#x1B[39;49;00m
        #x1B[96mself#x1B[39;49;00m, requests_mock, user_collections, expectation#x1B[90m#x1B[39;49;00m
    ):#x1B[90m#x1B[39;49;00m
        requests_mock.get(#x1B[90m#x1B[39;49;00m
            #x1B[33m"#x1B[39;49;00m#x1B[.../ws/2/collection#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, json={#x1B[33m"#x1B[39;49;00m#x1B[33mcollections#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: user_collections}#x1B[90m#x1B[39;49;00m
        )#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        #x1B[94mwith#x1B[39;49;00m expectation:#x1B[90m#x1B[39;49;00m
>           mbcollection.MusicBrainzCollectionPlugin().collection#x1B[90m#x1B[39;49;00m

#x1B[1m#x1B[31mtest\plugins\test_mbcollection.py#x1B[0m:66: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
#x1B[1m#x1B[31mC:\hostedtoolcache\windows\Python\3.11.9\x64\Lib\functools.py#x1B[0m:1001: in __get__
    #x1B[0mval = #x1B[96mself#x1B[39;49;00m.func(instance)#x1B[90m#x1B[39;49;00m
          ^^^^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mbeetsplug\mbcollection.py#x1B[0m:182: in collection
    #x1B[0mcollection_by_id := {#x1B[90m#x1B[39;49;00m
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

.0 = <list_iterator object at 0x00000202FBC46110>

    #x1B[0m    collection_by_id := {#x1B[90m#x1B[39;49;00m
>           c[#x1B[33m"#x1B[39;49;00m#x1B[33mid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m]: c #x1B[94mfor#x1B[39;49;00m c #x1B[95min#x1B[39;49;00m collections #x1B[94mif#x1B[39;49;00m c[#x1B[33m"#x1B[39;49;00m#x1B[33mentity-type#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m] == #x1B[33m"#x1B[39;49;00m#x1B[33mrelease#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                                               ^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
        }#x1B[90m#x1B[39;49;00m
    ):#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mE   KeyError: 'entity-type'#x1B[0m

#x1B[1m#x1B[31mbeetsplug\mbcollection.py#x1B[0m:183: KeyError
test\plugins\test_mbcollection.py::plugins::test_mbcollection::TestMbCollectionPlugin::test_get_collection_validation[valid]
Stack Traces | 0.023s run time
self = <test.plugins.test_mbcollection.TestMbCollectionPlugin object at 0x00000202E1F187D0>
requests_mock = <requests_mock.mocker.Mocker object at 0x00000202FF062350>
user_collections = [{'entity-type': 'release', 'id': 'f532e8a9-c955-44cd-bdc0-1f1bc7ef7505'}]
expectation = <contextlib.nullcontext object at 0x00000202E1F2B7D0>

    #x1B[0m#x1B[37m@pytest#x1B[39;49;00m.mark.parametrize(#x1B[90m#x1B[39;49;00m
        #x1B[33m"#x1B[39;49;00m#x1B[33muser_collections,expectation#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
        [#x1B[90m#x1B[39;49;00m
            (#x1B[90m#x1B[39;49;00m
                [],#x1B[90m#x1B[39;49;00m
                pytest.raises(#x1B[90m#x1B[39;49;00m
                    UserError, match=#x1B[33mr#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33mno collections exist for user#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                ),#x1B[90m#x1B[39;49;00m
            ),#x1B[90m#x1B[39;49;00m
            (#x1B[90m#x1B[39;49;00m
                [{#x1B[33m"#x1B[39;49;00m#x1B[33mid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mc1#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33mentity-type#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mevent#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m}],#x1B[90m#x1B[39;49;00m
                pytest.raises(UserError, match=#x1B[33mr#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33mNo release collection found.#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m),#x1B[90m#x1B[39;49;00m
            ),#x1B[90m#x1B[39;49;00m
            (#x1B[90m#x1B[39;49;00m
                [{#x1B[33m"#x1B[39;49;00m#x1B[33mid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mc1#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33mentity-type#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mrelease#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m}],#x1B[90m#x1B[39;49;00m
                pytest.raises(UserError, match=#x1B[33mr#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33minvalid collection ID#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m),#x1B[90m#x1B[39;49;00m
            ),#x1B[90m#x1B[39;49;00m
            (#x1B[90m#x1B[39;49;00m
                [{#x1B[33m"#x1B[39;49;00m#x1B[33mid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: COLLECTION_ID, #x1B[33m"#x1B[39;49;00m#x1B[33mentity-type#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mrelease#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m}],#x1B[90m#x1B[39;49;00m
                does_not_raise(),#x1B[90m#x1B[39;49;00m
            ),#x1B[90m#x1B[39;49;00m
        ],#x1B[90m#x1B[39;49;00m
        ids=[#x1B[33m"#x1B[39;49;00m#x1B[33mno collections#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33mno release collections#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33minvalid ID#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33mvalid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m],#x1B[90m#x1B[39;49;00m
    )#x1B[90m#x1B[39;49;00m
    #x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mtest_get_collection_validation#x1B[39;49;00m(#x1B[90m#x1B[39;49;00m
        #x1B[96mself#x1B[39;49;00m, requests_mock, user_collections, expectation#x1B[90m#x1B[39;49;00m
    ):#x1B[90m#x1B[39;49;00m
        requests_mock.get(#x1B[90m#x1B[39;49;00m
            #x1B[33m"#x1B[39;49;00m#x1B[.../ws/2/collection#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, json={#x1B[33m"#x1B[39;49;00m#x1B[33mcollections#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: user_collections}#x1B[90m#x1B[39;49;00m
        )#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        #x1B[94mwith#x1B[39;49;00m expectation:#x1B[90m#x1B[39;49;00m
>           mbcollection.MusicBrainzCollectionPlugin().collection#x1B[90m#x1B[39;49;00m

#x1B[1m#x1B[31mtest\plugins\test_mbcollection.py#x1B[0m:66: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
#x1B[1m#x1B[31mC:\hostedtoolcache\windows\Python\3.11.9\x64\Lib\functools.py#x1B[0m:1001: in __get__
    #x1B[0mval = #x1B[96mself#x1B[39;49;00m.func(instance)#x1B[90m#x1B[39;49;00m
          ^^^^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mbeetsplug\mbcollection.py#x1B[0m:182: in collection
    #x1B[0mcollection_by_id := {#x1B[90m#x1B[39;49;00m
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

.0 = <list_iterator object at 0x00000202E1456860>

    #x1B[0m    collection_by_id := {#x1B[90m#x1B[39;49;00m
>           c[#x1B[33m"#x1B[39;49;00m#x1B[33mid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m]: c #x1B[94mfor#x1B[39;49;00m c #x1B[95min#x1B[39;49;00m collections #x1B[94mif#x1B[39;49;00m c[#x1B[33m"#x1B[39;49;00m#x1B[33mentity-type#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m] == #x1B[33m"#x1B[39;49;00m#x1B[33mrelease#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                                               ^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
        }#x1B[90m#x1B[39;49;00m
    ):#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mE   KeyError: 'entity-type'#x1B[0m

#x1B[1m#x1B[31mbeetsplug\mbcollection.py#x1B[0m:183: KeyError
test\plugins\test_mbcollection.py::plugins::test_mbcollection::TestMbCollectionPlugin::test_get_collection_validation[no release collections]
Stack Traces | 0.024s run time
self = <test.plugins.test_mbcollection.TestMbCollectionPlugin object at 0x00000202E1F18E50>
requests_mock = <requests_mock.mocker.Mocker object at 0x00000202FF0CD710>
user_collections = [{'entity-type': 'event', 'id': 'c1'}]
expectation = RaisesExc(UserError, match='No release collection found.')

    #x1B[0m#x1B[37m@pytest#x1B[39;49;00m.mark.parametrize(#x1B[90m#x1B[39;49;00m
        #x1B[33m"#x1B[39;49;00m#x1B[33muser_collections,expectation#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
        [#x1B[90m#x1B[39;49;00m
            (#x1B[90m#x1B[39;49;00m
                [],#x1B[90m#x1B[39;49;00m
                pytest.raises(#x1B[90m#x1B[39;49;00m
                    UserError, match=#x1B[33mr#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33mno collections exist for user#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                ),#x1B[90m#x1B[39;49;00m
            ),#x1B[90m#x1B[39;49;00m
            (#x1B[90m#x1B[39;49;00m
                [{#x1B[33m"#x1B[39;49;00m#x1B[33mid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mc1#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33mentity-type#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mevent#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m}],#x1B[90m#x1B[39;49;00m
                pytest.raises(UserError, match=#x1B[33mr#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33mNo release collection found.#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m),#x1B[90m#x1B[39;49;00m
            ),#x1B[90m#x1B[39;49;00m
            (#x1B[90m#x1B[39;49;00m
                [{#x1B[33m"#x1B[39;49;00m#x1B[33mid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mc1#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33mentity-type#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mrelease#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m}],#x1B[90m#x1B[39;49;00m
                pytest.raises(UserError, match=#x1B[33mr#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33minvalid collection ID#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m),#x1B[90m#x1B[39;49;00m
            ),#x1B[90m#x1B[39;49;00m
            (#x1B[90m#x1B[39;49;00m
                [{#x1B[33m"#x1B[39;49;00m#x1B[33mid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: COLLECTION_ID, #x1B[33m"#x1B[39;49;00m#x1B[33mentity-type#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mrelease#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m}],#x1B[90m#x1B[39;49;00m
                does_not_raise(),#x1B[90m#x1B[39;49;00m
            ),#x1B[90m#x1B[39;49;00m
        ],#x1B[90m#x1B[39;49;00m
        ids=[#x1B[33m"#x1B[39;49;00m#x1B[33mno collections#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33mno release collections#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33minvalid ID#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33mvalid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m],#x1B[90m#x1B[39;49;00m
    )#x1B[90m#x1B[39;49;00m
    #x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mtest_get_collection_validation#x1B[39;49;00m(#x1B[90m#x1B[39;49;00m
        #x1B[96mself#x1B[39;49;00m, requests_mock, user_collections, expectation#x1B[90m#x1B[39;49;00m
    ):#x1B[90m#x1B[39;49;00m
        requests_mock.get(#x1B[90m#x1B[39;49;00m
            #x1B[33m"#x1B[39;49;00m#x1B[.../ws/2/collection#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, json={#x1B[33m"#x1B[39;49;00m#x1B[33mcollections#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: user_collections}#x1B[90m#x1B[39;49;00m
        )#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        #x1B[94mwith#x1B[39;49;00m expectation:#x1B[90m#x1B[39;49;00m
>           mbcollection.MusicBrainzCollectionPlugin().collection#x1B[90m#x1B[39;49;00m

#x1B[1m#x1B[31mtest\plugins\test_mbcollection.py#x1B[0m:66: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
#x1B[1m#x1B[31mC:\hostedtoolcache\windows\Python\3.11.9\x64\Lib\functools.py#x1B[0m:1001: in __get__
    #x1B[0mval = #x1B[96mself#x1B[39;49;00m.func(instance)#x1B[90m#x1B[39;49;00m
          ^^^^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mbeetsplug\mbcollection.py#x1B[0m:182: in collection
    #x1B[0mcollection_by_id := {#x1B[90m#x1B[39;49;00m
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

.0 = <list_iterator object at 0x00000202FBCC2F20>

    #x1B[0m    collection_by_id := {#x1B[90m#x1B[39;49;00m
>           c[#x1B[33m"#x1B[39;49;00m#x1B[33mid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m]: c #x1B[94mfor#x1B[39;49;00m c #x1B[95min#x1B[39;49;00m collections #x1B[94mif#x1B[39;49;00m c[#x1B[33m"#x1B[39;49;00m#x1B[33mentity-type#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m] == #x1B[33m"#x1B[39;49;00m#x1B[33mrelease#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                                               ^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
        }#x1B[90m#x1B[39;49;00m
    ):#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mE   KeyError: 'entity-type'#x1B[0m

#x1B[1m#x1B[31mbeetsplug\mbcollection.py#x1B[0m:183: KeyError
test\plugins\test_mbcollection.py::plugins::test_mbcollection::TestMbCollectionPlugin::test_mbupdate
Stack Traces | 0.027s run time
self = <test.plugins.test_mbcollection.TestMbCollectionPlugin object at 0x00000202E1F19D90>
helper = <test.plugins.test_mbcollection.TestMbCollectionPlugin object at 0x00000202E1F19D90>
requests_mock = <requests_mock.mocker.Mocker object at 0x00000202FF079790>
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x00000202FF15AB90>

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mtest_mbupdate#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m, helper, requests_mock, monkeypatch):#x1B[90m#x1B[39;49;00m
    #x1B[90m    #x1B[39;49;00m#x1B[33m"""Verify mbupdate sync of a MusicBrainz collection with the library.#x1B[39;49;00m
    #x1B[33m#x1B[39;49;00m
    #x1B[33m    This test ensures that the command:#x1B[39;49;00m
    #x1B[33m    - fetches collection releases using paginated requests,#x1B[39;49;00m
    #x1B[33m    - submits releases that exist locally but are missing from the remote#x1B[39;49;00m
    #x1B[33m      collection#x1B[39;49;00m
    #x1B[33m    - and removes releases from the remote collection that are not in the#x1B[39;49;00m
    #x1B[33m      local library. Small chunk sizes are forced to exercise pagination and#x1B[39;49;00m
    #x1B[33m      batching logic.#x1B[39;49;00m
    #x1B[33m    """#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        #x1B[94mfor#x1B[39;49;00m mb_albumid #x1B[95min#x1B[39;49;00m [#x1B[90m#x1B[39;49;00m
            #x1B[90m# already present in remote collection#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
            #x1B[33m"#x1B[39;49;00m#x1B[33min_collection1#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
            #x1B[33m"#x1B[39;49;00m#x1B[33min_collection2#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
            #x1B[90m# two new albums not in remote collection#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
            #x1B[33m"#x1B[39;49;00m#x1B[33m00000000-0000-0000-0000-000000000001#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
            #x1B[33m"#x1B[39;49;00m#x1B[33m00000000-0000-0000-0000-000000000002#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
        ]:#x1B[90m#x1B[39;49;00m
            helper.lib.add(Album(mb_albumid=mb_albumid))#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        #x1B[90m# The relevant collection#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        requests_mock.get(#x1B[90m#x1B[39;49;00m
            #x1B[33m"#x1B[39;49;00m#x1B[.../ws/2/collection#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
            json={#x1B[90m#x1B[39;49;00m
                #x1B[33m"#x1B[39;49;00m#x1B[33mcollections#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: [#x1B[90m#x1B[39;49;00m
                    {#x1B[90m#x1B[39;49;00m
                        #x1B[33m"#x1B[39;49;00m#x1B[33mid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[96mself#x1B[39;49;00m.COLLECTION_ID,#x1B[90m#x1B[39;49;00m
                        #x1B[33m"#x1B[39;49;00m#x1B[33mentity-type#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mrelease#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
                        #x1B[33m"#x1B[39;49;00m#x1B[33mrelease-count#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[94m3#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
                    }#x1B[90m#x1B[39;49;00m
                ]#x1B[90m#x1B[39;49;00m
            },#x1B[90m#x1B[39;49;00m
        )#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        collection_releases = #x1B[33mf#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[.../ws/2/collection/#x1B[39;49;00m#x1B[33m{#x1B[39;49;00m#x1B[96mself#x1B[39;49;00m.COLLECTION_ID#x1B[33m}#x1B[39;49;00m#x1B[33m/releases#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        #x1B[90m# Force small fetch chunk to require multiple paged requests.#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        monkeypatch.setattr(#x1B[90m#x1B[39;49;00m
            #x1B[33m"#x1B[39;49;00m#x1B[33mbeetsplug.mbcollection.MBCollection.FETCH_CHUNK_SIZE#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[94m2#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        )#x1B[90m#x1B[39;49;00m
        #x1B[90m# 3 releases are fetched in two pages.#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        requests_mock.get(#x1B[90m#x1B[39;49;00m
            re.compile(#x1B[33mrf#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33m.*#x1B[39;49;00m#x1B[33m{#x1B[39;49;00mcollection_releases#x1B[33m}#x1B[39;49;00m#x1B[33m\#x1B[39;49;00m#x1B[33mb.*&offset=0.*#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m),#x1B[90m#x1B[39;49;00m
            json={#x1B[90m#x1B[39;49;00m
                #x1B[33m"#x1B[39;49;00m#x1B[33mreleases#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: [{#x1B[33m"#x1B[39;49;00m#x1B[33mid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33min_collection1#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m}, {#x1B[33m"#x1B[39;49;00m#x1B[33mid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mnot_in_library#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m}]#x1B[90m#x1B[39;49;00m
            },#x1B[90m#x1B[39;49;00m
        )#x1B[90m#x1B[39;49;00m
        requests_mock.get(#x1B[90m#x1B[39;49;00m
            re.compile(#x1B[33mrf#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33m.*#x1B[39;49;00m#x1B[33m{#x1B[39;49;00mcollection_releases#x1B[33m}#x1B[39;49;00m#x1B[33m\#x1B[39;49;00m#x1B[33mb.*&offset=2.*#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m),#x1B[90m#x1B[39;49;00m
            json={#x1B[33m"#x1B[39;49;00m#x1B[33mreleases#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: [{#x1B[33m"#x1B[39;49;00m#x1B[33mid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33min_collection2#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m}]},#x1B[90m#x1B[39;49;00m
        )#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        #x1B[90m# Force small submission chunk#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        monkeypatch.setattr(#x1B[90m#x1B[39;49;00m
            #x1B[33m"#x1B[39;49;00m#x1B[33mbeetsplug.mbcollection.MBCollection.SUBMISSION_CHUNK_SIZE#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[94m1#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        )#x1B[90m#x1B[39;49;00m
        #x1B[90m# so that releases are added using two requests#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        requests_mock.put(#x1B[90m#x1B[39;49;00m
            re.compile(#x1B[90m#x1B[39;49;00m
                #x1B[33mrf#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33m.*#x1B[39;49;00m#x1B[33m{#x1B[39;49;00mcollection_releases#x1B[33m}#x1B[39;49;00m#x1B[33m/00000000-0000-0000-0000-000000000001#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
            )#x1B[90m#x1B[39;49;00m
        )#x1B[90m#x1B[39;49;00m
        requests_mock.put(#x1B[90m#x1B[39;49;00m
            re.compile(#x1B[90m#x1B[39;49;00m
                #x1B[33mrf#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33m.*#x1B[39;49;00m#x1B[33m{#x1B[39;49;00mcollection_releases#x1B[33m}#x1B[39;49;00m#x1B[33m/00000000-0000-0000-0000-000000000002#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
            )#x1B[90m#x1B[39;49;00m
        )#x1B[90m#x1B[39;49;00m
        #x1B[90m# and finally, one release is removed#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        requests_mock.delete(#x1B[90m#x1B[39;49;00m
            re.compile(#x1B[33mrf#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33m.*#x1B[39;49;00m#x1B[33m{#x1B[39;49;00mcollection_releases#x1B[33m}#x1B[39;49;00m#x1B[33m/not_in_library#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m)#x1B[90m#x1B[39;49;00m
        )#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
>       helper.run_command(#x1B[33m"#x1B[39;49;00m#x1B[33mmbupdate#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33m--remove#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m)#x1B[90m#x1B[39;49;00m

#x1B[1m#x1B[31mtest\plugins\test_mbcollection.py#x1B[0m:140: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
#x1B[1m#x1B[31mbeets\test\helper.py#x1B[0m:407: in run_command
    #x1B[0mbeets.ui._raw_main(#x1B[96mlist#x1B[39;49;00m(args), lib)#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mbeets\ui\__init__.py#x1B[0m:1624: in _raw_main
    #x1B[0msubcommand.func(lib, suboptions, subargs)#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mbeetsplug\mbcollection.py#x1B[0m:214: in update_collection
    #x1B[0m#x1B[96mself#x1B[39;49;00m.update_album_list(lib, lib.albums(), remove_missing)#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mbeetsplug\mbcollection.py#x1B[0m:227: in update_album_list
    #x1B[0mcollection = #x1B[96mself#x1B[39;49;00m.collection#x1B[90m#x1B[39;49;00m
                 ^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mC:\hostedtoolcache\windows\Python\3.11.9\x64\Lib\functools.py#x1B[0m:1001: in __get__
    #x1B[0mval = #x1B[96mself#x1B[39;49;00m.func(instance)#x1B[90m#x1B[39;49;00m
          ^^^^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mbeetsplug\mbcollection.py#x1B[0m:182: in collection
    #x1B[0mcollection_by_id := {#x1B[90m#x1B[39;49;00m
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

.0 = <list_iterator object at 0x00000202FDEFCEE0>

    #x1B[0m    collection_by_id := {#x1B[90m#x1B[39;49;00m
>           c[#x1B[33m"#x1B[39;49;00m#x1B[33mid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m]: c #x1B[94mfor#x1B[39;49;00m c #x1B[95min#x1B[39;49;00m collections #x1B[94mif#x1B[39;49;00m c[#x1B[33m"#x1B[39;49;00m#x1B[33mentity-type#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m] == #x1B[33m"#x1B[39;49;00m#x1B[33mrelease#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                                               ^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
        }#x1B[90m#x1B[39;49;00m
    ):#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mE   KeyError: 'entity-type'#x1B[0m

#x1B[1m#x1B[31mbeetsplug\mbcollection.py#x1B[0m:183: KeyError
test\plugins\test_parentwork.py::plugins::test_parentwork::ParentWorkTest::test_normal_case
Stack Traces | 0.03s run time
self = <test.plugins.test_parentwork.ParentWorkTest testMethod=test_normal_case>

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mtest_normal_case#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m):#x1B[90m#x1B[39;49;00m
        item = Item(path=#x1B[33m"#x1B[39;49;00m#x1B[33m/file#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, mb_workid=#x1B[33m"#x1B[39;49;00m#x1B[33m1#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, parentwork_workid_current=#x1B[33m"#x1B[39;49;00m#x1B[33m1#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m)#x1B[90m#x1B[39;49;00m
        item.add(#x1B[96mself#x1B[39;49;00m.lib)#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        #x1B[96mself#x1B[39;49;00m.run_command(#x1B[33m"#x1B[39;49;00m#x1B[33mparentwork#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m)#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        item.load()#x1B[90m#x1B[39;49;00m
>       #x1B[94massert#x1B[39;49;00m item[#x1B[33m"#x1B[39;49;00m#x1B[33mmb_parentworkid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m] == #x1B[33m"#x1B[39;49;00m#x1B[33m3#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mE       AssertionError: assert '1' == '3'#x1B[0m
#x1B[1m#x1B[31mE         #x1B[0m
#x1B[1m#x1B[31mE         #x1B[0m#x1B[91m- 3#x1B[39;49;00m#x1B[90m#x1B[39;49;00m#x1B[0m
#x1B[1m#x1B[31mE         #x1B[92m+ 1#x1B[39;49;00m#x1B[90m#x1B[39;49;00m#x1B[0m

#x1B[1m#x1B[31mtest\plugins\test_parentwork.py#x1B[0m:131: AssertionError
test\plugins\test_parentwork.py::plugins::test_parentwork::ParentWorkTest::test_force
Stack Traces | 0.035s run time
self = <test.plugins.test_parentwork.ParentWorkTest testMethod=test_force>

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mtest_force#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m):#x1B[90m#x1B[39;49;00m
        #x1B[96mself#x1B[39;49;00m.config[#x1B[33m"#x1B[39;49;00m#x1B[33mparentwork#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m][#x1B[33m"#x1B[39;49;00m#x1B[33mforce#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m] = #x1B[94mTrue#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        item = Item(#x1B[90m#x1B[39;49;00m
            path=#x1B[33m"#x1B[39;49;00m#x1B[33m/file#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
            mb_workid=#x1B[33m"#x1B[39;49;00m#x1B[33m1#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
            mb_parentworkid=#x1B[33m"#x1B[39;49;00m#x1B[33mXXX#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
            parentwork_workid_current=#x1B[33m"#x1B[39;49;00m#x1B[33m1#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
            parentwork=#x1B[33m"#x1B[39;49;00m#x1B[33mparentwork#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
        )#x1B[90m#x1B[39;49;00m
        item.add(#x1B[96mself#x1B[39;49;00m.lib)#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        #x1B[96mself#x1B[39;49;00m.run_command(#x1B[33m"#x1B[39;49;00m#x1B[33mparentwork#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m)#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        item.load()#x1B[90m#x1B[39;49;00m
>       #x1B[94massert#x1B[39;49;00m item[#x1B[33m"#x1B[39;49;00m#x1B[33mmb_parentworkid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m] == #x1B[33m"#x1B[39;49;00m#x1B[33m3#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mE       AssertionError: assert '1' == '3'#x1B[0m
#x1B[1m#x1B[31mE         #x1B[0m
#x1B[1m#x1B[31mE         #x1B[0m#x1B[91m- 3#x1B[39;49;00m#x1B[90m#x1B[39;49;00m#x1B[0m
#x1B[1m#x1B[31mE         #x1B[92m+ 1#x1B[39;49;00m#x1B[90m#x1B[39;49;00m#x1B[0m

#x1B[1m#x1B[31mtest\plugins\test_parentwork.py#x1B[0m:147: AssertionError
test\plugins\test_missing.py::plugins::test_missing::TestMissingAlbums::test_missing_artist_albums[missing]
Stack Traces | 0.042s run time
self = <test.plugins.test_missing.TestMissingAlbums object at 0x00000202E82A1790>
requests_mock = <requests_mock.mocker.Mocker object at 0x00000202FF0D5BD0>
helper = <beets.test.helper.TestHelper object at 0x00000202E11FFED0>
release_from_mb = {'id': 'other', 'title': 'Other Album'}
expected_output = 'Artist - Other Album\n'

    #x1B[0m#x1B[37m@pytest#x1B[39;49;00m.mark.parametrize(#x1B[90m#x1B[39;49;00m
        #x1B[33m"#x1B[39;49;00m#x1B[33mrelease_from_mb,expected_output#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
        [#x1B[90m#x1B[39;49;00m
            pytest.param(#x1B[90m#x1B[39;49;00m
                {#x1B[33m"#x1B[39;49;00m#x1B[33mid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mother#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33mtitle#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: #x1B[33m"#x1B[39;49;00m#x1B[33mOther Album#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m},#x1B[90m#x1B[39;49;00m
                #x1B[33m"#x1B[39;49;00m#x1B[33mArtist - Other Album#x1B[39;49;00m#x1B[33m\n#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
                #x1B[96mid#x1B[39;49;00m=#x1B[33m"#x1B[39;49;00m#x1B[33mmissing#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
            ),#x1B[90m#x1B[39;49;00m
            pytest.param(#x1B[90m#x1B[39;49;00m
                {#x1B[33m"#x1B[39;49;00m#x1B[33mid#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: album_in_lib.mb_albumid, #x1B[33m"#x1B[39;49;00m#x1B[33mtitle#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: album_in_lib.album},#x1B[90m#x1B[39;49;00m
                #x1B[33m"#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
                marks=pytest.mark.xfail(#x1B[90m#x1B[39;49;00m
                    reason=(#x1B[90m#x1B[39;49;00m
                        #x1B[33m"#x1B[39;49;00m#x1B[33mAlbum in lib must not be reported as missing.#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                        #x1B[33m"#x1B[39;49;00m#x1B[33m Needs fixing.#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                    )#x1B[90m#x1B[39;49;00m
                ),#x1B[90m#x1B[39;49;00m
                #x1B[96mid#x1B[39;49;00m=#x1B[33m"#x1B[39;49;00m#x1B[33mnot missing#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
            ),#x1B[90m#x1B[39;49;00m
        ],#x1B[90m#x1B[39;49;00m
    )#x1B[90m#x1B[39;49;00m
    #x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mtest_missing_artist_albums#x1B[39;49;00m(#x1B[90m#x1B[39;49;00m
        #x1B[96mself#x1B[39;49;00m, requests_mock, helper, release_from_mb, expected_output#x1B[90m#x1B[39;49;00m
    ):#x1B[90m#x1B[39;49;00m
        helper.lib.add(#x1B[96mself#x1B[39;49;00m.album_in_lib)#x1B[90m#x1B[39;49;00m
        requests_mock.get(#x1B[90m#x1B[39;49;00m
            #x1B[33mf#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[.../ws/2/release-group?artist=#x1B[39;49;00m#x1B[33m{#x1B[39;49;00m#x1B[96mself#x1B[39;49;00m.album_in_lib.mb_albumartistid#x1B[33m}#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
            json={#x1B[33m"#x1B[39;49;00m#x1B[33mrelease-groups#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: [release_from_mb]},#x1B[90m#x1B[39;49;00m
        )#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        #x1B[94mwith#x1B[39;49;00m #x1B[96mself#x1B[39;49;00m.configure_plugin({}):#x1B[90m#x1B[39;49;00m
            #x1B[94massert#x1B[39;49;00m (#x1B[90m#x1B[39;49;00m
>               helper.run_with_output(#x1B[33m"#x1B[39;49;00m#x1B[33mmissing#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33m--album#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m) == expected_output#x1B[90m#x1B[39;49;00m
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
            )#x1B[90m#x1B[39;49;00m

#x1B[1m#x1B[31mtest\plugins\test_missing.py#x1B[0m:60: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
#x1B[1m#x1B[31mbeets\test\helper.py#x1B[0m:411: in run_with_output
    #x1B[0m#x1B[96mself#x1B[39;49;00m.run_command(*args)#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mbeets\test\helper.py#x1B[0m:407: in run_command
    #x1B[0mbeets.ui._raw_main(#x1B[96mlist#x1B[39;49;00m(args), lib)#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mbeets\ui\__init__.py#x1B[0m:1624: in _raw_main
    #x1B[0msubcommand.func(lib, suboptions, subargs)#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mbeetsplug\missing.py#x1B[0m:146: in _miss
    #x1B[0mhelper(lib, args)#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mbeetsplug\missing.py#x1B[0m:200: in _missing_albums
    #x1B[0mresp = #x1B[96mself#x1B[39;49;00m.mb_api.browse_release_groups(artist=artist_id)#x1B[90m#x1B[39;49;00m
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mbeetsplug\_utils\musicbrainz.py#x1B[0m:501: in wrapper
    #x1B[0m#x1B[94mreturn#x1B[39;49;00m func(*args, **kwargs)#x1B[90m#x1B[39;49;00m
           ^^^^^^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = MusicBrainzAPI(api_host='https://musicbrainz.org', rate_limit=1.0)
kwargs = {'artist': '8bd78daf-7025-4d80-ba5e-b726240a655e'}

    #x1B[0m#x1B[37m@require_one_of#x1B[39;49;00m(#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[33mcollection#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33mrelease#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m)#x1B[90m#x1B[39;49;00m
    #x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mbrowse_release_groups#x1B[39;49;00m(#x1B[90m#x1B[39;49;00m
        #x1B[96mself#x1B[39;49;00m, **kwargs: Unpack[BrowseReleaseGroupsKwargs]#x1B[90m#x1B[39;49;00m
    ) -> #x1B[96mlist#x1B[39;49;00m[ReleaseGroup]:#x1B[90m#x1B[39;49;00m
    #x1B[90m    #x1B[39;49;00m#x1B[33m"""Browse release groups related to the given entities.#x1B[39;49;00m
    #x1B[33m#x1B[39;49;00m
    #x1B[33m    At least one of artist, collection, or release must be provided.#x1B[39;49;00m
    #x1B[33m    """#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
>       #x1B[94mreturn#x1B[39;49;00m #x1B[96mself#x1B[39;49;00m._get_resource(#x1B[33m"#x1B[39;49;00m#x1B[33mrelease-group#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, **kwargs)[#x1B[33m"#x1B[39;49;00m#x1B[33mrelease-groups#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m]#x1B[90m#x1B[39;49;00m
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mE       KeyError: 'release-groups'#x1B[0m

#x1B[1m#x1B[31mbeetsplug\_utils\musicbrainz.py#x1B[0m:639: KeyError

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

@amogus07
Copy link
Contributor

amogus07 commented Feb 2, 2026

I also did a similar sort of refactoring recently, and this is what I ended up with: https://github.com/prTopi/beets-vocadb/tree/experimental/beetsplug/vocadb/vocadb_api_client

Feel free to take inpiration!

Here are some suggestions:

  1. TypedDicts are only referenced in type annotations, so they can all be put under if TYPE_CHECKING:
  2. Using StrEnums instead of Literals (or a custom Enum type like this that overrides auto()).
  3. Perhaps it's worth putting the models into their own module or even package.

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.

3 participants