From 36d68a3946bd5829ce03c67173e4425f1adea717 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Mon, 25 Sep 2023 14:59:24 +1000 Subject: [PATCH 1/6] chore(build): update packaging to build ffi As we will transition to using the Rust Pact library, we need to download it as part of the build process. This commit adds a step to the build process to download the library and extract it to the correct location and then build the Python bindings. As the Rust library is available for more platforms than the Ruby executables, failing to find the Ruby executables is no longer a fatal error and will instead be raised as a warning during the build process. So small adjustments to the build script were made to accommodate this change. Signed-off-by: JP-Ellis --- hatch_build.py | 398 +++++++++++++++++++++++++++++++++++++++++-------- pyproject.toml | 12 +- 2 files changed, 340 insertions(+), 70 deletions(-) diff --git a/hatch_build.py b/hatch_build.py index 630fab65e..de4409ea6 100644 --- a/hatch_build.py +++ b/hatch_build.py @@ -1,34 +1,57 @@ -"""Hatchling build hook for Pact binary download.""" +""" +Hatchling build hook for binary downloads. + +Pact Python is built on top of the Ruby Pact binaries and the Rust Pact library. +This build script downloads the binaries and library for the current platform +and installs them in the `pact` directory under `/bin` and `/lib`. + +The version of the binaries and library can be controlled with the +`PACT_BIN_VERSION` and `PACT_LIB_VERSION` environment variables. If these are +not set, a pinned version will be used instead. +""" from __future__ import annotations +import gzip import os import shutil -import typing +import tarfile +import tempfile +import warnings +import zipfile from pathlib import Path from typing import Any, Dict +import cffi +import requests from hatchling.builders.hooks.plugin.interface import BuildHookInterface from packaging.tags import sys_tags ROOT_DIR = Path(__file__).parent.resolve() -PACT_VERSION = "2.0.7" -PACT_URL = "https://github.com/pact-foundation/pact-ruby-standalone/releases/download/v{version}/pact-{version}-{os}-{machine}.{ext}" -PACT_DISTRIBUTIONS: list[tuple[str, str, str]] = [ - ("linux", "arm64", "tar.gz"), - ("linux", "x86_64", "tar.gz"), - ("osx", "arm64", "tar.gz"), - ("osx", "x86_64", "tar.gz"), - ("windows", "x86", "zip"), - ("windows", "x86_64", "zip"), -] - - -class PactBuildHook(BuildHookInterface): + +PACT_BIN_VERSION = os.getenv("PACT_BIN_VERSION", "2.0.7") +PACT_BIN_URL = "https://github.com/pact-foundation/pact-ruby-standalone/releases/download/v{version}/pact-{version}-{os}-{machine}.{ext}" + +PACT_LIB_VERSION = os.getenv("PACT_LIB_VERSION", "0.4.9") +PACT_LIB_URL = "https://github.com/pact-foundation/pact-reference/releases/download/libpact_ffi-v{version}/{prefix}pact_ffi-{os}-{machine}.{ext}" + + +class PactBuildHook(BuildHookInterface[Any]): """Custom hook to download Pact binaries.""" PLUGIN_NAME = "custom" + def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 + """ + Initialize the build hook. + + For this hook, we additionally define the lib extension based on the + current platform. + """ + super().__init__(*args, **kwargs) + self.tmpdir = Path(tempfile.TemporaryDirectory().name) + self.tmpdir.mkdir(parents=True, exist_ok=True) + def clean(self, versions: list[str]) -> None: # noqa: ARG002 """Clean up any files created by the build hook.""" for subdir in ["bin", "lib", "data"]: @@ -43,10 +66,10 @@ def initialize( build_data["infer_tag"] = True build_data["pure_python"] = False - pact_version = os.getenv("PACT_VERSION", PACT_VERSION) - self.install_pact_binaries(pact_version) + self.pact_bin_install(PACT_BIN_VERSION) + self.pact_lib_install(PACT_LIB_VERSION) - def install_pact_binaries(self, version: str) -> None: # noqa: PLR0912 + def pact_bin_install(self, version: str) -> None: """ Install the Pact standalone binaries. @@ -54,10 +77,30 @@ def install_pact_binaries(self, version: str) -> None: # noqa: PLR0912 the current operating system is determined automatically. Args: - version: The Pact version to install. Defaults to the value in - `PACT_VERSION`. + version: The Pact version to install. + """ + url = self._pact_bin_url(version) + if url: + artifact = self._download(url) + self._pact_bin_extract(artifact) + + def _pact_bin_url(self, version: str) -> str | None: # noqa: PLR0911 """ - platform = typing.cast(str, next(sys_tags()).platform) + Generate the download URL for the Pact binaries. + + Generate the download URL for the Pact binaries based on the current + platform and specified version. This function mainly contains a lot of + matching logic to determine the correct URL to use, due to the + inconsistencies in naming conventions between ecosystems. + + Args: + version: The upstream Pact version. + + Returns: + The URL to download the Pact binaries from, or None if the current + platform is not supported. + """ + platform = next(sys_tags()).platform if platform.startswith("macosx"): os = "osx" @@ -67,81 +110,314 @@ def install_pact_binaries(self, version: str) -> None: # noqa: PLR0912 machine = "x86_64" else: msg = f"Unknown macOS machine {platform}" - raise ValueError(msg) - url = PACT_URL.format(version=version, os=os, machine=machine, ext="tar.gz") + warnings.warn(msg, RuntimeWarning, stacklevel=2) + return None + return PACT_BIN_URL.format( + version=version, + os=os, + machine=machine, + ext="tar.gz", + ) - elif platform.startswith("win"): + if platform.startswith("win"): os = "windows" if platform.endswith("amd64"): machine = "x86_64" elif platform.endswith(("x86", "win32")): machine = "x86" + else: + msg = f"Unknown Windows machine {platform}" + warnings.warn(msg, RuntimeWarning, stacklevel=2) + return None + return PACT_BIN_URL.format( + version=version, + os=os, + machine=machine, + ext="zip", + ) + + if "linux" in platform and "musl" not in platform: + os = "linux" + if platform.endswith("x86_64"): + machine = "x86_64" + elif platform.endswith("aarch64"): + machine = "arm64" + else: + msg = f"Unknown Linux machine {platform}" + warnings.warn(msg, RuntimeWarning, stacklevel=2) + return None + return PACT_BIN_URL.format( + version=version, + os=os, + machine=machine, + ext="tar.gz", + ) + + msg = f"Unknown platform {platform}" + warnings.warn(msg, RuntimeWarning, stacklevel=2) + return None + + def _pact_bin_extract(self, artifact: Path) -> None: + """ + Extract the Pact binaries. + + The upstream distributables contain a lot of files which are not needed + for this library. This function ensures that only the files in + `pact/bin` are extracted to avoid unnecessary bloat. + + Args: + artifact: The path to the downloaded artifact. + """ + (ROOT_DIR / "pact" / "bin").mkdir(parents=True, exist_ok=True) + + if str(artifact).endswith(".zip"): + with zipfile.ZipFile(artifact) as f: + for member in f.namelist(): + if member.startswith("pact/bin"): + f.extract(member, ROOT_DIR) + + if str(artifact).endswith(".tar.gz"): + with tarfile.open(artifact) as f: + for member in f.getmembers(): + if member.name.startswith("pact/bin"): + f.extract(member, ROOT_DIR) + + def pact_lib_install(self, version: str) -> None: + """ + Install the Pact library binary. + + The library is installed in `pact/lib`, and the relevant version for + the current operating system is determined automatically. + + Args: + version: The Pact version to install. + """ + url = self._pact_lib_url(version) + artifact = self._download(url) + self._pact_lib_extract(artifact) + includes = self._pact_lib_header(url) + self._pact_lib_cffi(includes) + + def _pact_lib_url(self, version: str) -> str: # noqa: C901, PLR0912 + """ + Generate the download URL for the Pact library. + + Generate the download URL for the Pact library based on the current + platform and specified version. This function mainly contains a lot of + matching logic to determine the correct URL to use, due to the + inconsistencies in naming conventions between ecosystems. + + Args: + version: The upstream Pact version. + + Returns: + The URL to download the Pact library from. + + Raises: + ValueError: + If the current platform is not supported. + """ + platform = next(sys_tags()).platform + + if platform.startswith("macosx"): + os = "osx" + if platform.endswith("arm64"): + machine = "aarch64-apple-darwin" + elif platform.endswith("x86_64"): + machine = "x86_64" + else: + msg = f"Unknown macOS machine {platform}" + raise ValueError(msg) + return PACT_LIB_URL.format( + prefix="lib", + version=version, + os=os, + machine=machine, + ext="a.gz", + ) + + if platform.startswith("win"): + os = "windows" + + if platform.endswith("amd64"): + machine = "x86_64" else: msg = f"Unknown Windows machine {platform}" raise ValueError(msg) + return PACT_LIB_URL.format( + prefix="", + version=version, + os=os, + machine=machine, + ext="lib.gz", + ) - url = PACT_URL.format(version=version, os=os, machine=machine, ext="zip") + if "linux" in platform and "musl" in platform: + os = "linux" + if platform.endswith("x86_64"): + machine = "x86_64-musl" + else: + msg = f"Unknown MUSL Linux machine {platform}" + raise ValueError(msg) + return PACT_LIB_URL.format( + prefix="lib", + version=version, + os=os, + machine=machine, + ext="a.gz", + ) - elif "linux" in platform: + if "linux" in platform: os = "linux" if platform.endswith("x86_64"): machine = "x86_64" elif platform.endswith("aarch64"): - machine = "arm64" + machine = "aarch64" else: msg = f"Unknown Linux machine {platform}" raise ValueError(msg) - url = PACT_URL.format(version=version, os=os, machine=machine, ext="tar.gz") + return PACT_LIB_URL.format( + prefix="lib", + version=version, + os=os, + machine=machine, + ext="a.gz", + ) + + msg = f"Unknown platform {platform}" + raise ValueError(msg) - else: - msg = f"Unknown platform {platform}" + def _pact_lib_extract(self, artifact: Path) -> None: + """ + Extract the Pact library. + + Extract the Pact library from the downloaded artifact and place it in + `pact/lib`. + + Args: + artifact: The URL to download the Pact binaries from. + """ + if not str(artifact).endswith(".gz"): + msg = f"Unknown artifact type {artifact}" raise ValueError(msg) - self.download_and_extract_pact(url) + with gzip.open(artifact, "rb") as f_in, ( + self.tmpdir / (artifact.name.split("-")[0] + artifact.suffixes[0]) + ).open("wb") as f_out: + shutil.copyfileobj(f_in, f_out) + + def _pact_lib_header(self, url: str) -> list[str]: + """ + Download the Pact library header. + + Download the Pact library header from GitHub and place it in + `pact/include`. This uses the same URL as for the artifact, replacing + the final segment with `pact.h`. + + This also processes the header to strip out elements which are not + supported by CFFI (i.e., any line starting with `#`). The list of + `#include` statements is returned for use in the CFFI bindings. + + Args: + url: The URL pointing to the Pact library artifact. + """ + url = url.rsplit("/", 1)[0] + "/pact.h" + artifact = self._download(url) + includes: list[str] = [] + with artifact.open("r", encoding="utf-8") as f_in, ( + self.tmpdir / "pact.h" + ).open("w", encoding="utf-8") as f_out: + for line in f_in: + sline = line.strip() + if sline.startswith("#include"): + includes.append(sline) + continue + if sline.startswith("#"): + continue + + f_out.write(line) + return includes + + def _pact_lib_cffi(self, includes: list[str]) -> None: + """ + Build the CFFI bindings for the Pact library. + + This will build the CFFI bindings for the Pact library and place them in + `pact/lib`. + + A list of additional `#include` statements can be passed to this + function, which will be included in the generated bindings. - def download_and_extract_pact(self, url: str) -> None: + Args: + includes: + A list of additional `#include` statements to include in the + generated bindings. """ - Download and extract the Pact binaries. + if os.name == "nt": + extra_libs = [ + "advapi32", + "bcrypt", + "crypt32", + "iphlpapi", + "ncrypt", + "netapi32", + "ntdll", + "ole32", + "oleaut32", + "pdh", + "powrprof", + "psapi", + "secur32", + "shell32", + "user32", + "userenv", + "ws2_32", + ] + else: + extra_libs = [] - If the download artifact is already present, it will be used instead of - downloading it again. + ffibuilder = cffi.FFI() + with (self.tmpdir / "pact.h").open( + "r", + encoding="utf-8", + ) as f: + ffibuilder.cdef(f.read()) + ffibuilder.set_source( + "_ffi", + "\n".join([*includes, '#include "pact.h"']), + libraries=["pact_ffi", *extra_libs], + library_dirs=[str(self.tmpdir)], + ) + output = ffibuilder.compile(verbose=True, tmpdir=str(self.tmpdir)) + shutil.copy(output, ROOT_DIR / "pact" / "v3") + + def _download(self, url: str) -> Path: + """ + Download the target URL. + + This will download the target URL to the `pact/data` directory. If the + download artifact is already present, its path will be returned. Args: - url: The URL to download the Pact binaries from. + url: The URL to download + + Return: + The path to the downloaded artifact. """ filename = url.split("/")[-1] artifact = ROOT_DIR / "pact" / "data" / filename artifact.parent.mkdir(parents=True, exist_ok=True) - if not filename.endswith((".zip", ".tar.gz")): - msg = f"Unknown artifact type {filename}" - raise ValueError(msg) - if not artifact.exists(): - import requests - response = requests.get(url, timeout=30) - response.raise_for_status() + try: + response.raise_for_status() + except requests.HTTPError as e: + msg = f"Failed to download from {url}." + raise RuntimeError(msg) from e with artifact.open("wb") as f: f.write(response.content) - if filename.endswith(".zip"): - import zipfile - - with zipfile.ZipFile(artifact) as f: - f.extractall(ROOT_DIR) - if filename.endswith(".tar.gz"): - import tarfile - - with tarfile.open(artifact) as f: - f.extractall(ROOT_DIR) - - # Move the README that is extracted from the Ruby standalone binaries to - # the `data` subdirectory. - if (ROOT_DIR / "pact" / "README.md").exists(): - shutil.move( - ROOT_DIR / "pact" / "README.md", - ROOT_DIR / "pact" / "data" / "README.md", - ) + return artifact diff --git a/pyproject.toml b/pyproject.toml index 2fff1c825..c059fc2d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ dev = [ ################################################################################ [build-system] -requires = ["hatchling", "packaging", "requests"] +requires = ["hatchling", "packaging", "requests", "cffi"] build-backend = "hatchling.build" [tool.hatch.version] @@ -78,17 +78,11 @@ path = "pact/__version__.py" [tool.hatch.build] include = ["pact/**/*.py", "*.md", "LICENSE"] -artifacts = ["pact/bin/*", "pact/data/*"] - -[tool.hatch.build.targets.sdist] -# Ignore binaries in the source distribution, but include the data files -# so that they can be installed from the source distribution. -exclude = ["pact/bin/*"] [tool.hatch.build.targets.wheel] # Ignore the data files in the wheel as their contents are already included # in the package. -exclude = ["pact/data/*"] +artifacts = ["pact/bin/*", "pact/lib/*"] [tool.hatch.build.targets.wheel.hooks.custom] @@ -100,7 +94,7 @@ exclude = ["pact/data/*"] # workflow. [tool.hatch.envs.default] features = ["dev"] -extra-dependencies = ["hatchling", "packaging", "requests"] +extra-dependencies = ["hatchling", "packaging", "requests", "cffi"] [tool.hatch.envs.default.scripts] lint = ["black --check --diff {args:.}", "ruff {args:.}", "mypy {args:.}"] From 238b5817d814d942e41b604418622aea9e0e6751 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Mon, 25 Sep 2023 15:26:20 +1000 Subject: [PATCH 2/6] style!: refactor constants With the possibility of building wheels against systems for which no Ruby executables exist, the constants module has been refactored to allow for the use of system installed Pact executables. This also introduces the `PACT_USE_SYSTEM_BINS` environment variable which can be used to force the use of system installed Pact executables. In doing these changes, the module has been refactored to avoid redundancies, and avoids the complexities of Windows executables extensions by using the `shutil.which` function. The test was refactored accordingly to test the constants instead of the functions. BREAKING CHANGE: The public functions within the constants module have been removed. If you previously used them, please make use of the constants. For example, instead of `pact.constants.broker_client_exe()` use `pact.constants.BROKER_CLIENT_PATH` instead. BREAKING CHANGE: It is possible to use the system installed Pact executables by setting `PACT_USE_SYSTEM_BINS` to `True` or `Yes` (case insensitive). Signed-off-by: JP-Ellis --- pact/constants.py | 92 +++++++++++++++++++++++++---------------- tests/test_constants.py | 92 +++++++++++++++++------------------------ 2 files changed, 95 insertions(+), 89 deletions(-) diff --git a/pact/constants.py b/pact/constants.py index 225d0a528..f29805940 100644 --- a/pact/constants.py +++ b/pact/constants.py @@ -1,38 +1,60 @@ -"""Constant values for the pact-python package.""" +""" +Constant values for the pact-python package. + +This will default to the bundled Pact binaries bundled with the package, but +should these be unavailable or the environment variable `PACT_USE_SYSTEM_BINS` is +set to `TRUE` or `YES`, the system Pact binaries will be used instead. +""" import os +import shutil +import warnings from pathlib import Path - -def broker_client_exe() -> str: - """Get the appropriate executable name for this platform.""" - if os.name == "nt": - return "pact-broker.bat" - return "pact-broker" - - -def message_exe() -> str: - """Get the appropriate executable name for this platform.""" - if os.name == "nt": - return "pact-message.bat" - return "pact-message" - - -def mock_service_exe() -> str: - """Get the appropriate executable name for this platform.""" - if os.name == "nt": - return "pact-mock-service.bat" - return "pact-mock-service" - - -def provider_verifier_exe() -> str: - """Get the appropriate provider executable name for this platform.""" - if os.name == "nt": - return "pact-provider-verifier.bat" - return "pact-provider-verifier" - - -ROOT_DIR = Path(__file__).parent.resolve() -BROKER_CLIENT_PATH = ROOT_DIR / "bin" / broker_client_exe() -MESSAGE_PATH = ROOT_DIR / "bin" / message_exe() -MOCK_SERVICE_PATH = ROOT_DIR / "bin" / mock_service_exe() -VERIFIER_PATH = ROOT_DIR / "bin" / provider_verifier_exe() +__all__ = [ + "BROKER_CLIENT_PATH", + "MESSAGE_PATH", + "MOCK_SERVICE_PATH", + "VERIFIER_PATH", +] + + +_USE_SYSTEM_BINS = os.getenv("PACT_USE_SYSTEM_BINS", "").upper() in ("TRUE", "YES") +_BIN_DIR = Path(__file__).parent.resolve() / "bin" + + +def _find_executable(executable: str) -> str: + """ + Find the path to an executable. + + This inspects the environment variable `PACT_USE_SYSTEM_BINS` to determine + whether to use the bundled Pact binaries or the system ones. Note that if + the local executables are not found, this will fall back to the system + executables (if found). + + Args: + executable: + The name of the executable to find without the extension. Python + will automatically append the correct extension for the current + platform. + + Returns: + The absolute path to the executable. + + Warns: + RuntimeWarning: + If the executable cannot be found in the system path. + """ + if _USE_SYSTEM_BINS: + bin_path = shutil.which(executable) + else: + bin_path = shutil.which(executable, path=_BIN_DIR) or shutil.which(executable) + if bin_path is None: + msg = f"Unable to find {executable} binary executable." + warnings.warn(msg, RuntimeWarning, stacklevel=2) + return bin_path or "" + + +BROKER_CLIENT_PATH = _find_executable("pact-broker") +MESSAGE_PATH = _find_executable("pact-message") +MOCK_SERVICE_PATH = _find_executable("pact-mock-service") +VERIFIER_PATH = _find_executable("pact-provider-verifier") diff --git a/tests/test_constants.py b/tests/test_constants.py index 38bc48b90..384ae2dc6 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -1,67 +1,51 @@ -from unittest import TestCase +"""Test the values in pact.constants.""" -from mock import patch +import os -from pact import constants as constants +def test_broker_client() -> None: + """Test the value of BROKER_CLIENT_PATH on POSIX.""" + import pact.constants -class BrokerClientExeTestCase(TestCase): - def setUp(self): - super(BrokerClientExeTestCase, self).setUp() - self.addCleanup(patch.stopall) - self.mock_os = patch.object(constants, 'os', autospec=True).start() + if os.name == "nt": + # As the Windows filesystem is case insensitive, we must normalize it. + assert pact.constants.BROKER_CLIENT_PATH.lower().endswith("pact-broker.bat") + else: + assert pact.constants.BROKER_CLIENT_PATH.endswith("pact-broker") - def test_other(self): - self.mock_os.name = 'posix' - self.assertEqual(constants.broker_client_exe(), 'pact-broker') - def test_windows(self): - self.mock_os.name = 'nt' - self.assertEqual(constants.broker_client_exe(), 'pact-broker.bat') +def test_message() -> None: + """Test the value of MESSAGE_PATH on POSIX.""" + import pact.constants + if os.name == "nt": + # As the Windows filesystem is case insensitive, we must normalize it. + assert pact.constants.MESSAGE_PATH.lower().endswith("pact-message.bat") + else: + assert pact.constants.MESSAGE_PATH.endswith("pact-message") -class MockServiceExeTestCase(TestCase): - def setUp(self): - super(MockServiceExeTestCase, self).setUp() - self.addCleanup(patch.stopall) - self.mock_os = patch.object(constants, 'os', autospec=True).start() - def test_other(self): - self.mock_os.name = 'posix' - self.assertEqual(constants.mock_service_exe(), 'pact-mock-service') +def test_mock_service() -> None: + """Test the value of MOCK_SERVICE_PATH on POSIX.""" + import pact.constants - def test_windows(self): - self.mock_os.name = 'nt' - self.assertEqual(constants.mock_service_exe(), 'pact-mock-service.bat') + if os.name == "nt": + # As the Windows filesystem is case insensitive, we must normalize it. + assert pact.constants.MOCK_SERVICE_PATH.lower().endswith( + "pact-mock-service.bat", + ) + else: + assert pact.constants.MOCK_SERVICE_PATH.endswith("pact-mock-service") -class MessageExeTestCase(TestCase): - def setUp(self): - super(MessageExeTestCase, self).setUp() - self.addCleanup(patch.stopall) - self.mock_os = patch.object(constants, 'os', autospec=True).start() +def test_verifier() -> None: + """Test the value of VERIFIER_PATH on POSIX.""" + import pact.constants - def test_other(self): - self.mock_os.name = 'posix' - self.assertEqual(constants.message_exe(), 'pact-message') - - def test_windows(self): - self.mock_os.name = 'nt' - self.assertEqual(constants.message_exe(), 'pact-message.bat') - - -class ProviderVerifierExeTestCase(TestCase): - def setUp(self): - super(ProviderVerifierExeTestCase, self).setUp() - self.addCleanup(patch.stopall) - self.mock_os = patch.object(constants, 'os', autospec=True).start() - - def test_other(self): - self.mock_os.name = 'posix' - self.assertEqual( - constants.provider_verifier_exe(), 'pact-provider-verifier') - - def test_windows(self): - self.mock_os.name = 'nt' - self.assertEqual( - constants.provider_verifier_exe(), 'pact-provider-verifier.bat') + if os.name == "nt": + # As the Windows filesystem is case insensitive, we must normalize it. + assert pact.constants.VERIFIER_PATH.lower().endswith( + "pact-provider-verifier.bat", + ) + else: + assert pact.constants.VERIFIER_PATH.endswith("pact-provider-verifier") From 4a01fe90d5ee8ed176acb6b473abfcf0e9d40394 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Mon, 25 Sep 2023 15:50:42 +1000 Subject: [PATCH 3/6] chore(tests): add ruff.toml for tests directory Adjust the lint rules that apply to tests to be more permissive. Specifically to allow the use of `assert` statements. Signed-off-by: JP-Ellis --- tests/ruff.toml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 tests/ruff.toml diff --git a/tests/ruff.toml b/tests/ruff.toml new file mode 100644 index 000000000..7d4b21b00 --- /dev/null +++ b/tests/ruff.toml @@ -0,0 +1,6 @@ +extend = "../pyproject.toml" +ignore = [ + "D103", # Require docstrings on public functions + "S101", # Disable assert + "PLR2004", # Forbid magic numbers +] From 1c4faba6bd7322ad3cbb7795afc59bcae8d5573a Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 26 Sep 2023 11:08:45 +1000 Subject: [PATCH 4/6] feat(v3): add v3.ffi module This module provides a Python interface to the Pact library written in Rust. For this first commit, only the `pactffi_version()` function is implemented and tested. In the transition to v3, the new codebase will be located within the `v3` submodule. This will allow the v2 code to remain in place for backwards compatibility, and will allow the v3 code to be tested independently of the v2 code. Once the v3 code is complete, the existing v2 code will be scoped to a new `v2` submodule, and the v3 code will be moved to the root of the repository. Signed-off-by: JP-Ellis --- pact/v3/__init__.py | 21 +++++++++++++++++++++ pact/v3/ffi.py | 18 ++++++++++++++++++ tests/test_ffi.py | 15 +++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 pact/v3/__init__.py create mode 100644 pact/v3/ffi.py create mode 100644 tests/test_ffi.py diff --git a/pact/v3/__init__.py b/pact/v3/__init__.py new file mode 100644 index 000000000..443bf9164 --- /dev/null +++ b/pact/v3/__init__.py @@ -0,0 +1,21 @@ +""" +Pact Python V3. + +The next major release of Pact Python will make use of the Pact reference +library written in Rust. This will allow us to support all of the features of +Pact, and bring the Python library in line with the other Pact libraries. + +The migration will happen in stages, and this module will be used to provide +access to the new functionality without breaking existing code. The stages will +be as follows: + +- **Stage 1**: The new library is exposed within `pact.v3` and can be used + alongside the existing library. During this stage, no guarantees are made + about the stability of the `pact.v3` module. +- **Stage 2**: The library within `pact.v3` is considered stable, and we begin + the process of deprecating the existing library by raising deprecation + warnings when it is used. A detailed migration guide will be provided. +- **Stage 3**: The `pact.v3` module is renamed to `pact`, and the existing + library is moved to the `pact.v2` scope. The `pact.v2` module will be + considered deprecated, and will be removed in a future release. +""" diff --git a/pact/v3/ffi.py b/pact/v3/ffi.py new file mode 100644 index 000000000..3ff7bbfa7 --- /dev/null +++ b/pact/v3/ffi.py @@ -0,0 +1,18 @@ +""" +Python bindings for the Pact FFI. + +This module provides a Python interface to the Pact FFI. It is a thin wrapper +around the C API, and is intended to be used by the Pact Python client library +to provide a Pythonic interface to Pact. + +This module is not intended to be used directly by Pact users. Pact users +should use the Pact Python client library instead. No guarantees are made +about the stability of this module's API. +""" + +from ._ffi import ffi, lib + + +def version() -> str: + """Return the version of the Pact FFI library.""" + return ffi.string(lib.pactffi_version()).decode("utf-8") diff --git a/tests/test_ffi.py b/tests/test_ffi.py new file mode 100644 index 000000000..be3aff537 --- /dev/null +++ b/tests/test_ffi.py @@ -0,0 +1,15 @@ +""" +Tests of the FFI module. + +These tests are intended to ensure that the FFI module is working correctly. +They are not intended to test the Pact API itself, as that is handled by the +client library. +""" + +from pact.v3 import ffi + + +def test_version() -> None: + assert isinstance(ffi.version(), str) + assert len(ffi.version()) > 0 + assert ffi.version().count(".") == 2 From 2b269b568fa603ea9f8ad55e2c01077af9d87db3 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 26 Sep 2023 12:30:45 +1000 Subject: [PATCH 5/6] chore(ci): update build targets As the upstream Pact reference library has a different set of targets, the build targets for this library have been updated to match. The most significant change is the dropping is 32-bit architectures altogether. This also adds a `musllinux` target (which was previously not supported). Signed-off-by: JP-Ellis --- .github/workflows/build.yml | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ec9a76b46..fe1c559cf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -58,36 +58,6 @@ jobs: path: ./wheelhouse/*.whl if-no-files-found: error - build-x86: - name: Build wheels on ${{ matrix.os }} (x86, 32-bit) - - if: github.event_name == 'push' || ! github.event.pull_request.draft - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - os: windows-latest - archs: x86 - - steps: - - uses: actions/checkout@v4 - with: - # Fetch all tags - fetch-depth: 0 - - - name: Create wheels - uses: pypa/cibuildwheel@v2.15.0 - env: - CIBW_ARCHS: ${{ matrix.archs }} - - - name: Upload wheels - uses: actions/upload-artifact@v3 - with: - name: wheels - path: ./wheelhouse/*.whl - if-no-files-found: error - build-arm64: name: Build wheels on ${{ matrix.os }} (arm64) @@ -102,10 +72,8 @@ jobs: include: - os: ubuntu-latest archs: aarch64 - build: "*manylinux*" - os: macos-latest archs: arm64 - build: "*" steps: - uses: actions/checkout@v4 @@ -123,7 +91,6 @@ jobs: uses: pypa/cibuildwheel@v2.15.0 env: CIBW_ARCHS: ${{ matrix.archs }} - CIBW_BUILD: ${{ matrix.build }} - name: Upload wheels uses: actions/upload-artifact@v3 @@ -139,7 +106,6 @@ jobs: needs: - build-x86_64 - - build-x86 - build-arm64 steps: From 6e9db03ced1ebfbb57c07e06e3f7626168d6d871 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 6 Oct 2023 14:53:13 +1100 Subject: [PATCH 6/6] fix(build): include omitted `lib` dir When packaging the Ruby Pact binaries, I initially removed the `lib` dir naively believing that the Pact binaries were static. This is in fact incorrect, and I adjusted the extraction to extract _all_ of the content (and only remove the README.md). The unit tests all passed which affirmed my initial belief. Unfortunately (as I have now discovered), the unit tests mock out the call to the binaries, and therefore the test suite did not actuall test the execution of the binaries. Signed-off-by: JP-Ellis --- hatch_build.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/hatch_build.py b/hatch_build.py index de4409ea6..01b9d122e 100644 --- a/hatch_build.py +++ b/hatch_build.py @@ -169,19 +169,16 @@ def _pact_bin_extract(self, artifact: Path) -> None: Args: artifact: The path to the downloaded artifact. """ - (ROOT_DIR / "pact" / "bin").mkdir(parents=True, exist_ok=True) - if str(artifact).endswith(".zip"): with zipfile.ZipFile(artifact) as f: - for member in f.namelist(): - if member.startswith("pact/bin"): - f.extract(member, ROOT_DIR) + f.extractall(ROOT_DIR) if str(artifact).endswith(".tar.gz"): with tarfile.open(artifact) as f: - for member in f.getmembers(): - if member.name.startswith("pact/bin"): - f.extract(member, ROOT_DIR) + f.extractall(ROOT_DIR) + + # Cleanup the extract `README.md` + (ROOT_DIR / "pact" / "README.md").unlink() def pact_lib_install(self, version: str) -> None: """