From 6b86a9072fb1136f3315af5635066359d98c171f Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 6 Jun 2025 08:40:51 +0200 Subject: [PATCH] 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. --- qutebrowser/utils/version.py | 15 ++++++++--- tests/unit/utils/test_version.py | 45 +++++++++++++++++++++----------- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index 854268eaf..1e2e12ab6 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -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"), diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index 91d737dd2..11bada4fa 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -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),