diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2b02a812f..43e921ebf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -107,6 +107,12 @@ jobs: working-directory: pact-python-ffi run: hatch run test.py${{ matrix.python-version }}:test --junit-xml=junit.xml --cov-report=xml:coverage-ffi.xml + - name: Run tests (lazy imports) + if: matrix.python-version == '3.15' && matrix.os == 'ubuntu-latest' + env: + PACT_TEST_LAZY_IMPORTS: '1' + run: hatch run test.py${{ matrix.python-version }}:test --no-cov + - name: Upload coverage if: matrix.python-version == env.STABLE_PYTHON_VERSION && matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 diff --git a/pact-python-ffi/src/pact_ffi/__init__.py b/pact-python-ffi/src/pact_ffi/__init__.py index dc740ade9..3a3bbbf50 100644 --- a/pact-python-ffi/src/pact_ffi/__init__.py +++ b/pact-python-ffi/src/pact_ffi/__init__.py @@ -96,9 +96,17 @@ from enum import Enum from typing import TYPE_CHECKING, Any, Literal -from pact_ffi.__version__ import __version__ as __version__ -from pact_ffi.__version__ import __version_tuple__ as __version_tuple__ -from pact_ffi.ffi import ffi, lib # type: ignore[import] +# The `__version__` and `ffi` submodules share their names with attributes of +# this package. Under PEP 810 lazy imports, a submodule loaded after its +# attribute is bound replaces that attribute. Assigning from an aliased module +# binds the attribute after the submodule has loaded. +import pact_ffi.__version__ as _version_module +import pact_ffi.ffi as _ffi_module # type: ignore[import] + +__version__ = _version_module.__version__ +__version_tuple__ = _version_module.__version_tuple__ +ffi = _ffi_module.ffi +lib = _ffi_module.lib if TYPE_CHECKING: import datetime as dt diff --git a/src/pact/__init__.py b/src/pact/__init__.py index a84f9ab38..447952fd3 100644 --- a/src/pact/__init__.py +++ b/src/pact/__init__.py @@ -111,11 +111,20 @@ from __future__ import annotations +# The `__version__` submodule shares its name with the attribute. Under PEP 810 +# lazy imports, a submodule loaded after its attribute is bound replaces that +# attribute. Assigning from an aliased module binds the attribute after the +# submodule has loaded. +import pact.__version__ as _version_module +from pact import generate as generate +from pact import match as match from pact import xml as xml -from pact.__version__ import __version__, __version_tuple__ from pact.pact import Pact from pact.verifier import Verifier +__version__ = _version_module.__version__ +__version_tuple__ = _version_module.__version_tuple__ + __url__ = "https://github.com/pact-foundation/pact-python" __license__ = "MIT" __author__ = "Pact Foundation" diff --git a/tests/conftest.py b/tests/conftest.py index f41ae4845..9fe7c2f15 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,9 +8,21 @@ from __future__ import annotations import json +import os +import sys import tempfile from pathlib import Path +if sys.version_info >= (3, 15) and os.environ.get("PACT_TEST_LAZY_IMPORTS"): + # Approximates a user running with `PYTHON_LAZY_IMPORTS=all`, which pytest + # itself does not support: only imports made by Pact's modules are lazy. + sys.set_lazy_imports("all") + sys.set_lazy_imports_filter( + lambda importer, _name, _fromlist: ( + importer is not None and importer.split(".")[0] in {"pact", "pact_ffi"} + ) + ) + import pytest import pact_ffi diff --git a/tests/test_lazy_imports.py b/tests/test_lazy_imports.py new file mode 100644 index 000000000..5a9f5900a --- /dev/null +++ b/tests/test_lazy_imports.py @@ -0,0 +1,56 @@ +""" +Tests for compatibility with PEP 810 lazy imports. +""" + +from __future__ import annotations + +import subprocess +import sys + +import pytest + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 15), + reason="Lazy imports require Python 3.15", +) + + +@pytest.mark.parametrize( + ("expression", "expected"), + [ + pytest.param("pact_ffi.ffi", "FFI", id="pact_ffi.ffi"), + pytest.param("pact_ffi.lib", "Lib", id="pact_ffi.lib"), + pytest.param("pact_ffi.__version__", "str", id="pact_ffi.__version__"), + pytest.param("pact.__version__", "str", id="pact.__version__"), + pytest.param("pact.match", "module", id="pact.match"), + pytest.param("pact.generate", "module", id="pact.generate"), + pytest.param("pact.xml", "module", id="pact.xml"), + ], +) +def test_attribute_under_lazy_imports(expression: str, expected: str) -> None: + # Reading `__version_tuple__` first loads the `__version__` submodule + # before `__version__` itself is resolved, which is the order that once + # replaced the attribute with the submodule. + code = ( + "import pact, pact_ffi; " + "pact.__version_tuple__; pact_ffi.__version_tuple__; " + f"print(type({expression}).__name__)" + ) + result = subprocess.run( # noqa: S603 + [sys.executable, "-X", "lazy_imports=all", "-c", code], + capture_output=True, + text=True, + check=True, + ) + assert result.stdout.strip() == expected + + +def test_ffi_call_under_lazy_imports() -> None: + code = "import pact_ffi; print(pact_ffi.version())" + result = subprocess.run( # noqa: S603 + [sys.executable, "-X", "lazy_imports=all", "-c", code], + capture_output=True, + text=True, + check=True, + ) + assert result.stdout.strip()