diff --git a/pact-python-cli/hatch_build.py b/pact-python-cli/hatch_build.py index 650b356c0..942177972 100644 --- a/pact-python-cli/hatch_build.py +++ b/pact-python-cli/hatch_build.py @@ -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}" + class UnsupportedPlatformError(RuntimeError): """ @@ -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( @@ -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) @@ -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. @@ -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() @@ -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. + + 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. diff --git a/pact-python-cli/src/pact_cli/__init__.py b/pact-python-cli/src/pact_cli/__init__.py index 02c0fab1a..0d6c85a03 100644 --- a/pact-python-cli/src/pact_cli/__init__.py +++ b/pact-python-cli/src/pact_cli/__init__.py @@ -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]: @@ -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: ", - 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) diff --git a/pact-python-cli/tests/test_init.py b/pact-python-cli/tests/test_init.py index 004ef8f35..904e2e62a 100644 --- a/pact-python-cli/tests/test_init.py +++ b/pact-python-cli/tests/test_init.py @@ -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