Skip to content

Commit b0da83e

Browse files
committed
feat(cli): build abi-agnostic wheels
Instead of building separate wheels for each Python version, we now build `py3-none-{platform}` wheels. These can be used by any Python 3 version, now and into the future. The reason this works is that the Python code is pure Python and only bundles CLIs to be called through Python. Ref: #1120 Ref: #1082 Signed-off-by: JP-Ellis <[email protected]>
1 parent cd23152 commit b0da83e

File tree

3 files changed

+86
-100
lines changed

3 files changed

+86
-100
lines changed

.github/workflows/build-cli.yml

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ concurrency:
1919
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
2020

2121
env:
22-
STABLE_PYTHON_VERSION: '3.13'
22+
STABLE_PYTHON_VERSION: '313'
2323
HATCH_VERBOSE: '1'
2424
FORCE_COLOR: '1'
2525
CIBW_BUILD_FRONTEND: build
@@ -97,24 +97,12 @@ jobs:
9797
with:
9898
fetch-depth: 0
9999

100-
- name: Filter targets
101-
id: cibw-filter
102-
shell: bash
103-
# On PRs, only build the latest stable version of Python to speed up the
104-
# workflow.
105-
run: |
106-
if [[ "${{ github.event_name}}" == "pull_request" ]] ; then
107-
echo "build=cp${STABLE_PYTHON_VERSION/./}-*" >> "$GITHUB_OUTPUT"
108-
else
109-
echo "build=*" >> "$GITHUB_OUTPUT"
110-
fi
111-
112100
- name: Create wheels
113101
uses: pypa/cibuildwheel@95d2f3a92fbf80abe066b09418bbf128a8923df2 # v3.0.1
114102
with:
115103
package-dir: pact-python-cli
116104
env:
117-
CIBW_BUILD: ${{ steps.cibw-filter.outputs.build }}
105+
CIBW_BUILD: cp${{ env.STABLE_PYTHON_VERSION }}-*
118106

119107
- name: Upload wheels
120108
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2

pact-python-cli/hatch_build.py

Lines changed: 73 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,24 @@
1313
from __future__ import annotations
1414

1515
import logging
16+
import os
1617
import shutil
18+
import sys
1719
import tarfile
1820
import tempfile
21+
import urllib.request
1922
import zipfile
2023
from pathlib import Path
2124
from typing import Any
2225

23-
import requests
26+
from hatchling.builders.config import BuilderConfig
2427
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
2528
from packaging.tags import sys_tags
2629

2730
logger = logging.getLogger(__name__)
2831

32+
EXE = ".exe" if os.name == "nt" else ""
2933
PKG_DIR = Path(__file__).parent.resolve() / "src" / "pact_cli"
30-
31-
# Latest version available at:
32-
# https://github.com/pact-foundation/pact-ruby-standalone/releases
3334
PACT_BIN_URL = "https://github.com/pact-foundation/pact-ruby-standalone/releases/download/v{version}/pact-{version}-{os}-{machine}.{ext}"
3435

3536

@@ -47,15 +48,12 @@ def __init__(self, platform: str) -> None:
4748
super().__init__(f"Unsupported platform {platform}")
4849

4950

50-
class PactBuildHook(BuildHookInterface[Any]):
51+
class PactCliBuildHook(BuildHookInterface[BuilderConfig]):
5152
"""Custom hook to download Pact binaries."""
5253

53-
PLUGIN_NAME = "custom"
54-
"""
55-
This is a hard-coded name required by Hatch
56-
"""
54+
PLUGIN_NAME = "pact-cli"
5755

58-
def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401
56+
def __init__(self, *args: object, **kwargs: object) -> None:
5957
"""
6058
Initialize the build hook.
6159
@@ -64,7 +62,6 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401
6462
"""
6563
super().__init__(*args, **kwargs)
6664
self.tmpdir = Path(tempfile.TemporaryDirectory().name)
67-
self.tmpdir.mkdir(parents=True, exist_ok=True)
6865

6966
def clean(self, versions: list[str]) -> None: # noqa: ARG002
7067
"""Clean up any files created by the build hook."""
@@ -77,51 +74,54 @@ def initialize(
7774
build_data: dict[str, Any],
7875
) -> None:
7976
"""Hook into Hatchling's build process."""
80-
build_data["infer_tag"] = True
81-
build_data["pure_python"] = False
82-
8377
cli_version = ".".join(self.metadata.version.split(".")[:3])
8478
if not cli_version:
8579
self.app.display_error("Failed to determine Pact CLI version.")
8680

8781
try:
88-
self.pact_bin_install(cli_version)
82+
self._pact_bin_install(cli_version)
8983
except UnsupportedPlatformError as err:
9084
msg = f"Pact CLI is not available for {err.platform}."
9185
logger.exception(msg, RuntimeWarning, stacklevel=2)
9286

93-
def pact_bin_install(self, version: str) -> None:
87+
build_data["tag"] = self._infer_tag()
88+
89+
def _sys_tag_platform(self) -> str:
90+
"""
91+
Get the platform tag from the current system tags.
92+
93+
This is used to determine the target platform for the Pact binaries.
94+
"""
95+
return next(t.platform for t in sys_tags())
96+
97+
def _pact_bin_install(self, version: str) -> None:
9498
"""
9599
Install the Pact standalone binaries.
96100
97-
The binaries are installed in `src/pact/bin`, and the relevant version for
98-
the current operating system is determined automatically.
101+
The binaries are installed in `src/pact_cli/bin`, and the relevant
102+
version for the current operating system is determined automatically.
99103
100104
Args:
101-
version: The Pact version to install.
105+
version:
106+
The Pact CLI version to install.
102107
"""
103108
url = self._pact_bin_url(version)
104-
if url:
105-
artifact = self._download(url)
106-
self._pact_bin_extract(artifact)
109+
artifact = self._download(url)
110+
self._pact_bin_extract(artifact)
107111

108-
def _pact_bin_url(self, version: str) -> str | None:
112+
def _pact_bin_url(self, version: str) -> str:
109113
"""
110114
Generate the download URL for the Pact binaries.
111115
112-
Generate the download URL for the Pact binaries based on the current
113-
platform and specified version. This function mainly contains a lot of
114-
matching logic to determine the correct URL to use, due to the
115-
inconsistencies in naming conventions between ecosystems.
116-
117116
Args:
118-
version: The upstream Pact version.
117+
version:
118+
The Pact CLI version to download.
119119
120120
Returns:
121-
The URL to download the Pact binaries from, or None if the current
122-
platform is not supported.
121+
The URL to download the Pact binaries from. If the platform is not
122+
supported, the resulting URL may be invalid.
123123
"""
124-
platform = next(sys_tags()).platform
124+
platform = self._sys_tag_platform()
125125

126126
if platform.startswith("macosx"):
127127
os = "osx"
@@ -161,22 +161,21 @@ def _pact_bin_extract(self, artifact: Path) -> None:
161161
Args:
162162
artifact: The path to the downloaded artifact.
163163
"""
164-
with tempfile.TemporaryDirectory() as tmpdir:
165-
if str(artifact).endswith(".zip"):
166-
with zipfile.ZipFile(artifact) as f:
167-
f.extractall(tmpdir) # noqa: S202
168-
169-
if str(artifact).endswith(".tar.gz"):
170-
with tarfile.open(artifact) as f:
171-
f.extractall(tmpdir) # noqa: S202
172-
173-
for d in ["bin", "lib"]:
174-
if (PKG_DIR / d).is_dir():
175-
shutil.rmtree(PKG_DIR / d)
176-
shutil.copytree(
177-
Path(tmpdir) / "pact" / d,
178-
PKG_DIR / d,
179-
)
164+
if str(artifact).endswith(".zip"):
165+
with zipfile.ZipFile(artifact) as f:
166+
f.extractall(self.tmpdir) # noqa: S202
167+
168+
if str(artifact).endswith(".tar.gz"):
169+
with tarfile.open(artifact) as f:
170+
f.extractall(self.tmpdir) # noqa: S202
171+
172+
for d in ["bin", "lib"]:
173+
if (PKG_DIR / d).is_dir():
174+
shutil.rmtree(PKG_DIR / d)
175+
shutil.copytree(
176+
Path(self.tmpdir) / "pact" / d,
177+
PKG_DIR / d,
178+
)
180179

181180
def _download(self, url: str) -> Path:
182181
"""
@@ -196,13 +195,31 @@ def _download(self, url: str) -> Path:
196195
artifact.parent.mkdir(parents=True, exist_ok=True)
197196

198197
if not artifact.exists():
199-
response = requests.get(url, timeout=30)
200-
try:
201-
response.raise_for_status()
202-
except requests.HTTPError as e:
203-
msg = f"Failed to download from {url}."
204-
raise RuntimeError(msg) from e
205-
with artifact.open("wb") as f:
206-
f.write(response.content)
198+
urllib.request.urlretrieve(url, artifact) # noqa: S310
207199

208200
return artifact
201+
202+
def _infer_tag(self) -> str:
203+
"""
204+
Infer the tag for the current build.
205+
206+
Since we have a pure Python wrapper around a binary CLI, we are not
207+
tied to any specific Python version or ABI. As a result, we generate
208+
`py3-none-{platform}` tags for the wheels.
209+
"""
210+
platform = self._sys_tag_platform()
211+
212+
# On macOS, the version needs to be set based on the deployment target
213+
# (i.e., the version of the system libraries).
214+
if sys.platform == "darwin" and (
215+
deployment_target := os.environ.get("MACOSX_DEPLOYMENT_TARGET")
216+
):
217+
target = deployment_target.replace(".", "_")
218+
if platform.endswith("_arm64"):
219+
platform = f"macosx_{target}_arm64"
220+
elif platform.endswith("_x86_64"):
221+
platform = f"macosx_{target}_x86_64"
222+
else:
223+
raise UnsupportedPlatformError(platform)
224+
225+
return f"py3-none-{platform}"

pact-python-cli/pyproject.toml

Lines changed: 11 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,7 @@ requires = [
6666
"hatch-vcs",
6767
"hatchling",
6868
"packaging",
69-
"requests",
70-
"setuptools ; python_version >= '3.12'",
69+
# "setuptools ; python_version >= '3.12'",
7170
]
7271

7372
[tool.hatch]
@@ -95,29 +94,12 @@ requires = [
9594
[tool.hatch.build.hooks.vcs]
9695
version-file = "src/pact_cli/__version__.py"
9796

98-
[tool.hatch.build.targets.sdist]
99-
include = [
100-
# Source
101-
"/src/pact_cli/**/*.py",
102-
"/src/pact_cli/**/*.pyi",
103-
"/src/pact_cli/**/py.typed",
104-
105-
# Metadata
106-
"*.md",
107-
"LICENSE",
108-
]
109-
11097
[tool.hatch.build.targets.wheel]
111-
artifacts = ["/src/pact_cli/bin/*", "/src/pact_cli/lib/*"]
112-
include = [
113-
# Source
114-
"/src/pact_cli/**/*.py",
115-
"/src/pact_cli/**/*.pyi",
116-
"/src/pact_cli/**/py.typed",
117-
]
118-
packages = ["/src/pact_cli"]
98+
artifacts = ["src/pact_cli/bin", "src/pact_cli/lib"]
99+
packages = ["src/pact_cli"]
119100

120101
[tool.hatch.build.targets.wheel.hooks.custom]
102+
patch = "hatch_build.py"
121103

122104
########################################
123105
## Hatch Environment Configuration
@@ -208,15 +190,14 @@ exclude = ''
208190
## CI Build Wheel
209191
################################################################################
210192
[tool.cibuildwheel]
211-
before-build = "rm -rvf src/pact_cli/{bin,data,lib}"
212-
213193
# The repair tool unfortunately did not like the bundled Ruby distributable,
214194
# with false-positives missing libraries despite being bundled.
215195
repair-wheel-command = ""
216196

217-
[tool.cibuildwheel.windows]
218-
before-build = [
219-
'IF EXIST src\pact_cli\bin\ RMDIR /S /Q src\pact_cli\bin',
220-
'IF EXIST src\pact_cli\data\ RMDIR /S /Q src\pact_cli\data',
221-
'IF EXIST src\pact_cli\lib\ RMDIR /S /Q src\pact_cli\lib',
222-
]
197+
[[tool.cibuildwheel.overrides]]
198+
environment.MACOSX_DEPLOYMENT_TARGET = "10.13"
199+
select = "*-macosx_x86_64"
200+
201+
[[tool.cibuildwheel.overrides]]
202+
environment.MACOSX_DEPLOYMENT_TARGET = "11.0"
203+
select = "*-macosx_arm64"

0 commit comments

Comments
 (0)