From af17b48e72df0e470bc69bf3d35eb5ad4c226986 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 16 Oct 2026 12:53:42 +1100 Subject: [PATCH 1/3] fix(ffi): keep `ffi` and `__version__` bound under lazy imports On Python 3.15 with `PYTHON_LAZY_IMPORTS=all`, `pact_ffi.ffi` resolved to the `pact_ffi.ffi` extension module instead of the cffi `FFI` object, so the first FFI call raised `AttributeError: module 'pact_ffi.ffi' has no attribute 'NULL'`. `pact_ffi.__version__` did the same when `__version_tuple__` was read first. Both submodules share their names with attributes the package re-exports. Under PEP 810, `from pact_ffi.ffi import ffi` binds `ffi` lazily. Loading the submodule later sets the package attribute `ffi` to the module, replacing the object. The package now imports each submodule under an alias and assigns the attributes from it, so the binding happens after the submodule has loaded. Assisted-by: Claude Code:claude-opus-5-5 --- pact-python-ffi/src/pact_ffi/__init__.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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 From a2a12e17132aeb94dd06e3ea0e1543492a8d99df Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 16 Oct 2026 12:53:52 +1100 Subject: [PATCH 2/3] fix: keep `pact` attributes bound under lazy imports On Python 3.15 with `PYTHON_LAZY_IMPORTS=all`, two of the attributes `pact` documents break. - `pact.match` and `pact.generate` raised `AttributeError`. `pact/__init__.py` never imported them: they became attributes only as a side effect of `pact.xml` importing them, and a lazy import has no side effects until it is used. They are now imported explicitly. - `pact.__version__` resolved to the `pact.__version__` submodule when `__version_tuple__` was read first, because the submodule shares the attribute's name. The package now imports the submodule under an alias and assigns both attributes from it, as `pact_ffi` does. Without lazy imports, every module involved was already imported, so the explicit imports add no cost. Assisted-by: Claude Code:claude-opus-5-5 --- src/pact/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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" From f0424d8d40b4ac353321d94ab7855abc1142937b Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 16 Oct 2026 12:53:53 +1100 Subject: [PATCH 3/3] test: run the suite with lazy Pact imports on Python 3.15 `tests/test_lazy_imports.py` checks, in a subprocess with `-X lazy_imports=all`, that the public attributes of `pact` and `pact_ffi` keep their types and that an FFI call works. It reads `__version_tuple__` first, the access order that exposed the `__version__` collision. pytest cannot run with `PYTHON_LAZY_IMPORTS=all`: its own lazily imported `fnmatch` raises `ImportCycleError`. Setting `PACT_TEST_LAZY_IMPORTS` instead makes only imports issued by `pact` and `pact_ffi` modules lazy. A new CI step runs the suite that way on Python 3.15 on Linux. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/test.yml | 6 ++++ tests/conftest.py | 12 ++++++++ tests/test_lazy_imports.py | 56 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 tests/test_lazy_imports.py 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/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()