Skip to content

Commit

Permalink
chore: migrate to pyproject.toml and hatch
Browse files Browse the repository at this point in the history
The previous build process relied on `distutils` which is due to be
deprecated in 3.12. Furthermore, the use of `setup.py` is now
discouraged.

The choice to migrate to `hatch` was made for the following reasons:

- It offers a very simple management of the venv. No more need to
  `python -m venv .venv` and `pip install`, `hatch` handles all of that
  automatically when creating the virtual environment.
- `hatch` supercedes `tox`, allowing for multiple python versions to be
  tested in a single command.
- `hatch` manages the build process, and offers a nicer way to hook in a
  custom build process to download the `pact` standalone binaries.

A minor change to the packaging of the library now places the binaries
in `pact/bin` instead of `pact/bin/pact/bin`. The `constants.py` file
has been accordingly updated to reflect this change in case anyone was
making direct use of the binaries.

While this change is rather significant, it should not affect the end
user experience. Users will still be able to `pip install pact-python`
from PyPI. Other than for the aforementioned, there has been no changes
to the library code.

Resolves: #369
Refs: https://docs.python.org/3/whatsnew/3.10.html#distutils-deprecated
Refs: https://setuptools.pypa.io/en/latest/userguide/quickstart.html#setuppy-discouraged

Signed-off-by: JP-Ellis <[email protected]>
  • Loading branch information
JP-Ellis committed Sep 13, 2023
1 parent 2d4ddf7 commit 6fea26d
Show file tree
Hide file tree
Showing 11 changed files with 337 additions and 463 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ e2e/pacts
userserviceclient-userservice.json
detectcontentlambda-contentprovider.json
pact/bin
pact/lib
pact/data

# Byte-compiled / optimized / DLL files
__pycache__/
Expand Down
31 changes: 0 additions & 31 deletions MANIFEST

This file was deleted.

6 changes: 0 additions & 6 deletions MANIFEST.in

This file was deleted.

48 changes: 8 additions & 40 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
DOCS_DIR := ./docs

PROJECT := pact-python
PYTHON_MAJOR_VERSION := 3.9
PYTHON_MAJOR_VERSION := 3.11

sgr0 := $(shell tput sgr0)
red := $(shell tput setaf 1)
Expand All @@ -10,7 +10,6 @@ green := $(shell tput setaf 2)
help:
@echo ""
@echo " clean to clear build and distribution directories"
@echo " deps to install the required files for development"
@echo " examples to run the example end to end tests (consumer, fastapi, flask, messaging)"
@echo " consumer to run the example consumer tests"
@echo " fastapi to run the example FastApi provider tests"
Expand All @@ -19,24 +18,16 @@ help:
@echo " package to create a distribution package in /dist/"
@echo " release to perform a release build, including deps, test, and package targets"
@echo " test to run all tests"
@echo " venv to setup a venv under .venv using pyenv, if available"
@echo ""


.PHONY: release
release: deps test package
release: test package


.PHONY: clean
clean:
rm -rf build
rm -rf dist
rm -rf pact/bin


.PHONY: deps
deps:
pip install -r requirements_dev.txt -e .
hatch clean


define CONSUMER
Expand Down Expand Up @@ -105,34 +96,11 @@ examples: consumer flask fastapi messaging

.PHONY: package
package:
python setup.py sdist
hatch build


.PHONY: test
test: deps
flake8
pydocstyle pact
coverage erase
tox
coverage report -m --fail-under=100

.PHONY: venv
venv:
@if [ -d "./.venv" ]; then echo "$(red).venv already exists, not continuing!$(sgr0)"; exit 1; fi
@type pyenv >/dev/null 2>&1 || (echo "$(red)pyenv not found$(sgr0)"; exit 1)

@echo "\n$(green)Try to find the most recent minor version of the major version specified$(sgr0)"
$(eval PYENV_VERSION=$(shell pyenv install -l | grep "\s\s$(PYTHON_MAJOR_VERSION)\.*" | tail -1 | xargs))
@echo "$(PYTHON_MAJOR_VERSION) -> $(PYENV_VERSION)"

@echo "\n$(green)Install the Python pyenv version if not already available$(sgr0)"
pyenv install $(PYENV_VERSION) -s

@echo "\n$(green)Make a .venv dir$(sgr0)"
~/.pyenv/versions/${PYENV_VERSION}/bin/python3 -m venv ${CURDIR}/.venv

@echo "\n$(green)Make it 'available' to pyenv$(sgr0)"
ln -sf ${CURDIR}/.venv ~/.pyenv/versions/${PROJECT}

@echo "\n$(green)Use it! (populate .python-version)$(sgr0)"
pyenv local ${PROJECT}
test:
hatch run all
hatch run test:all
coverage report -m --fail-under=100
147 changes: 147 additions & 0 deletions hatch_build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""Hatchling build hook for Pact binary download."""

from __future__ import annotations

import os
import shutil
import typing
from pathlib import Path
from typing import Any

from hatchling.builders.hooks.plugin.interface import BuildHookInterface
from packaging.tags import sys_tags

ROOT_DIR = Path(__file__).parent.resolve()
PACT_VERSION = "2.0.3"
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):
"""Custom hook to download Pact binaries."""

PLUGIN_NAME = "custom"

def clean(self, versions: list[str]) -> None: # noqa: ARG002
"""Clean up any files created by the build hook."""
for subdir in ["bin", "lib", "data"]:
shutil.rmtree(ROOT_DIR / "pact" / subdir, ignore_errors=True)

def initialize(
self,
version: str, # noqa: ARG002
build_data: dict[str, Any],
) -> None:
"""Hook into Hatchling's build process."""
build_data["infer_tag"] = True
build_data["pure_python"] = False

pact_version = os.getenv("PACT_VERSION", PACT_VERSION)
self.install_pact_binaries(pact_version)

def install_pact_binaries(self, version: str) -> None: # noqa: PLR0912
"""
Install the Pact standalone binaries.
The binaries are installed in `pact/bin`, and the relevant version for
the current operating system is determined automatically.
Args:
version: The Pact version to install. Defaults to the value in
`PACT_VERSION`.
"""
platform = typing.cast(str, next(sys_tags()).platform)

if platform.startswith("macosx"):
os = "osx"
if platform.endswith("arm64"):
machine = "arm64"
elif platform.endswith("x86_64"):
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")

elif platform.startswith("win"):
os = "windows"

if platform.endswith("amd64"):
machine = "x86_64"
elif platform.endswith("x86"):
machine = "x86"
else:
msg = f"Unknown Windows machine {platform}"
raise ValueError(msg)

url = PACT_URL.format(version=version, os=os, machine=machine, ext="zip")

elif "linux" 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}"
raise ValueError(msg)

url = PACT_URL.format(version=version, os=os, machine=machine, ext="tar.gz")

else:
msg = f"Unknown platform {platform}"
raise ValueError(msg)

self.download_and_extract_pact(url)

def download_and_extract_pact(self, url: str) -> None:
"""
Download and extract the Pact binaries.
If the download artifact is already present, it will be used instead of
downloading it again.
Args:
url: The URL to download the Pact binaries from.
"""
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()
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",
)
57 changes: 23 additions & 34 deletions pact/constants.py
Original file line number Diff line number Diff line change
@@ -1,48 +1,37 @@
"""Constant values for the pact-python package."""
import os
from os.path import join, dirname, normpath
from pathlib import Path


def broker_client_exe():
def broker_client_exe() -> str:
"""Get the appropriate executable name for this platform."""
if os.name == 'nt':
return 'pact-broker.bat'
else:
return 'pact-broker'
if os.name == "nt":
return "pact-broker.bat"
return "pact-broker"


def message_exe():
def message_exe() -> str:
"""Get the appropriate executable name for this platform."""
if os.name == 'nt':
return 'pact-message.bat'
else:
return 'pact-message'
if os.name == "nt":
return "pact-message.bat"
return "pact-message"


def mock_service_exe():
def mock_service_exe() -> str:
"""Get the appropriate executable name for this platform."""
if os.name == 'nt':
return 'pact-mock-service.bat'
else:
return 'pact-mock-service'
if os.name == "nt":
return "pact-mock-service.bat"
return "pact-mock-service"


def provider_verifier_exe():
def provider_verifier_exe() -> str:
"""Get the appropriate provider executable name for this platform."""
if os.name == 'nt':
return 'pact-provider-verifier.bat'
else:
return 'pact-provider-verifier'


BROKER_CLIENT_PATH = normpath(join(
dirname(__file__), 'bin', 'pact', 'bin', broker_client_exe()))

MESSAGE_PATH = normpath(join(
dirname(__file__), 'bin', 'pact', 'bin', message_exe()))

MOCK_SERVICE_PATH = normpath(join(
dirname(__file__), 'bin', 'pact', 'bin', mock_service_exe()))

VERIFIER_PATH = normpath(join(
dirname(__file__), 'bin', 'pact', 'bin', provider_verifier_exe()))
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()
Loading

0 comments on commit 6fea26d

Please sign in to comment.