From f29ec035cc15693e3d5843c633c2cf898623835f Mon Sep 17 00:00:00 2001 From: Scott Ernst Date: Thu, 30 Jul 2026 09:17:55 -0500 Subject: [PATCH] Restore Podman Release Support - **Podman Compatibility** - Restore the lost container-engine option from the former GitLab history while preserving Docker as the default for existing users. - **Release Visibility** - Publish the same verified distribution artifacts to PyPI and GitHub Releases so release state and binaries are visible in both systems. --- .github/workflows/publish.yaml | 134 ++++++++++++++- README.md | 29 +++- hacksaws/_cli.py | 7 +- hacksaws/_configs.py | 7 + hacksaws/_ecr.py | 47 ++++-- hacksaws/tests/test_hacksaws.py | 281 +++++++++++++++++++++++++++++--- pyproject.toml | 3 +- uv.lock | 4 +- 8 files changed, 455 insertions(+), 57 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 5bed1aa..02cb18a 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -5,43 +5,112 @@ on: tags: - "v*" -permissions: - contents: read +permissions: {} + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false jobs: build: name: Build distribution runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.metadata.outputs.version }} + prerelease: ${{ steps.metadata.outputs.prerelease }} + previous_tag: ${{ steps.metadata.outputs.previous_tag }} + artifact_name: ${{ steps.metadata.outputs.artifact_name }} steps: - name: Check out repository uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.14" - name: Set up uv uses: astral-sh/setup-uv@v7 - - name: Validate tag and project version - shell: python + - name: Validate and collect release metadata + id: metadata + shell: bash run: | + uv run --locked python - <<'PY' import os + import subprocess import tomllib + from packaging.version import Version + with open("pyproject.toml", "rb") as stream: version = tomllib.load(stream)["project"]["version"] + tag = os.environ["GITHUB_REF_NAME"] if tag != f"v{version}": raise SystemExit( f"Tag {tag!r} does not match project version {version!r}." ) + + prerelease = Version(version).is_prerelease + tags = subprocess.run( + [ + "git", + "tag", + "--merged", + "HEAD", + "--list", + "v*", + "--sort=-version:refname", + ], + check=True, + capture_output=True, + text=True, + ).stdout.splitlines() + previous_tag = next( + (candidate for candidate in tags if candidate != tag), + "", + ) + artifact_name = f"python-distributions-{tag}" + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + print(f"version={version}", file=output) + print(f"prerelease={str(prerelease).lower()}", file=output) + print(f"previous_tag={previous_tag}", file=output) + print(f"artifact_name={artifact_name}", file=output) + PY - name: Build wheel and source distribution run: uv build + - name: Validate distribution files + shell: python + run: | + from pathlib import Path + + files = sorted( + path + for path in Path("dist").iterdir() + if path.is_file() and not path.name.startswith(".") + ) + wheels = [path for path in files if path.suffix == ".whl"] + source_distributions = [ + path for path in files if path.name.endswith(".tar.gz") + ] + if len(files) != 2 or len(wheels) != 1 or len(source_distributions) != 1: + names = ", ".join(path.name for path in files) + raise SystemExit( + "Expected exactly one wheel and one source distribution; " + f"found: {names or ''}" + ) - name: Upload distribution uses: actions/upload-artifact@v7 with: - name: python-distributions - path: dist/ + name: ${{ steps.metadata.outputs.artifact_name }} + path: | + dist/*.whl + dist/*.tar.gz if-no-files-found: error + retention-days: 30 publish: name: Publish to PyPI @@ -49,14 +118,63 @@ jobs: runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/project/hacksaws/ + url: https://pypi.org/project/hacksaws/${{ needs.build.outputs.version }}/ permissions: id-token: write steps: - name: Download distribution uses: actions/download-artifact@v8 with: - name: python-distributions + name: ${{ needs.build.outputs.artifact_name }} path: dist/ - name: Publish distribution uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + name: Create GitHub Release + needs: + - build + - publish + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download distribution + uses: actions/download-artifact@v8 + with: + name: ${{ needs.build.outputs.artifact_name }} + path: dist/ + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PRERELEASE: ${{ needs.build.outputs.prerelease }} + PREVIOUS_TAG: ${{ needs.build.outputs.previous_tag }} + TAG: ${{ github.ref_name }} + shell: bash + run: | + shopt -s nullglob + wheels=(dist/*.whl) + source_distributions=(dist/*.tar.gz) + if (( ${#wheels[@]} != 1 || ${#source_distributions[@]} != 1 )); then + echo "Expected exactly one wheel and one source distribution." >&2 + exit 1 + fi + + arguments=( + "$TAG" + "${wheels[0]}" + "${source_distributions[0]}" + --verify-tag + --title "hacksaws $TAG" + --generate-notes + --fail-on-no-commits + ) + if [[ -n "$PREVIOUS_TAG" ]]; then + arguments+=(--notes-start-tag "$PREVIOUS_TAG") + fi + if [[ "$PRERELEASE" == "true" ]]; then + arguments+=(--prerelease --latest=false) + fi + + gh release create "${arguments[@]}" diff --git a/README.md b/README.md index 0b3c6e5..d572d37 100644 --- a/README.md +++ b/README.md @@ -41,12 +41,20 @@ The `--lifespan` option changes how long the temporary session remains valid. The default is 12 hours (`--lifespan=43200` seconds). AWS allows at most 24 hours, and the profile's role or account policy may set a lower maximum. -Hacksaws can also log Docker into Amazon ECR in the profile's default region: +Hacksaws can also log a container engine into Amazon ECR in the profile's +default region. Docker is used by default: ```shell hacksaws mfa login --ecr ``` +Select Podman by adding `--podman`. The option chooses the container engine but +does not enable ECR by itself, so use it together with `--ecr`: + +```shell +hacksaws mfa login --ecr --podman +``` + Use `--ecr-region` more than once to add regions. The profile's primary region is processed first, followed by each additional region once in the order provided: @@ -72,6 +80,12 @@ registries as well: hacksaws mfa logout --ecr ``` +Use the same `--podman` selection when logging Podman out: + +```shell +hacksaws mfa logout --ecr --podman +``` + Use `--directory` to select a different AWS configuration directory: ```shell @@ -135,15 +149,18 @@ uv run task build Publishing is handled by the [`publish.yaml`](https://github.com/rocketboosters/hacksaws/blob/main/.github/workflows/publish.yaml) -GitHub Actions workflow and PyPI trusted publishing. +GitHub Actions workflow and PyPI trusted publishing. Each successful release +publishes the wheel and source distribution to PyPI, then creates a GitHub +Release for the same tag with those exact artifacts attached. 1. Update `project.version` in `pyproject.toml`. 2. Run `uv lock`, `npm ci`, and `uv run task check`. 3. Build locally with `uv build` and inspect the wheel and source distribution. 4. Merge the version change to `main`. -5. Create and push a `v` tag, such as `v0.3.0`. +5. Create and push a `v` tag, such as `v0.3.2`. The workflow verifies that the tag exactly matches the project version before it -builds and publishes. The repository's `pypi` environment must be configured as -a trusted publisher for owner `rocketboosters`, repository `hacksaws`, workflow -`publish.yaml`, and environment `pypi`. +builds once, publishes the resulting artifacts to PyPI, and creates the GitHub +Release only after PyPI succeeds. The repository's `pypi` environment must be +configured as a trusted publisher for owner `rocketboosters`, repository +`hacksaws`, workflow `publish.yaml`, and environment `pypi`. diff --git a/hacksaws/_cli.py b/hacksaws/_cli.py index 35c558d..6571a6a 100644 --- a/hacksaws/_cli.py +++ b/hacksaws/_cli.py @@ -37,6 +37,7 @@ def _create_parser() -> argparse.ArgumentParser: for action_parser in (login_parser, logout_parser): action_parser.add_argument("--ecr", action="store_true") + action_parser.add_argument("--podman", action="store_true") action_parser.add_argument("--ecr-region", action="append") action_parser.add_argument( "-d", @@ -82,7 +83,11 @@ def _run_mfa(context: _configs.Context) -> _configs.Result: aws_account = _configs.AwsAccount.from_context(context) if cast("bool", context.args.ecr): - _ecr.logout(aws_account) + _ecr.logout( + context, + aws_account, + check=action not in {"login", "in"}, + ) if action in {"login", "in"}: _aws.login(context) diff --git a/hacksaws/_configs.py b/hacksaws/_configs.py index 1721397..435d57c 100644 --- a/hacksaws/_configs.py +++ b/hacksaws/_configs.py @@ -17,6 +17,8 @@ import argparse from collections.abc import Mapping +ContainerEngine = Literal["docker", "podman"] + class OperationalError(Exception): """An expected operational failure that is safe to show without a traceback.""" @@ -33,6 +35,11 @@ def profile(self) -> str: """Return the AWS profile name for this invocation.""" return cast("str", self.args.profile) + @property + def container_engine(self) -> ContainerEngine: + """Return the container engine selected for ECR authentication.""" + return "podman" if cast("bool", self.args.podman) else "docker" + @property def aws_directory(self) -> Path: """Return the directory containing AWS configuration and credentials.""" diff --git a/hacksaws/_ecr.py b/hacksaws/_ecr.py index 8cd6cee..b206823 100644 --- a/hacksaws/_ecr.py +++ b/hacksaws/_ecr.py @@ -1,4 +1,4 @@ -"""Docker authentication operations for Amazon ECR.""" +"""Container-engine authentication operations for Amazon ECR.""" from __future__ import annotations @@ -16,18 +16,25 @@ from hacksaws import _configs -def _run_docker(command: list[str], *, password: bytes | None = None) -> None: - """Run a Docker command and normalize expected execution failures.""" +def _run_container_engine( + engine: _configs.ContainerEngine, + command: list[str], + *, + password: bytes | None = None, + check: bool = True, +) -> None: + """Run a container-engine command and normalize expected execution failures.""" + executable_name = engine.title() try: - subprocess.run(command, input=password, check=True) # noqa: S603 + subprocess.run(command, input=password, check=check) # noqa: S603 except FileNotFoundError as error: - message = "Docker is not installed or is not available on PATH." + message = f"{executable_name} is not installed or is not available on PATH." raise _configs.OperationalError(message) from error except OSError as error: - message = f"Unable to run Docker: {error}" + message = f"Unable to run {executable_name}: {error}" raise _configs.OperationalError(message) from error except subprocess.CalledProcessError as error: - message = f"Docker command failed with exit code {error.returncode}." + message = f"{executable_name} command failed with exit code {error.returncode}." raise _configs.OperationalError(message) from error @@ -37,7 +44,7 @@ def _do_login( account_id: str, region_name: str, ) -> None: - """Log Docker into one region-specific ECR registry.""" + """Log the selected container engine into one region-specific ECR registry.""" registry = f"{account_id}.dkr.ecr.{region_name}.amazonaws.com" print(f"[STARTED]: Logging into {registry}", flush=True) # noqa: T201 try: @@ -68,9 +75,11 @@ def _do_login( message = f"AWS returned an invalid ECR token for {region_name}." raise _configs.OperationalError(message) from error - _run_docker( + engine = context.container_engine + _run_container_engine( + engine, [ - "docker", + engine, "login", f"--username={user}", "--password-stdin", @@ -88,7 +97,7 @@ def _do_login( def login(context: _configs.Context, aws_account: _configs.AwsAccount) -> None: - """Log Docker into every configured ECR region.""" + """Log the selected container engine into every configured ECR region.""" for region_name in aws_account.ecr_regions: _do_login( context, @@ -97,7 +106,17 @@ def login(context: _configs.Context, aws_account: _configs.AwsAccount) -> None: ) -def logout(aws_account: _configs.AwsAccount) -> None: - """Log Docker out of every configured ECR registry.""" +def logout( + context: _configs.Context, + aws_account: _configs.AwsAccount, + *, + check: bool = True, +) -> None: + """Log the selected container engine out of every configured ECR registry.""" + engine = context.container_engine for registry in aws_account.ecr_registries: - _run_docker(["docker", "logout", registry]) + _run_container_engine( + engine, + [engine, "logout", registry], + check=check, + ) diff --git a/hacksaws/tests/test_hacksaws.py b/hacksaws/tests/test_hacksaws.py index 6011640..1e4d77f 100644 --- a/hacksaws/tests/test_hacksaws.py +++ b/hacksaws/tests/test_hacksaws.py @@ -5,6 +5,7 @@ import argparse import configparser import os +import subprocess import tomllib from contextlib import ExitStack from datetime import UTC @@ -80,6 +81,7 @@ def _context( *, action: str = "login", ecr: bool = False, + podman: bool = False, regions: list[str] | None = None, ) -> _configs.Context: return _configs.Context( @@ -90,6 +92,7 @@ def _context( mfa_code="123456", lifespan=43200, ecr=ecr, + podman=podman, ecr_region=regions, directory=str(directory), aws_account_name=None, @@ -142,8 +145,8 @@ def _temporary_credentials() -> dict[str, object]: def test_version_and_main_exit_status() -> None: """Expose the project version and pass the result status to the shell.""" with Path(__file__).parents[2].joinpath("pyproject.toml").open("rb") as stream: - assert tomllib.load(stream)["project"]["version"] == "0.3.1" - assert hacksaws.__version__ == "0.3.1" + assert tomllib.load(stream)["project"]["version"] == "0.3.2" + assert hacksaws.__version__ == "0.3.2" with patch( "hacksaws.console_main", return_value=_configs.Result("ERROR", "", exit_code=7), @@ -208,6 +211,64 @@ def inspect_context(context: _configs.Context) -> _configs.Result: assert observed_action == alias +@pytest.mark.parametrize( + ("action", "required_arguments"), + [ + ("login", [PROFILE, "123456"]), + ("in", [PROFILE, "123456"]), + ("logout", [PROFILE]), + ("out", [PROFILE]), + ], +) +def test_container_engine_parser_defaults( + action: str, + required_arguments: list[str], +) -> None: + """Default to Docker and keep Podman independent from ECR selection.""" + parser = hacksaws._cli._create_parser() + default_context = _configs.Context( + parser.parse_args(["mfa", action, *required_arguments]), + ) + podman_context = _configs.Context( + parser.parse_args(["mfa", action, *required_arguments, "--podman"]), + ) + + assert default_context.container_engine == "docker" + assert default_context.args.ecr is False + assert default_context.args.podman is False + assert podman_context.container_engine == "podman" + assert podman_context.args.ecr is False + assert podman_context.args.podman is True + + +def test_podman_without_ecr_does_not_run_container_commands( + tmp_path: Path, +) -> None: + """Treat --podman only as the engine choice for an explicit ECR operation.""" + context = _context(tmp_path, podman=True) + account = _configs.AwsAccount( + identity_response=_identity_response(), + region_name="us-west-2", + ecr_additional_regions=(), + ) + + with ( + patch("hacksaws._aws.logout"), + patch("hacksaws._aws.login"), + patch( + "hacksaws._configs.AwsAccount.from_context", + return_value=account, + ), + patch("hacksaws._ecr.logout") as ecr_logout, + patch("hacksaws._ecr.login") as ecr_login, + ): + result = hacksaws._cli._run_mfa(context) + + assert result.exit_code == 0 + ecr_logout.assert_not_called() + ecr_login.assert_not_called() + + def test_mfa_without_action_prints_command_help( capsys: pytest.CaptureFixture[str], ) -> None: @@ -337,10 +398,16 @@ def test_logout_restores_credentials(tmp_path: Path) -> None: assert not (tmp_path / f"{PROFILE}.store.credentials").exists() -def test_ecr_regions_and_docker_commands_are_ordered_and_exact( +@pytest.mark.parametrize( + ("engine", "podman"), + [("docker", False), ("podman", True)], +) +def test_ecr_regions_and_container_commands_are_ordered_and_exact( tmp_path: Path, + engine: str, + podman: bool, ) -> None: - """Process primary and additional ECR regions once, in input order.""" + """Run exact engine commands in primary-first, duplicate-free region order.""" _prepare_aws_directory(tmp_path) regions = ["us-east-1", "us-west-2", "eu-west-1", "us-east-1"] ordered_regions = ["us-west-2", "us-east-1", "eu-west-1"] @@ -398,6 +465,8 @@ def test_ecr_regions_and_docker_commands_are_ordered_and_exact( ] for region in regions: arguments.extend(["--ecr-region", region]) + if podman: + arguments.append("--podman") with ( stack, @@ -411,13 +480,13 @@ def test_ecr_regions_and_docker_commands_are_ordered_and_exact( ] expected_calls = [ *[ - call(["docker", "logout", registry], input=None, check=True) + call([engine, "logout", registry], input=None, check=False) for registry in registries ], *[ call( [ - "docker", + engine, "login", "--username=AWS", "--password-stdin", @@ -433,6 +502,55 @@ def test_ecr_regions_and_docker_commands_are_ordered_and_exact( assert subprocess_run.call_args_list == expected_calls +@pytest.mark.parametrize("action", ["logout", "out"]) +@pytest.mark.parametrize( + ("engine", "podman"), + [("docker", False), ("podman", True)], +) +def test_explicit_ecr_logout_is_strict( + tmp_path: Path, + action: str, + engine: str, + podman: bool, +) -> None: + """Run explicit Docker and Podman logout commands with check enabled.""" + _prepare_aws_directory(tmp_path) + identity_client = _sts_client() + identity_stubber = Stubber(identity_client) + identity_stubber.add_response("get_caller_identity", _identity_response(), {}) + arguments = [ + "mfa", + action, + PROFILE, + "--directory", + str(tmp_path), + "--ecr", + ] + if podman: + arguments.append("--podman") + + with ( + identity_stubber, + patch( + "boto3.Session", + return_value=_session( + region_name="us-west-2", + clients={"sts": identity_client}, + ), + ), + patch("subprocess.run") as subprocess_run, + ): + result = hacksaws.console_main(arguments) + + registry = f"{ACCOUNT_ID}.dkr.ecr.us-west-2.amazonaws.com" + assert result.exit_code == 0 + subprocess_run.assert_called_once_with( + [engine, "logout", registry], + input=None, + check=True, + ) + + def test_known_configuration_failure_is_concise( tmp_path: Path, capsys: pytest.CaptureFixture[str], @@ -516,16 +634,67 @@ def test_aws_failure_is_concise( assert "Traceback" not in captured.err -def test_docker_failure_is_concise() -> None: - """Normalize an unavailable Docker executable.""" - with ( - patch("subprocess.run", side_effect=FileNotFoundError), - pytest.raises( - _configs.OperationalError, - match="Docker is not installed", +@pytest.mark.parametrize( + ("engine", "podman", "failure", "message"), + [ + ( + "docker", + False, + FileNotFoundError(), + "Docker is not installed or is not available on PATH.", + ), + ( + "podman", + True, + FileNotFoundError(), + "Podman is not installed or is not available on PATH.", + ), + ( + "docker", + False, + PermissionError("access denied"), + "Unable to run Docker: access denied", ), + ( + "podman", + True, + PermissionError("access denied"), + "Unable to run Podman: access denied", + ), + ( + "docker", + False, + subprocess.CalledProcessError(9, ["docker"]), + "Docker command failed with exit code 9.", + ), + ( + "podman", + True, + subprocess.CalledProcessError(9, ["podman"]), + "Podman command failed with exit code 9.", + ), + ], +) +def test_container_engine_failures_are_normalized( + tmp_path: Path, + engine: str, + podman: bool, + failure: OSError | subprocess.CalledProcessError, + message: str, +) -> None: + """Normalize expected launch and nonzero failures for both engines.""" + context = _context( + tmp_path, + action="logout", + ecr=True, + podman=podman, + ) + with ( + patch("subprocess.run", side_effect=failure) as subprocess_run, + pytest.raises(_configs.OperationalError, match=f"^{message}$"), ): _ecr.logout( + context, _configs.AwsAccount( identity_response=_identity_response(), region_name="us-west-2", @@ -533,12 +702,71 @@ def test_docker_failure_is_concise() -> None: ), ) + registry = f"{ACCOUNT_ID}.dkr.ecr.us-west-2.amazonaws.com" + subprocess_run.assert_called_once_with( + [engine, "logout", registry], + input=None, + check=True, + ) + + +@pytest.mark.parametrize( + ("podman", "failure", "message"), + [ + ( + False, + FileNotFoundError(), + "Docker is not installed or is not available on PATH.", + ), + ( + True, + FileNotFoundError(), + "Podman is not installed or is not available on PATH.", + ), + ( + False, + PermissionError("access denied"), + "Unable to run Docker: access denied", + ), + ( + True, + PermissionError("access denied"), + "Unable to run Podman: access denied", + ), + ], +) +def test_non_strict_logout_still_normalizes_launch_failures( + tmp_path: Path, + podman: bool, + failure: OSError, + message: str, +) -> None: + """Do not suppress missing or unlaunchable engines during pre-login cleanup.""" + context = _context(tmp_path, ecr=True, podman=podman) + account = _configs.AwsAccount( + identity_response=_identity_response(), + region_name="us-west-2", + ecr_additional_regions=(), + ) + + with ( + patch("subprocess.run", side_effect=failure), + pytest.raises(_configs.OperationalError, match=f"^{message}$"), + ): + _ecr.logout(context, account, check=False) + -def test_docker_launch_os_error_is_concise_through_cli( +@pytest.mark.parametrize( + ("engine", "podman"), + [("docker", False), ("podman", True)], +) +def test_container_engine_launch_os_error_is_concise_through_cli( tmp_path: Path, capsys: pytest.CaptureFixture[str], + engine: str, + podman: bool, ) -> None: - """Convert a Docker launch permission error through the complete CLI path.""" + """Convert an engine launch permission error through the complete CLI path.""" _prepare_aws_directory(tmp_path) identity_client = _sts_client() identity_stubber = Stubber(identity_client) @@ -555,21 +783,22 @@ def test_docker_launch_os_error_is_concise_through_cli( ), patch("subprocess.run", side_effect=PermissionError("access denied")), ): - result = hacksaws.console_main( - [ - "mfa", - "logout", - PROFILE, - "--directory", - str(tmp_path), - "--ecr", - ], - ) + arguments = [ + "mfa", + "logout", + PROFILE, + "--directory", + str(tmp_path), + "--ecr", + ] + if podman: + arguments.append("--podman") + result = hacksaws.console_main(arguments) captured = capsys.readouterr() assert result.code == "OPERATIONAL_ERROR" assert result.exit_code == 1 - assert captured.err == "Error: Unable to run Docker: access denied\n" + assert captured.err == f"Error: Unable to run {engine.title()}: access denied\n" assert "Traceback" not in captured.err diff --git a/pyproject.toml b/pyproject.toml index adc65cf..7b626f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "hacksaws" -version = "0.3.1" +version = "0.3.2" description = "A command-line utility for AWS profiles using dynamic authentication methods such as MFA." authors = [ { name = "Scott Ernst", email = "swernst@gmail.com" }, @@ -27,6 +27,7 @@ dependencies = [ dev = [ "boto3-stubs[ecr,sts]>=1.40,<2", "mypy>=1.14", + "packaging>=25.0", "pytest>=8.3", "pytest-cov>=6.0", "ruff>=0.8", diff --git a/uv.lock b/uv.lock index 9723303..cd74696 100644 --- a/uv.lock +++ b/uv.lock @@ -169,7 +169,7 @@ wheels = [ [[package]] name = "hacksaws" -version = "0.3.1" +version = "0.3.2" source = { editable = "." } dependencies = [ { name = "boto3" }, @@ -179,6 +179,7 @@ dependencies = [ dev = [ { name = "boto3-stubs", extra = ["ecr", "sts"] }, { name = "mypy" }, + { name = "packaging" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -192,6 +193,7 @@ requires-dist = [{ name = "boto3", specifier = ">=1.40,<2" }] dev = [ { name = "boto3-stubs", extras = ["ecr", "sts"], specifier = ">=1.40,<2" }, { name = "mypy", specifier = ">=1.14" }, + { name = "packaging", specifier = ">=25.0" }, { name = "pytest", specifier = ">=8.3" }, { name = "pytest-cov", specifier = ">=6.0" }, { name = "ruff", specifier = ">=0.8" },