version: Rely on importlib.metadata.version too

Packages are slowly migrating to not having a __version__ attribute anymore,
instead relying on importlib.metadata to query the installed version.

jinja2 now shows a deprecation warning when accessing the __version__
attribute: https://github.com/pallets/jinja/pull/2098

For now we keep accessing __version__ for other packages (we still need the
logic for PyQt and its special version attributes anyways), but we fall back on
importlib.metadata.version if we can't get a version that way, and we stop
trying __version__ for jinja2.
This commit is contained in:
Florian Bruhin 2025-06-06 08:40:51 +02:00
parent 96e535c7ed
commit 6b86a9072f
2 changed files with 41 additions and 19 deletions

View File

@ -322,8 +322,8 @@ class ModuleInfo:
except (ImportError, ValueError):
self._installed = False
return
else:
self._installed = True
self._installed = True
for attribute_name in self._version_attributes:
if hasattr(module, attribute_name):
@ -332,6 +332,13 @@ class ModuleInfo:
self._version = str(version)
break
if self._version is None:
try:
self._version = importlib.metadata.version(self.name)
except importlib.metadata.PackageNotFoundError:
log.misc.debug(f"{self.name} not found")
self._version = None
self._initialized = True
def get_version(self) -> Optional[str]:
@ -372,7 +379,7 @@ class ModuleInfo:
version = self.get_version()
if version is None:
return f'{self.name}: yes'
return f'{self.name}: unknown'
text = f'{self.name}: {version}'
if self.is_outdated():
@ -383,7 +390,7 @@ class ModuleInfo:
def _create_module_info() -> dict[str, ModuleInfo]:
packages = [
('colorama', ['VERSION', '__version__']),
('jinja2', ['__version__']),
('jinja2', []),
('pygments', ['__version__']),
('yaml', ['__version__']),
('adblock', ['__version__'], "0.3.2"),

View File

@ -15,8 +15,10 @@ import textwrap
import datetime
import dataclasses
import importlib.metadata
import unittest.mock
import pytest
import pytest_mock
import hypothesis
import hypothesis.strategies
from qutebrowser.qt import machinery
@ -620,27 +622,32 @@ def test_path_info(monkeypatch, equal):
assert pathinfo['system data'] == 'SYSTEM DATA PATH'
@pytest.fixture
def import_fake(stubs, monkeypatch):
"""Fixture to patch imports using ImportFake."""
fake = stubs.ImportFake(dict.fromkeys(version.MODULE_INFO, True), monkeypatch)
fake.patch()
return fake
class TestModuleVersions:
"""Tests for _module_versions() and ModuleInfo."""
@pytest.fixture
def import_fake(self, stubs, monkeypatch):
"""Fixture to patch imports using ImportFake."""
fake = stubs.ImportFake(dict.fromkeys(version.MODULE_INFO, True), monkeypatch)
fake.patch()
return fake
@pytest.fixture(autouse=True)
def importlib_metadata_mock(
self, mocker: pytest_mock.MockerFixture
) -> unittest.mock.Mock:
return mocker.patch("importlib.metadata.version", return_value="4.5.6")
def test_all_present(self, import_fake):
"""Test with all modules present in version 1.2.3."""
"""Test with all modules present in a fixed version."""
expected = []
for name in import_fake.modules:
version.MODULE_INFO[name]._reset_cache()
if '__version__' not in version.MODULE_INFO[name]._version_attributes:
expected.append('{}: yes'.format(name))
expected.append(f"{name}: 4.5.6") # from importlib.metadata
else:
expected.append('{}: 1.2.3'.format(name))
expected.append(f"{name}: 1.2.3")
assert version._module_versions() == expected
@pytest.mark.parametrize('module, idx, expected', [
@ -695,6 +702,14 @@ class TestModuleVersions:
expected = f"adblock: {fake_version} (< {mod_info.min_version}, outdated)"
assert version._module_versions()[4] == expected
def test_importlib_not_found(self, importlib_metadata_mock: unittest.mock.Mock):
"""Test with no __version__ attribute and missing importlib.metadata."""
assert not version.MODULE_INFO["jinja2"]._version_attributes # sanity check
importlib_metadata_mock.side_effect = importlib.metadata.PackageNotFoundError
version.MODULE_INFO["jinja2"]._reset_cache()
idx = list(version.MODULE_INFO).index("jinja2")
assert version._module_versions()[idx] == "jinja2: unknown"
@pytest.mark.parametrize('attribute, expected_modules', [
('VERSION', ['colorama']),
('SIP_VERSION_STR', ['PyQt5.sip', 'PyQt6.sip']),
@ -722,17 +737,17 @@ class TestModuleVersions:
mod_info = version.MODULE_INFO[name]
if name in expected_modules:
assert mod_info.get_version() == "1.2.3"
expected.append('{}: 1.2.3'.format(name))
expected.append(f"{name}: 1.2.3")
else:
assert mod_info.get_version() is None
expected.append('{}: yes'.format(name))
assert mod_info.get_version() == "4.5.6" # from importlib.metadata
expected.append(f"{name}: 4.5.6")
assert version._module_versions() == expected
@pytest.mark.parametrize('name, has_version', [
('sip', False),
('colorama', True),
('jinja2', True),
# jinja2: removed in 3.3
('pygments', True),
('yaml', True),
('adblock', True),