Drop support for Python 3.9 (#6144)

Drop support for Python 3.9 and pyupgrade the codebase.

Dependency upgrades:

<img width="644" height="1017" alt="image"
src="https://github.com/user-attachments/assets/e5be110b-66fb-4373-8413-e09a56ba54bc"
/>
This commit is contained in:
Šarūnas Nejus 2025-11-11 04:09:46 +00:00 committed by GitHub
commit 3a72d85c5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1656 additions and 1489 deletions

View file

@ -78,4 +78,6 @@ d93ddf8dd43e4f9ed072a03829e287c78d2570a2
# Moved ui.commands._utils into ui.commands.utils
25ae330044abf04045e3f378f72bbaed739fb30d
# Refactor test_ui_command.py into multiple modules
a59e41a88365e414db3282658d2aa456e0b3468a
a59e41a88365e414db3282658d2aa456e0b3468a
# pyupgrade Python 3.10
301637a1609831947cb5dd90270ed46c24b1ab1b

View file

@ -20,10 +20,10 @@ jobs:
fail-fast: false
matrix:
platform: [ubuntu-latest, windows-latest]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13"]
runs-on: ${{ matrix.platform }}
env:
IS_MAIN_PYTHON: ${{ matrix.python-version == '3.9' && matrix.platform == 'ubuntu-latest' }}
IS_MAIN_PYTHON: ${{ matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest' }}
steps:
- uses: actions/checkout@v5
- name: Install Python tools

View file

@ -3,6 +3,10 @@ on:
workflow_dispatch:
schedule:
- cron: "0 0 * * SUN" # run every Sunday at midnight
env:
PYTHON_VERSION: "3.10"
jobs:
test_integration:
runs-on: ubuntu-latest
@ -12,7 +16,7 @@ jobs:
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v6
with:
python-version: 3.9
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
- name: Install dependencies

View file

@ -12,7 +12,7 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
PYTHON_VERSION: 3.9
PYTHON_VERSION: "3.10"
jobs:
changed-files:

View file

@ -8,7 +8,7 @@ on:
required: true
env:
PYTHON_VERSION: 3.9
PYTHON_VERSION: "3.10"
NEW_VERSION: ${{ inputs.version }}
NEW_TAG: v${{ inputs.version }}

View file

@ -124,12 +124,12 @@ command. Instead, you can activate the virtual environment in your shell with:
$ poetry shell
You should see ``(beets-py3.9)`` prefix in your shell prompt. Now you can run
You should see ``(beets-py3.10)`` prefix in your shell prompt. Now you can run
commands directly, for example:
::
$ (beets-py3.9) pytest
$ (beets-py3.10) pytest
Additionally, poethepoet_ task runner assists us with the most common
operations. Formatting, linting, testing are defined as ``poe`` tasks in

View file

@ -18,7 +18,7 @@ from __future__ import annotations
import warnings
from importlib import import_module
from typing import TYPE_CHECKING, Union
from typing import TYPE_CHECKING
from beets import config, logging
@ -117,8 +117,8 @@ SPECIAL_FIELDS = {
def _apply_metadata(
info: Union[AlbumInfo, TrackInfo],
db_obj: Union[Album, Item],
info: AlbumInfo | TrackInfo,
db_obj: Album | Item,
nullable_fields: Sequence[str] = [],
):
"""Set the db_obj's metadata to match the info."""

View file

@ -26,9 +26,16 @@ import threading
import time
from abc import ABC
from collections import defaultdict
from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence
from collections.abc import (
Callable,
Generator,
Iterable,
Iterator,
Mapping,
Sequence,
)
from sqlite3 import Connection, sqlite_version_info
from typing import TYPE_CHECKING, Any, AnyStr, Callable, Generic
from typing import TYPE_CHECKING, Any, AnyStr, Generic
from typing_extensions import TypeVar # default value support
from unidecode import unidecode

View file

@ -15,7 +15,7 @@ from __future__ import annotations
import os
import time
from typing import TYPE_CHECKING, Sequence
from typing import TYPE_CHECKING
from beets import config, dbcore, library, logging, plugins, util
from beets.importer.tasks import Action
@ -25,6 +25,8 @@ from . import stages as stagefuncs
from .state import ImportState
if TYPE_CHECKING:
from collections.abc import Sequence
from beets.util import PathBytes
from .tasks import ImportTask

View file

@ -16,7 +16,7 @@ from __future__ import annotations
import itertools
import logging
from typing import TYPE_CHECKING, Callable
from typing import TYPE_CHECKING
from beets import config, plugins
from beets.util import MoveOperation, displayable_path, pipeline
@ -30,6 +30,8 @@ from .tasks import (
)
if TYPE_CHECKING:
from collections.abc import Callable
from beets import library
from .session import ImportSession

View file

@ -20,9 +20,10 @@ import re
import shutil
import time
from collections import defaultdict
from collections.abc import Callable, Iterable, Sequence
from enum import Enum
from tempfile import mkdtemp
from typing import TYPE_CHECKING, Any, Callable, Iterable, Sequence
from typing import TYPE_CHECKING, Any
import mediafile

View file

@ -37,7 +37,7 @@ from logging import (
RootLogger,
StreamHandler,
)
from typing import TYPE_CHECKING, Any, Mapping, TypeVar, Union, overload
from typing import TYPE_CHECKING, Any, TypeVar, Union, overload
__all__ = [
"DEBUG",
@ -54,6 +54,8 @@ __all__ = [
]
if TYPE_CHECKING:
from collections.abc import Mapping
T = TypeVar("T")
from types import TracebackType

View file

@ -10,7 +10,7 @@ from __future__ import annotations
import abc
import re
from functools import cache, cached_property
from typing import TYPE_CHECKING, Generic, Literal, Sequence, TypedDict, TypeVar
from typing import TYPE_CHECKING, Generic, Literal, TypedDict, TypeVar
import unidecode
from confuse import NotFoundError
@ -22,7 +22,7 @@ from beets.util.id_extractors import extract_release_id
from .plugins import BeetsPlugin, find_plugins, notify_info_yielded, send
if TYPE_CHECKING:
from collections.abc import Iterable
from collections.abc import Iterable, Sequence
from .autotag.hooks import AlbumInfo, Item, TrackInfo

View file

@ -25,7 +25,6 @@ from collections import defaultdict
from functools import cached_property, wraps
from importlib import import_module
from pathlib import Path
from types import GenericAlias
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar
import mediafile
@ -450,9 +449,6 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
for obj in reversed(namespace.__dict__.values()):
if (
inspect.isclass(obj)
and not isinstance(
obj, GenericAlias
) # seems to be needed for python <= 3.9 only
and issubclass(obj, BeetsPlugin)
and obj != BeetsPlugin
and not inspect.isabstract(obj)

View file

@ -32,7 +32,7 @@ import warnings
from difflib import SequenceMatcher
from functools import cache
from itertools import chain
from typing import Any, Callable, Literal
from typing import TYPE_CHECKING, Any, Literal
import confuse
@ -42,6 +42,9 @@ from beets.dbcore import query as db_query
from beets.util import as_string
from beets.util.functemplate import template
if TYPE_CHECKING:
from collections.abc import Callable
# On Windows platforms, use colorama to support "ANSI" terminal colors.
if sys.platform == "win32":
try:

View file

@ -29,7 +29,7 @@ import tempfile
import traceback
import warnings
from collections import Counter
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from contextlib import suppress
from enum import Enum
from functools import cache
@ -41,7 +41,6 @@ from typing import (
TYPE_CHECKING,
Any,
AnyStr,
Callable,
ClassVar,
Generic,
NamedTuple,

View file

@ -26,7 +26,7 @@ import subprocess
from abc import ABC, abstractmethod
from enum import Enum
from itertools import chain
from typing import Any, ClassVar, Mapping
from typing import TYPE_CHECKING, Any, ClassVar
from urllib.parse import urlencode
from beets import logging, util
@ -37,6 +37,9 @@ from beets.util import (
syspath,
)
if TYPE_CHECKING:
from collections.abc import Mapping
PROXY_URL = "https://images.weserv.nl/"
log = logging.getLogger("beets")

View file

@ -105,8 +105,6 @@ def compile_func(arg_names, statements, name="_the_func", debug=False):
decorator_list=[],
)
# The ast.Module signature changed in 3.8 to accept a list of types to
# ignore.
mod = ast.Module([func_def], [])
ast.fix_missing_locations(mod)

View file

@ -20,10 +20,9 @@ import os
import stat
import sys
from pathlib import Path
from typing import Union
def is_hidden(path: Union[bytes, Path]) -> bool:
def is_hidden(path: bytes | Path) -> bool:
"""
Determine whether the given path is treated as a 'hidden file' by the OS.
"""

View file

@ -36,10 +36,13 @@ from __future__ import annotations
import queue
import sys
from threading import Lock, Thread
from typing import Callable, Generator, TypeVar
from typing import TYPE_CHECKING, TypeVar
from typing_extensions import TypeVarTuple, Unpack
if TYPE_CHECKING:
from collections.abc import Callable, Generator
BUBBLE = "__PIPELINE_BUBBLE__"
POISON = "__PIPELINE_POISON__"

View file

@ -19,14 +19,7 @@ from __future__ import annotations
import json
import re
from datetime import datetime, timedelta
from typing import (
TYPE_CHECKING,
Iterable,
Iterator,
Literal,
Sequence,
overload,
)
from typing import TYPE_CHECKING, Literal, overload
import confuse
from requests_oauthlib import OAuth1Session
@ -42,6 +35,8 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.metadata_plugins import MetadataSourcePlugin
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Sequence
from beets.importer import ImportSession
from beets.library import Item

View file

@ -283,7 +283,7 @@ class BaseServer:
if not self.ctrl_sock:
self.ctrl_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.ctrl_sock.connect((self.ctrl_host, self.ctrl_port))
self.ctrl_sock.sendall((f"{message}\n").encode("utf-8"))
self.ctrl_sock.sendall((f"{message}\n").encode())
def _send_event(self, event):
"""Notify subscribed connections of an event."""

View file

@ -18,8 +18,8 @@ autotagger. Requires the pyacoustid library.
import re
from collections import defaultdict
from collections.abc import Iterable
from functools import cached_property, partial
from typing import Iterable
import acoustid
import confuse

View file

@ -18,7 +18,7 @@ from __future__ import annotations
import collections
import time
from typing import TYPE_CHECKING, Literal, Sequence
from typing import TYPE_CHECKING, Literal
import requests
@ -32,6 +32,8 @@ from beets.metadata_plugins import (
)
if TYPE_CHECKING:
from collections.abc import Sequence
from beets.library import Item, Library
from ._typing import JSONDict

View file

@ -27,7 +27,7 @@ import time
import traceback
from functools import cache
from string import ascii_lowercase
from typing import TYPE_CHECKING, Sequence, cast
from typing import TYPE_CHECKING, cast
import confuse
from discogs_client import Client, Master, Release
@ -43,7 +43,7 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.metadata_plugins import MetadataSourcePlugin
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from collections.abc import Callable, Iterable, Sequence
from beets.library import Item

View file

@ -23,7 +23,7 @@ from collections import OrderedDict
from contextlib import closing
from enum import Enum
from functools import cached_property
from typing import TYPE_CHECKING, AnyStr, ClassVar, Literal, Tuple, Type
from typing import TYPE_CHECKING, AnyStr, ClassVar, Literal
import confuse
import requests
@ -86,7 +86,7 @@ class Candidate:
path: None | bytes = None,
url: None | str = None,
match: None | MetadataMatch = None,
size: None | Tuple[int, int] = None,
size: None | tuple[int, int] = None,
):
self._log = log
self.path = path
@ -682,7 +682,7 @@ class GoogleImages(RemoteArtSource):
"""
if not (album.albumartist and album.album):
return
search_string = f"{album.albumartist},{album.album}".encode("utf-8")
search_string = f"{album.albumartist},{album.album}".encode()
try:
response = self.request(
@ -1293,7 +1293,7 @@ class CoverArtUrl(RemoteArtSource):
# All art sources. The order they will be tried in is specified by the config.
ART_SOURCES: set[Type[ArtSource]] = {
ART_SOURCES: set[type[ArtSource]] = {
FileSystem,
CoverArtArchive,
ITunesStore,

View file

@ -19,7 +19,7 @@ class ImportSourcePlugin(BeetsPlugin):
def __init__(self):
"""Initialize the plugin and read configuration."""
super(ImportSourcePlugin, self).__init__()
super().__init__()
self.config.add(
{
"suggest_removal": False,

View file

@ -28,7 +28,7 @@ import os
import traceback
from functools import singledispatchmethod
from pathlib import Path
from typing import TYPE_CHECKING, Union
from typing import TYPE_CHECKING
import pylast
import yaml
@ -352,7 +352,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
combined = old + new
return self._resolve_genres(combined)
def _get_genre(self, obj: LibModel) -> tuple[Union[str, None], ...]:
def _get_genre(self, obj: LibModel) -> tuple[str | None, ...]:
"""Get the final genre string for an Album or Item object.
`self.sources` specifies allowed genre sources. Starting with the first

View file

@ -28,7 +28,7 @@ from html import unescape
from http import HTTPStatus
from itertools import groupby
from pathlib import Path
from typing import TYPE_CHECKING, Iterable, Iterator, NamedTuple
from typing import TYPE_CHECKING, NamedTuple
from urllib.parse import quote, quote_plus, urlencode, urlparse
import langdetect
@ -42,6 +42,8 @@ from beets.autotag.distance import string_dist
from beets.util.config import sanitize_choices
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from beets.importer import ImportTask
from beets.library import Item, Library
from beets.logging import BeetsLogger as Logger

View file

@ -19,7 +19,7 @@ from __future__ import annotations
import itertools
import traceback
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Iterable, Sequence
from typing import TYPE_CHECKING, Any
import mediafile
import musicbrainzngs
@ -40,6 +40,8 @@ from beetsplug.musicbrainz import (
)
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from beets.autotag import AlbumMatch
from beets.library import Item
from beetsplug._typing import JSONDict

View file

@ -21,7 +21,7 @@ from collections import Counter
from contextlib import suppress
from functools import cached_property
from itertools import product
from typing import TYPE_CHECKING, Any, Iterable, Sequence
from typing import TYPE_CHECKING, Any
from urllib.parse import urljoin
import musicbrainzngs
@ -34,6 +34,7 @@ from beets.metadata_plugins import MetadataSourcePlugin
from beets.util.id_extractors import extract_release_id
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from typing import Literal
from beets.library import Item

View file

@ -28,7 +28,7 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass
from multiprocessing.pool import ThreadPool
from threading import Event, Thread
from typing import TYPE_CHECKING, Any, Callable, TypeVar
from typing import TYPE_CHECKING, Any, TypeVar
from beets import ui
from beets.plugins import BeetsPlugin
@ -36,7 +36,7 @@ from beets.util import command_output, displayable_path, syspath
if TYPE_CHECKING:
import optparse
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from logging import Logger
from confuse import ConfigView

View file

@ -27,7 +27,7 @@ import re
import threading
import time
import webbrowser
from typing import TYPE_CHECKING, Any, Literal, Sequence, Union
from typing import TYPE_CHECKING, Any, Literal, Union
import confuse
import requests
@ -43,6 +43,8 @@ from beets.metadata_plugins import (
)
if TYPE_CHECKING:
from collections.abc import Sequence
from beets.library import Library
from beetsplug._typing import JSONDict

View file

@ -7,6 +7,9 @@ below!
Unreleased
----------
Beets now requires Python 3.10 or later since support for EOL Python 3.9 has
been dropped.
New features:
- :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle.
@ -42,6 +45,8 @@ For plugin developers:
For packagers:
- The minimum supported Python version is now 3.10.
Other changes:
- The documentation chapter :doc:`dev/paths` has been moved to the "For

View file

@ -1,10 +1,10 @@
Installation
============
Beets requires `Python 3.9 or later`_. You can install it using package
Beets requires `Python 3.10 or later`_. You can install it using package
managers, pipx_, pip_ or by using package managers.
.. _python 3.9 or later: https://python.org/download/
.. _python 3.10 or later: https://python.org/download/
Using ``pipx`` or ``pip``
-------------------------

View file

@ -6,18 +6,18 @@ from __future__ import annotations
import re
import subprocess
from collections.abc import Callable
from contextlib import redirect_stdout
from datetime import datetime, timezone
from functools import partial
from io import StringIO
from pathlib import Path
from typing import Callable, NamedTuple
from typing import NamedTuple, TypeAlias
import click
import tomli
from packaging.version import Version, parse
from sphinx.ext import intersphinx
from typing_extensions import TypeAlias
from docs.conf import rst_epilog

2948
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -17,7 +17,6 @@ classifiers = [
"Environment :: Web Environment",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
@ -42,7 +41,7 @@ Changelog = "https://github.com/beetbox/beets/blob/master/docs/changelog.rst"
"Bug Tracker" = "https://github.com/beetbox/beets/issues"
[tool.poetry.dependencies]
python = ">=3.9,<4"
python = ">=3.10,<4"
colorama = { version = "*", markers = "sys_platform == 'win32'" }
confuse = ">=2.1.0"
@ -228,7 +227,7 @@ cmd = "ruff format"
[tool.poe.tasks.format-docs]
help = "Format the documentation"
cmd = "docstrfmt"
cmd = "docstrfmt docs *.rst"
[tool.poe.tasks.lint]
help = "Check the code for linting issues. Accepts ruff options."
@ -286,7 +285,6 @@ extend-exclude = [
"docs/api/**/*",
"README_kr.rst",
]
files = ["docs", "*.rst"]
[tool.ruff]
target-version = "py39"
@ -305,9 +303,7 @@ select = [
"N", # pep8-naming
"PT", # flake8-pytest-style
# "RUF", # ruff
# "UP", # pyupgrade
"UP031", # do not use percent formatting
"UP032", # use f-string instead of format call
"UP", # pyupgrade
"TCH", # flake8-type-checking
"W", # pycodestyle
]

View file

@ -1,7 +1,7 @@
import os
from http import HTTPStatus
from pathlib import Path
from typing import Any, Optional
from typing import Any
import pytest
from flask.testing import Client
@ -58,9 +58,7 @@ class TestAuraResponse:
def get_response_data(self, client: Client, item):
"""Return a callback accepting `endpoint` and `params` parameters."""
def get(
endpoint: str, params: dict[str, str]
) -> Optional[dict[str, Any]]:
def get(endpoint: str, params: dict[str, str]) -> dict[str, Any] | None:
"""Add additional `params` and GET the given endpoint.
`include` parameter is added to every call to check that the

View file

@ -14,7 +14,7 @@
"""Tests for the 'ftintitle' plugin."""
from typing import Dict, Generator, Optional, Tuple, Union
from collections.abc import Generator
import pytest
@ -39,7 +39,7 @@ def env() -> Generator[FtInTitlePluginFunctional, None, None]:
def set_config(
env: FtInTitlePluginFunctional,
cfg: Optional[Dict[str, Union[str, bool, list[str]]]],
cfg: dict[str, str | bool | list[str]] | None,
) -> None:
cfg = {} if cfg is None else cfg
defaults = {
@ -57,7 +57,7 @@ def add_item(
path: str,
artist: str,
title: str,
albumartist: Optional[str],
albumartist: str | None,
) -> Item:
return env.add_item(
path=path,
@ -250,10 +250,10 @@ def add_item(
)
def test_ftintitle_functional(
env: FtInTitlePluginFunctional,
cfg: Optional[Dict[str, Union[str, bool, list[str]]]],
cmd_args: Tuple[str, ...],
given: Tuple[str, str, Optional[str]],
expected: Tuple[str, str],
cfg: dict[str, str | bool | list[str]] | None,
cmd_args: tuple[str, ...],
given: tuple[str, str, str | None],
expected: tuple[str, str],
) -> None:
set_config(env, cfg)
ftintitle.FtInTitlePlugin()
@ -287,7 +287,7 @@ def test_ftintitle_functional(
def test_find_feat_part(
artist: str,
albumartist: str,
expected: Optional[str],
expected: str | None,
) -> None:
assert ftintitle.find_feat_part(artist, albumartist) == expected
@ -307,7 +307,7 @@ def test_find_feat_part(
)
def test_split_on_feat(
given: str,
expected: Tuple[str, Optional[str]],
expected: tuple[str, str | None],
) -> None:
assert ftintitle.split_on_feat(given) == expected
@ -359,7 +359,7 @@ def test_contains_feat(given: str, expected: bool) -> None:
],
)
def test_custom_words(
given: str, custom_words: Optional[list[str]], expected: bool
given: str, custom_words: list[str] | None, expected: bool
) -> None:
if custom_words is None:
custom_words = []

View file

@ -19,13 +19,13 @@ import os
import sys
import unittest
from contextlib import contextmanager
from typing import TYPE_CHECKING, Callable
from typing import TYPE_CHECKING
from beets import plugins
from beets.test.helper import PluginTestCase, capture_log
if TYPE_CHECKING:
from collections.abc import Iterator
from collections.abc import Callable, Iterator
class HookTestCase(PluginTestCase):

View file

@ -1,6 +1,5 @@
import datetime
import os
import os.path
from beets.library import Album, Item
from beets.test.helper import PluginTestCase

View file

@ -14,7 +14,7 @@
"""Various tests for querying the library database."""
from mock import patch
from unittest.mock import patch
import beets.library
from beets import config, dbcore