Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 87 additions & 8 deletions pact-python-cli/hatch_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,15 @@
from packaging.tags import sys_tags

PKG_DIR = Path(__file__).parent.resolve() / "src" / "pact_cli"

# Remove when pact-standalone is removed
PACT_CLI_URL = "https://github.com/pact-foundation/pact-standalone/releases/download/v{version}/pact-{version}-{os}-{machine}.{ext}"

# Remove fixed version and infer from package metadata when pact-cli versioning
# is adopted.
PACT_RUST_CLI_VERSION = "0.9.5"
PACT_RUST_CLI_URL = "https://github.com/pact-foundation/pact-cli/releases/download/v{version}/pact-{arch}-{os}{ext}"
Comment thread
JP-Ellis marked this conversation as resolved.


class UnsupportedPlatformError(RuntimeError):
"""
Expand Down Expand Up @@ -83,6 +90,7 @@ def clean(self, versions: Sequence[str]) -> None: # noqa: ARG002
passed to `hatch build`.
"""
for subdir in ["bin", "lib", "data"]:
# TODO(epoch-transition): Remove "lib" when standalone dropped # noqa: TD003
shutil.rmtree(PKG_DIR / subdir, ignore_errors=True)

def initialize(
Expand Down Expand Up @@ -116,7 +124,8 @@ def initialize(
raise ValueError(msg)

try:
build_data["force_include"] = self._install(cli_version)
build_data["force_include"] = self._install_ruby_cli(cli_version)
self._install_rust_cli()
except UnsupportedPlatformError as err:
msg = f"Pact CLI is not available for {err.platform}."
self.app.display_error(msg)
Expand All @@ -132,9 +141,9 @@ def _sys_tag_platform(self) -> str:
"""
return next(t.platform for t in sys_tags())

def _install(self, version: str) -> Mapping[str, str]:
def _install_ruby_cli(self, version: str) -> Mapping[str, str]:
"""
Install the Pact standalone binaries.
Install the Pact Ruby standalone binaries.

The binaries are installed in `src/pact_cli/bin`, and the relevant
version for the current operating system is determined automatically.
Expand All @@ -148,25 +157,26 @@ def _install(self, version: str) -> Mapping[str, str]:
wheel. Each `src` is a full path in the current filesystem, and the
`dst` is the corresponding path within the wheel.
"""
url = self._pact_bin_url(version)
url = self._pact_ruby_bin_url(version)
artefact = self._download(url)
self._extract(artefact)
return {
str(PKG_DIR / "bin"): "pact_cli/bin",
# TODO(epoch-transition): Remove lib/ when standalone dropped # noqa: TD003
str(PKG_DIR / "lib"): "pact_cli/lib",
}

def _pact_bin_url(self, version: str) -> str:
def _pact_ruby_bin_url(self, version: str) -> str:
"""
Generate the download URL for the Pact binaries.
Generate the download URL for the Pact Ruby binaries.

Args:
version:
The Pact CLI version to download.

Returns:
The URL to download the Pact binaries from. If the platform is not
supported, the resulting URL may be invalid.
The URL to download the Pact Ruby binaries from. If the platform is
not supported, the resulting URL may be invalid.
"""
platform = self._sys_tag_platform()

Expand Down Expand Up @@ -196,6 +206,75 @@ def _pact_bin_url(self, version: str) -> str:
ext=ext,
)

def _pact_rust_bin_url(self, version: str) -> str:
"""
Generate the download URL for the Rust pact binary from pact-cli.

The pact-cli release assets are plain binaries (not archives) named
``pact-{arch}-{os}`` (e.g. ``pact-aarch64-macos``), with ``.exe``
appended on Windows.

Args:
version:
The pact-cli version to download.

Returns:
The URL to the pact binary asset.

Raises:
UnsupportedPlatformError:
If the current platform's OS or architecture is not recognised.
"""
platform = self._sys_tag_platform()

if platform.startswith("macosx"):
os_name = "macos"
ext = ""
elif "linux" in platform:
# musl-based Linux targets (e.g. Alpine) are not distinguished;
# the linux-gnu binary is used for all Linux targets.
os_name = "linux-gnu"
ext = ""
elif platform.startswith("win"):
os_name = "windows-msvc"
ext = ".exe"
else:
raise UnsupportedPlatformError(platform)

if platform.endswith(("arm64", "aarch64")):
arch = "aarch64"
elif platform.endswith(("x86_64", "amd64")):
arch = "x86_64"
else:
raise UnsupportedPlatformError(platform)

return PACT_RUST_CLI_URL.format(
version=version,
arch=arch,
os=os_name,
ext=ext,
)

def _install_rust_cli(self) -> None:
"""
Install the Rust pact binary from pact-cli.

Overwrites the `pact` binary bundled with pact-standalone.
Comment thread
JP-Ellis marked this conversation as resolved.

The binary is downloaded from the pact-cli GitHub release as a plain
executable (not an archive) and placed in `bin/` as `pact`
(or `pact.exe` on Windows).
"""
url = self._pact_rust_bin_url(PACT_RUST_CLI_VERSION)
artefact = self._download(url)

bin_name = "pact.exe" if sys.platform == "win32" else "pact"
dest = PKG_DIR / "bin" / bin_name
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(artefact, dest)
if sys.platform != "win32":
dest.chmod(0o755)

def _extract(self, artefact: Path) -> None:
"""
Extract the Pact binaries.
Expand Down
44 changes: 29 additions & 15 deletions pact-python-cli/src/pact_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,22 @@
)

if TYPE_CHECKING:
from collections.abc import Container, Mapping
from collections.abc import Mapping

_USE_SYSTEM_BINS = os.getenv("PACT_USE_SYSTEM_BINS", "").upper() in ("TRUE", "YES")
_BIN_DIR = Path(__file__).parent.resolve() / "bin"
_LEGACY_BINS: Container[str] = frozenset((
"pact-message",
"pact-mock-service",
"pact-provider-verifier",
"pact-stub-service",
))
_DEPRECATED_COMMANDS: Mapping[str, str | None] = {
"pact-broker": "pact broker",
"pact-message": None, # being removed; no Rust equivalent
"pact-mock-service": "pact mock",
"pact-plugin-cli": "pact plugin",
"pact-provider-verifier": "pact verifier",
"pact-stub-server": "pact stub",
"pact-stub-service": "pact stub",
"pact_mock_server_cli": "pact mock",
"pact_verifier_cli": "pact verifier",
"pactflow": "pact pactflow",
}


def _telemetry_env() -> Mapping[str, str]:
Expand Down Expand Up @@ -108,14 +114,22 @@ def _exec() -> None:
print("Unknown command:", command, file=sys.stderr) # noqa: T201
sys.exit(1)

if command in _LEGACY_BINS:
warnings.warn(
f"The '{command}' executable is deprecated and will be removed in "
"a future release. Please migrate to the new Pact CLI tools. "
"See: <https://github.com/pact-foundation/pact-standalone>",
DeprecationWarning,
stacklevel=2,
)
if command in _DEPRECATED_COMMANDS:
replacement = _DEPRECATED_COMMANDS[command]
if replacement:
print( # noqa: T201
f"WARNING: '{command}' is deprecated and will be removed in a "
f"future release. Use '{replacement}' instead.\n",
file=sys.stderr,
flush=True,
)
else:
print( # noqa: T201
f"WARNING: '{command}' is deprecated and will be removed in a "
"future release.\n",
file=sys.stderr,
flush=True,
)

if not _USE_SYSTEM_BINS:
executable = _find_executable(command)
Expand Down
39 changes: 39 additions & 0 deletions pact-python-cli/tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,42 @@ def test_exec_directly(executable: str) -> None:
assert (os.sep + executable) in cmd
assert args == [cmd]
assert env


def test_deprecated_command_warns_with_replacement(
capsys: pytest.CaptureFixture[str],
) -> None:
"""Ruby commands with a Rust equivalent print a hint to stderr."""
with (
patch.object(sys, "argv", new=["pact-broker", "--help"]),
patch("os.execve"),
):
pact_cli._exec() # noqa: SLF001
captured = capsys.readouterr()
assert "deprecated" in captured.err
assert "pact broker" in captured.err


def test_deprecated_command_warns_without_replacement(
capsys: pytest.CaptureFixture[str],
) -> None:
"""Ruby commands without a Rust equivalent print a generic warning."""
with (
patch.object(sys, "argv", new=["pact-message", "--help"]),
patch("os.execve"),
):
pact_cli._exec() # noqa: SLF001
captured = capsys.readouterr()
assert "deprecated" in captured.err
assert "Use " not in captured.err # no replacement hint


def test_pact_command_does_not_warn(capsys: pytest.CaptureFixture[str]) -> None:
"""The Rust pact binary does not trigger any deprecation warning."""
with (
patch.object(sys, "argv", new=["pact", "--help"]),
patch("os.execve"),
):
pact_cli._exec() # noqa: SLF001
captured = capsys.readouterr()
assert "deprecated" not in captured.err
Loading