Skip to content

Conversation

@snejus
Copy link
Member

@snejus snejus commented Dec 5, 2025

Export all previously publicly available definitions.

Once this is merged we should be good to go ahead with v1, I think.

Context

I found that fetchart plugin crashed in beets due to

ImportError while importing test module '/home/sarunas/repo/beets/test/plugins/test_art.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
../../.local/share/uv/python/cpython-3.10.18-linux-x86_64-gnu/lib/python3.10/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
test/plugins/test_art.py:40: in <module>
    from beetsplug import fetchart
beetsplug/fetchart.py:30: in <module>
    from mediafile import image_mime_type
E   ImportError: cannot import name 'image_mime_type' from 'mediafile' (/media/poetry/virtualenvs/beets-yAypcYUQ-py3.10/lib/python3.10/site-packages/mediafile/__init__.py)

@snejus snejus requested review from JOJ0 and sampsyo December 5, 2025 04:53
@github-actions
Copy link

github-actions bot commented Dec 5, 2025

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.

@codecov-commenter
Copy link

codecov-commenter commented Dec 5, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.26%. Comparing base (d2bb1b4) to head (7e2e1c0).

Additional details and impacted files
@@           Coverage Diff           @@
##           master      #96   +/-   ##
=======================================
  Coverage   93.26%   93.26%           
=======================================
  Files          16       16           
  Lines         817      817           
  Branches      118      118           
=======================================
  Hits          762      762           
  Misses         35       35           
  Partials       20       20           

@semohr
Copy link
Contributor

semohr commented Dec 7, 2025

As I mentioned indirectly in #86 (comment), I’m strongly against re-adding all exports from the original monolithic module.

We bumped the major version specifically to define and narrow the public API. Re-exposing legacy imports would effectively commit us to supporting all of those classes and functions indefinitely, which would severely limit our ability to refactor or redesign the internals.

So this is a firm NO from my side.

@snejus
Copy link
Member Author

snejus commented Dec 7, 2025

I am fundamentally against breaking functionality for people who depend on mediafile for the sole reason of internal restructuring - when all we have to do is to simply include the previously available API in the __all__ definition.

Please search GitHub and you will find that multiple projects import these classes/functions that I have re-added. I don't understand what is the drawback for us simply including them in __all__ as I've done it in this PR?

@semohr
Copy link
Contributor

semohr commented Dec 7, 2025

The main point is that the whole reason we bumped to 1.0.0 was to define and enforce a stable public API. By re-adding internal or legacy exports to __all__, we are effectively committing ourselves to support every class and function that previously existed, even those that were never intended for public use. That severely limits our ability to refactor, redesign, or improve the internals without breaking downstream users in the future.

Yes, some projects currently import these legacy items, but they were never guaranteed to be stable. Changing an import path or deprecating these internal functions is exactly what a 1.0.0 release is for: it’s the first version where we define what is public and what isn’t. In other words, breaking internal imports is reasonable and expected at this stage.

Re-exposing everything in __all__ might seem like a minimal change now, but it effectively freezes a large portion of our codebase in its current form forever. That tradeoff is much more damaging than the one-time migration effort required for consumers to adapt to the new, clearly defined API.

Take for instance #97: if we exported MediaField in __all__, it would already require another major version. This shows exactly how exposing internals limits our flexibility and ability to evolve the codebase.

@snejus
Copy link
Member Author

snejus commented Dec 8, 2025

Can you point me to the discussion where the specifics of what constitutes a stable public API, and what counts as legacy, have been debated, before you decided to remove these imports?

I am specifically interested to see the consensus regarding downstream developers that rely on them.


Re-exposing everything in __all__ might seem like a minimal change now, but it effectively freezes a large portion of our codebase in its current form forever. That tradeoff is much more damaging than the one-time migration effort required for consumers to adapt to the new, clearly defined API.

Take for instance #97: if we exported MediaField in __all__, it would already require another major version. This shows exactly how exposing internals limits our flexibility and ability to evolve the codebase.

Can you explain how

# mediafile/__init__.py
from .fields import MediaField


__all__ = [
    ...
    MediaField,
    ...
]

has any impact on the refactoring that you linked?

@semohr
Copy link
Contributor

semohr commented Dec 8, 2025

Can you point me to the discussion where the specifics of what constitutes a stable public API, and what counts as legacy, have been debated, before you decided to remove these imports?

We regard everything defined in __all__ originally (before the refactor) as stable public API, as we agree these should stay public. Conversely, anything not exported via __all__ is treated as private, even if it was previously imported from internal paths by external code. This approach is consistent with PEP 8s conventions around public vs. private interfaces. Because many of those previously "accidentally public" imports are no longer available at the same paths after the refactor, I’ve referred to them collectively as legacy.

Generally downstream users who depend on such private or internal objects (intentionally not defined in __all__) implicitly accept that these parts of the codebase may change, move, or disappear without notice.

Can you explain how ... has any impact on the refactoring that you linked?

In short, the refactor changes the inheritance hierarchy of the MediaField classes in order to enable correct type-hint propagation for MediaFile fields. This does alter behavior in observable ways: for example, it affects isinstance checks against MediaField. If downstream code relies on the old hierarchy or on performing such checks, this is a breaking change.

Under semantic versioning, modifying class relationships in a way that breaks inheritance expectations would require a major version bump, provided that MediaField was intended to be part of the public API.

@snejus
Copy link
Member Author

snejus commented Dec 13, 2025

We regard everything defined in __all__ originally (before the refactor) as stable public API, as we agree these should stay public. Conversely, anything not exported via __all__ is treated as private, even if it was previously imported from internal paths by external code. This approach is consistent with PEP 8s conventions around public vs. private interfaces. Because many of those previously "accidentally public" imports are no longer available at the same paths after the refactor, I’ve referred to them collectively as legacy.

Philosophically speaking, I agree with you. However, in practice, we know that these accidentally public imports are being used by many downstream projects. The only definitions that we could safely treat as private were prefixed an underscore, as I mentioned in my comment under your PR. It is my responsibility as a maintainer to consider this and not break their functionality, unless we have a very, very convincing reason to do so.

In short, the refactor changes the inheritance hierarchy of the MediaField classes in order to enable correct type-hint propagation for MediaFile fields. This does alter behavior in observable ways: for example, it affects isinstance checks against MediaField. If downstream code relies on the old hierarchy or on performing such checks, this is a breaking change.

I am not convinced - this class will break isinstance checks regardless whether it's imported from mediafile or from mediafile.fields.

If you're still not convinced that the pre-existing definition of __all__ had no value

__all__ = ["UnreadableFileError", "FileTypeError", "MediaFile"]

Simply have a glance at internal tests that rely on other definitions

# test/test_mediafile.py
from mediafile import CoverArtField, Image, ImageType, MediaFile, UnreadableFileError

And finally, if __all__ defined the de-facto public interface, why did you extend it?

__all__ = [
    "UnreadableFileError",
    "FileTypeError",
    "MutagenError",
    "MediaFile",
    "Image",
    "TYPES",
    "ImageType",
]

@semohr
Copy link
Contributor

semohr commented Dec 17, 2025

There are two ways to define the public API: either __all__ is authoritative, meaning only symbols explicitly listed there are public and everything else is private (as suggested by PEP 8), or it’s usage-based, meaning any symbol downstream code imports or relies on is de-facto public. If we follow the usage-based approach, re-adding previously importable symbols to __all__ has no effect, since they’re already effectively public; conversely, if __all__ is authoritative, symbols not listed there cannot retroactively become public. The current PR seems to try to apply both logics at once, which makes it inconsistent at best or undermines its purpose at worst.

Simply have a glance at internal tests that rely on other definitions

Internal tests also aren’t a good indicator of public API. They are free to import private or internal symbols, and using them as evidence would make refactoring effectively impossible.

I am not convinced - this class will break isinstance checks regardless whether it's imported from mediafile or from mediafile.fields.

The breaking change comes from the altered class hierarchy itself, not from whether a class is imported via mediafile or mediafile.fields. Changing the inheritance will affect isinstance checks regardless of import path. What I'm saying is MediaField should be for internal usage only.

And finally, if all defined the de-facto public interface, why did you extend it?

The fact that __all__ was extended during the prerelease suggests we’re explicitly defining and formalizing the public API now, rather than preserving a historically fixed one. That seems like the right time to correct accidental exposure instead of treating it as immutable. We already had a discussion on this in #86.

If the concern is widespread downstream reliance on specific internal helpers (e.g. mutagen_call), it would be helpful to see concrete external usage examples. Without that, it’s hard to weigh hypothetical breakage against the long-term maintainability. I’m happy to find common ground here, but I think we need a consistent definition of "public API" to reason about breakage meaningfully. Just adding everything to __all__ seems like the wrong approach to me.

Tagging @JOJ0 and @henry-oberholtzer for some external perspective and in case they want to give their 2 cents.

@snejus
Copy link
Member Author

snejus commented Dec 17, 2025

And @Serene-Arc!

@henry-oberholtzer
Copy link
Member

My inexperience with Python packaging & this project in general means that my opinion is maybe more along the lines of that of a user.

I do feel like since we're working towards the first major release, we have a lot more freedom to change things up. If we were to remove this after a 1.0.0 update, I think there would be more to worry about. We also have the advantage of having not published a package on PyPi for over a year, which gives more impetus to users to investigate the breaking changes with the update. I think it's also necessary to assume that users will look into major changes between versions, so the deprecation of that export could be mentioned in our release notes.

It looks like importing filetype and using filetype.guess_mime() would directly replace that function we have in Mediafile too, so I'm not sure there's a reason to include it in our public API when it's a one line wrapper of another library.

There's also the third option of maintaining the export with a deprecation warning, but I think that goes counter to the 1.0.0 ideals.

@snejus
Copy link
Member Author

snejus commented Dec 28, 2025

After thinking about the feedback here, I've decided to merge this PR but release it as v0.14.0 instead of v1.0.0.

@semohr @henry-oberholtzer you both make good points about what v1.0.0 should represent, and I get the argument about not freezing internal stuff forever. But I think we're viewing it differently.

v1.0.0 doesn't mean "let's break everything and start fresh" - it means "OK, we've been shipping this for 10+ releases, we know what works, and this is the API we're committing to not change." It's about acknowledging reality and making a promise based on what's already proven stable in the field.

These imports aren't hypothetical - they're in actual projects right now. Multiple beets plugins are importing StorageStyle, etc. If we release v1.0.0 and immediately break them, we're not really establishing a stable API, we're just drawing an arbitrary line and saying "everything before this doesn't count." That's not great for anyone trying to depend on us.

The way I see it:

  • v0.14.0 acknowledges "this is what people are actually using"
  • v0.15+ we deprecate things clearly, so people have a migration path, following the same methodology we have in beets
  • v1.0.0 we remove the deprecated stuff and say "and this is what we're committing to"

That's more honest about what stability means. We're not breaking things "because it's v1.0" - we're being intentional about what we support and giving people time to adapt.

Appreciate the discussion on this – it's exactly the kind of thing that matters.

@snejus snejus force-pushed the export-previous-public-defs branch from 5807412 to 0cace9f Compare December 28, 2025 10:41
@semohr
Copy link
Contributor

semohr commented Dec 28, 2025

You are again arguing for usage defines the api (which is fine btw). Could you please provide a list and show where each exported class is used externally also when the package/app was updated last. Compatibility with beets might also be interesting. I would not count beets here tbh as we can make it compatible easily and it is not really that big of an issue to change an import when we increment the mediafile version.

Also we should keep the __all__ as it was before than. We do not need to add anything to it, as it has no meaning (only defines how * exports are treated). Just importing everything in the __init__.py file should be sufficient.

@snejus snejus merged commit 7b53be9 into master Dec 28, 2025
13 checks passed
@snejus snejus deleted the export-previous-public-defs branch December 28, 2025 10:58
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.

5 participants