Skip to content

Type musicbrainz#6329

Draft
snejus wants to merge 29 commits intomasterfrom
type-musicbrainz
Draft

Type musicbrainz#6329
snejus wants to merge 29 commits intomasterfrom
type-musicbrainz

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

❌ 9 Tests Failed:

Tests completed Failed Passed Skipped
2289 9 2280 68
View the top 3 failed test(s) by shortest run time
test/plugins/test_musicbrainz.py::MBAlbumInfoTest::test_missing_tracks
Stack Traces | 0.038s run time
self = <test.plugins.test_musicbrainz.MBAlbumInfoTest testMethod=test_missing_tracks>

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mtest_missing_tracks#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m):#x1B[90m#x1B[39;49;00m
        tracks = [#x1B[90m#x1B[39;49;00m
>           #x1B[96mself#x1B[39;49;00m._make_track(#x1B[33m"#x1B[39;49;00m#x1B[33mTITLE ONE#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33mID ONE#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[94m100.0#x1B[39;49;00m * #x1B[94m1000.0#x1B[39;49;00m),#x1B[90m#x1B[39;49;00m
            #x1B[96mself#x1B[39;49;00m._make_track(#x1B[90m#x1B[39;49;00m
                #x1B[33m"#x1B[39;49;00m#x1B[33mTITLE TWO#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
                #x1B[33m"#x1B[39;49;00m#x1B[33mID TWO#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
                #x1B[94m200.0#x1B[39;49;00m * #x1B[94m1000.0#x1B[39;49;00m,#x1B[90m#x1B[39;49;00m
                disambiguation=#x1B[33m"#x1B[39;49;00m#x1B[33mSECOND TRACK#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[1m#x1B[31mE       AttributeError: 'MBAlbumInfoTest' object has no attribute '_make_track'#x1B[0m

#x1B[1m#x1B[31mtest/plugins/test_musicbrainz.py#x1B[0m:569: AttributeError
test/plugins/test_mbcollection.py::TestMbCollectionPlugin::test_get_collection_validation[invalid ID]
Stack Traces | 0.041s run time
self = <test.plugins.test_mbcollection.TestMbCollectionPlugin object at 0x7fa5bf207850>
requests_mock = <requests_mock.mocker.Mocker object at 0x7fa59f4fefe0>
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[.../hostedtoolcache/Python/3.10.19.../x64/lib/python3.10/functools.py#x1B[0m:981: in __get__
    #x1B[0mval = #x1B[96mself#x1B[39;49;00m.func(instance)#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 0x7fa59e89dd50>

    #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[1m#x1B[31mE   KeyError: 'entity-type'#x1B[0m

#x1B[1m#x1B[31mbeetsplug/mbcollection.py#x1B[0m:183: KeyError
test/plugins/test_mbcollection.py::TestMbCollectionPlugin::test_get_collection_validation[no release collections]
Stack Traces | 0.041s run time
self = <test.plugins.test_mbcollection.TestMbCollectionPlugin object at 0x7fa5bf205e10>
requests_mock = <requests_mock.mocker.Mocker object at 0x7fa59e92a7d0>
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[.../hostedtoolcache/Python/3.10.19.../x64/lib/python3.10/functools.py#x1B[0m:981: in __get__
    #x1B[0mval = #x1B[96mself#x1B[39;49;00m.func(instance)#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 0x7fa59e92bf40>

    #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[1m#x1B[31mE   KeyError: 'entity-type'#x1B[0m

#x1B[1m#x1B[31mbeetsplug/mbcollection.py#x1B[0m:183: KeyError
test/plugins/test_mbcollection.py::TestMbCollectionPlugin::test_get_collection_validation[valid]
Stack Traces | 0.042s run time
self = <test.plugins.test_mbcollection.TestMbCollectionPlugin object at 0x7fa5bf2062f0>
requests_mock = <requests_mock.mocker.Mocker object at 0x7fa59f95d660>
user_collections = [{'entity-type': 'release', 'id': 'dd3ad122-82dc-47d6-aced-3a792581d2ab'}]
expectation = <contextlib.nullcontext object at 0x7fa5bf2068c0>

    #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[.../hostedtoolcache/Python/3.10.19.../x64/lib/python3.10/functools.py#x1B[0m:981: in __get__
    #x1B[0mval = #x1B[96mself#x1B[39;49;00m.func(instance)#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 0x7fa59f400340>

    #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[1m#x1B[31mE   KeyError: 'entity-type'#x1B[0m

#x1B[1m#x1B[31mbeetsplug/mbcollection.py#x1B[0m:183: KeyError
test/plugins/test_mpdstats.py::MPDStatsTest::test_get_item
Stack Traces | 0.042s 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[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[1m#x1B[31m../../../..../pypoetry/virtualenvs/beets-TE2wMJyA-py3.10/lib/python3.10.../site-packages/confuse/core.py#x1B[0m:317: 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[1m#x1B[31m../../../..../pypoetry/virtualenvs/beets-TE2wMJyA-py3.10/lib/python3.10....../site-packages/confuse/templates.py#x1B[0m:101: 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
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

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[96mstr#x1B[39;49;00m = #x1B[33m"#x1B[39;49;00m#x1B[33mdefault#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m) -> T_co:#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[33mf#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33m{#x1B[39;49;00mkey_name#x1B[33m}#x1B[39;49;00m#x1B[33m not found#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m)#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mE           confuse.exceptions.NotFoundError: mpd.rating not found#x1B[0m

#x1B[1m#x1B[31m../../../..../pypoetry/virtualenvs/beets-TE2wMJyA-py3.10/lib/python3.10....../site-packages/confuse/templates.py#x1B[0m:110: NotFoundError
test/plugins/test_parentwork.py::ParentWorkTest::test_normal_case
Stack Traces | 0.054s 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_mbcollection.py::TestMbCollectionPlugin::test_mbupdate
Stack Traces | 0.055s run time
self = <test.plugins.test_mbcollection.TestMbCollectionPlugin object at 0x7fa5bf2040a0>
helper = <test.plugins.test_mbcollection.TestMbCollectionPlugin object at 0x7fa5bf2040a0>
requests_mock = <requests_mock.mocker.Mocker object at 0x7fa59f7c41c0>
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7fa59f4fe350>

    #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:139: 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:1612: 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[1m#x1B[.../hostedtoolcache/Python/3.10.19.../x64/lib/python3.10/functools.py#x1B[0m:981: in __get__
    #x1B[0mval = #x1B[96mself#x1B[39;49;00m.func(instance)#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 0x7fa59ee92500>

    #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[1m#x1B[31mE   KeyError: 'entity-type'#x1B[0m

#x1B[1m#x1B[31mbeetsplug/mbcollection.py#x1B[0m:183: KeyError
test/plugins/test_parentwork.py::ParentWorkTest::test_force
Stack Traces | 0.064s 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::TestMissingAlbums::test_missing_artist_albums[missing]
Stack Traces | 0.082s run time
self = <test.plugins.test_missing.TestMissingAlbums object at 0x7fa5be9851b0>
requests_mock = <requests_mock.mocker.Mocker object at 0x7fa59e8fe740>
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, release_from_mb, expected_output#x1B[90m#x1B[39;49;00m
    ):#x1B[90m#x1B[39;49;00m
        #x1B[96mself#x1B[39;49;00m.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[96mself#x1B[39;49;00m.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[1m#x1B[31mtest/plugins/test_missing.py#x1B[0m:62: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
#x1B[1m#x1B[31mbeets/test/helper.py#x1B[0m:148: 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:139: 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:1612: 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[1m#x1B[31mbeetsplug/_utils/musicbrainz.py#x1B[0m:532: in wrapper
    #x1B[0m#x1B[94mreturn#x1B[39;49;00m func(*args, **kwargs)#x1B[90m#x1B[39;49;00m
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = MusicBrainzAPI(api_host='https://musicbrainz.org', rate_limit=1.0)
kwargs = {'artist': '9362305f-f3d0-48c5-aa5a-42ef1b8b9cef'}

    #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[1m#x1B[31mE       KeyError: 'release-groups'#x1B[0m

#x1B[1m#x1B[31mbeetsplug/_utils/musicbrainz.py#x1B[0m:685: 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.

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

Copilot reviewed 12 out of 13 changed files in this pull request and generated 6 comments.

artist = factory.SubFactory(ArtistFactory)
joinphrase = ""
name = factory.LazyAttribute(lambda o: f"{o.artist['name']} Credit")

Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

grug see many factory test now, good for grug

but grug worry: medium factory have big post_generation magic on line 201 that change track position. grug think this hide where track position come from. what if future grug want track at position 5 but factory always override? harder for grug to understand test when magic happen in background.

maybe better track position set explicit in test, not magic in factory? more code lines, yes, but grug brain see what happen easier.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +64 to +68
return factories.MediumFactory(**kwargs) # type: ignore[return-value]


def release_factory(**kwargs) -> mb.Release:
return factories.ReleaseFactory(**kwargs) # type: ignore[return-value]
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

grug see type: ignore[return-value] on medium_factory and release_factory, lines 64 and 68. grug wonder why need ignore? if typing not work, maybe something wrong with factory return type?

factory_boy return instance that match dict shape, but mypy not understand. grug think maybe better use proper TypedDict cast or fix return annotation to help mypy, instead of tell mypy to be quiet.

Copilot uses AI. Check for mistakes.
Comment on lines +215 to +260
class ReleaseFactory(_IdFactory):
class Params:
id_base = 1000000

aliases = factory.List([])
artist_credit = factory.List(
[factory.SubFactory(ArtistCreditFactory, artist__id_base=10)]
)
asin = factory.LazyAttribute(lambda o: f"{o.title} Asin")
barcode = "0000000000000"
cover_art_archive = factory.Dict(
{
"artwork": False,
"back": False,
"count": 0,
"darkened": False,
"front": False,
}
)
disambiguation = factory.LazyAttribute(
lambda o: f"{o.title} Disambiguation"
)
genres = factory.List([factory.SubFactory(GenreFactory)])
label_info = factory.List([factory.SubFactory(LabelInfoFactory)])
media = factory.List([factory.SubFactory(MediumFactory)])
packaging: str | None = None
packaging_id: str | None = None
quality = "normal"
release_events = factory.List(
[
factory.SubFactory(
ReleaseEventFactory, area=None, date="2021-03-26"
),
factory.SubFactory(
ReleaseEventFactory,
area__iso_3166_1_codes=["US"],
date="2020-01-01",
),
]
)
release_group = factory.SubFactory(ReleaseGroupFactory)
status = "Official"
status_id = "4e304316-386d-3409-af2e-78857eec5cfe"
tags = factory.List([factory.SubFactory(TagFactory)])
text_representation = factory.SubFactory(TextRepresentationFactory)
title = "Album"
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

grug see country field missing from ReleaseFactory but Release TypedDict in beetsplug/_utils/musicbrainz.py line 513 say country: NotRequired[str | None].

release factory should have country field with default value, maybe None or "US" to match release_events. right now factory not make full release shape, which could confuse grug if test need country.

Copilot uses AI. Check for mistakes.
Comment on lines +275 to +324
@staticmethod
def _parse_artist_credits(artist_credits: list[ArtistCredit]) -> ArtistInfo:
"""Normalize MusicBrainz artist-credit data into tag-friendly fields.

MusicBrainz represents credits as a sequence of credited artists, each
with a display name and a `joinphrase` (for example `' & '`, `' feat.
'`, or `''`). This helper converts that structured representation into
both:

- Single string values suitable for common tags (concatenated names with
joinphrases preserved).
- Parallel lists that keep the per-artist granularity for callers that
need to reason about individual credited artists.

When available, a preferred alias is used for the canonical artist name
and sort name, while the credit name preserves the exact credited text
from the release.
"""
artist_parts: list[str] = []
artist_sort_parts: list[str] = []
artist_credit_parts: list[str] = []
artists: list[str] = []
artists_sort: list[str] = []
artists_credit: list[str] = []
artists_ids: list[str] = []

for el in artist_credits:
artists_ids.append(el["artist"]["id"])
alias = _preferred_alias(el["artist"].get("aliases", []))
artist_object = alias or el["artist"]

joinphrase = el["joinphrase"]
for name, parts, multi in (
(artist_object["name"], artist_parts, artists),
(artist_object["sort_name"], artist_sort_parts, artists_sort),
(el["name"], artist_credit_parts, artists_credit),
):
parts.extend([name, joinphrase])
multi.append(name)

return {
"artist": "".join(artist_parts),
"artist_id": artists_ids[0],
"artist_sort": "".join(artist_sort_parts),
"artist_credit": "".join(artist_credit_parts),
"artists": artists,
"artists_ids": artists_ids,
"artists_sort": artists_sort,
"artists_credit": artists_credit,
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

grug see _parse_artist_credits now return dict with 8 keys. this many key make grug head spin!

old code spread artist parsing through many function like _flatten_artist_credit and _multi_artist_credit. new code put all in one big dict. grug like one place better than many place, but 8 field lot to keep in brain at once.

maybe helper could return smaller pieces? like one dict for single strings, one dict for lists? less key per dict easier for grug understand.

Copilot generated this review using guidance from repository custom instructions.
recording = track["recording"]
# Prefer track data, where present, over recording data.
for key in ("title", "artist_credit", "length"):
recording[key] = track[key] or recording[key]
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

grug see line 514 do recording[key] = track[key] or recording[key]. but grug worry: what if track[key] is empty string "" or 0? then track[key] or recording[key] skip track value even though track value exist!

old code at line ~556 check if track.get(key): which also have same problem. but mutating recording dict in place very dangerous, harder to see what change. maybe better make copy first or use explicit None check like if track[key] is not None:?

Suggested change
recording[key] = track[key] or recording[key]
value = track.get(key)
if value is not None:
recording[key] = value

Copilot uses AI. Check for mistakes.
Comment on lines +571 to +575
media=(
medias.pop()
if len(medias := {t.media for t in track_infos}) == 1
else "Media"
),
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

grug see at line 571-575, code do walrus operator with set comprehension:

media=(
    medias.pop()
    if len(medias := {t.media for t in track_infos}) == 1
    else "Media"
)

this make grug head hurt! walrus inside if condition, plus set comprehension, plus pop on set. grug have to read many time to understand.

maybe split into two line? first medias = {t.media for t in track_infos}, then check len(medias). easier for grug read and debug.

Copilot generated this review using guidance from repository custom instructions.
@snejus snejus marked this pull request as draft February 23, 2026 05:22
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