rhiza_tools.commands

Commands for Rhiza Tools.

This package contains all command implementations for the rhiza-tools CLI. Each command is implemented as a separate module with its own logic.

Available commands:
  • bump_command: Version bumping with semantic versioning support
  • update_readme_command: README synchronization with make help output
  • generate_coverage_badge_command: Coverage badge generation
  • version_matrix_command: Python version matrix generation from pyproject.toml
  • analyze_benchmarks_command: Analyze and visualize pytest-benchmark results
  • release_command: Create and push release tags
  • rollback_command: Rollback a release and/or version bump
Example:

Import and use commands::

from rhiza_tools.commands import bump_command, update_readme_command, version_matrix_command

bump_command("patch")
update_readme_command()
version_matrix_command()
 1"""Commands for Rhiza Tools.
 2
 3This package contains all command implementations for the rhiza-tools CLI.
 4Each command is implemented as a separate module with its own logic.
 5
 6Available commands:
 7    - bump_command: Version bumping with semantic versioning support
 8    - update_readme_command: README synchronization with make help output
 9    - generate_coverage_badge_command: Coverage badge generation
10    - version_matrix_command: Python version matrix generation from pyproject.toml
11    - analyze_benchmarks_command: Analyze and visualize pytest-benchmark results
12    - release_command: Create and push release tags
13    - rollback_command: Rollback a release and/or version bump
14
15Example:
16    Import and use commands::
17
18        from rhiza_tools.commands import bump_command, update_readme_command, version_matrix_command
19
20        bump_command("patch")
21        update_readme_command()
22        version_matrix_command()
23"""
24
25from .analyze_benchmarks import analyze_benchmarks_command
26from .bump import bump_command
27from .release import release_command
28from .rollback import rollback_command
29from .update_readme import update_readme_command
30from .version_matrix import version_matrix_command
31
32__all__ = [
33    "analyze_benchmarks_command",
34    "bump_command",
35    "release_command",
36    "rollback_command",
37    "update_readme_command",
38    "version_matrix_command",
39]
def analyze_benchmarks_command( benchmarks_json: pathlib.Path | None = None, output_html: pathlib.Path | None = None) -> None:
 41def analyze_benchmarks_command(
 42    benchmarks_json: Path | None = None,
 43    output_html: Path | None = None,
 44) -> None:
 45    """Analyze pytest-benchmark results and visualize them.
 46
 47    This command reads a benchmarks.json file produced by pytest-benchmark,
 48    prints a reduced table with benchmark name, mean milliseconds, and operations
 49    per second, and renders an interactive Plotly bar chart of mean runtimes.
 50
 51    Args:
 52        benchmarks_json: Path to the benchmarks.json file. Defaults to _benchmarks/benchmarks.json.
 53        output_html: Path to save the HTML visualization. Defaults to _benchmarks/benchmarks.html.
 54
 55    Raises:
 56        SystemExit: If benchmarks.json is missing, invalid, or has no valid benchmarks.
 57
 58    Example:
 59        Analyze benchmarks with default paths::
 60
 61            analyze_benchmarks_command()
 62
 63        Use custom paths::
 64
 65            analyze_benchmarks_command(
 66                benchmarks_json=Path("tests/benchmarks.json"),
 67                output_html=Path("reports/benchmarks.html")
 68            )
 69    """
 70    # Import pandas and plotly here to avoid requiring them as hard dependencies
 71    try:
 72        import pandas as pd
 73        import plotly.express as px
 74    except ImportError:
 75        console.error(
 76            "pandas and plotly are required for this command. "
 77            "Install them with: uv pip install -e '.[dev]' or pip install 'rhiza-tools[dev]'"
 78        )
 79        sys.exit(1)
 80
 81    # Set default paths
 82    if benchmarks_json is None:
 83        benchmarks_json = Path("_benchmarks/benchmarks.json")
 84
 85    if output_html is None:
 86        output_html = Path("_benchmarks/benchmarks.html")
 87
 88    # Check if the file exists
 89    if not benchmarks_json.exists():
 90        console.warning(f"benchmarks.json not found at {benchmarks_json}; skipping analysis and exiting successfully.")
 91        sys.exit(0)
 92
 93    # Load pytest-benchmark JSON
 94    try:
 95        with benchmarks_json.open() as f:
 96            data = json.load(f)
 97    except json.JSONDecodeError:
 98        console.warning(
 99            f"benchmarks.json at {benchmarks_json} is invalid or empty; skipping analysis and exiting successfully."
100        )
101        sys.exit(0)
102
103    # Validate structure: require a 'benchmarks' list
104    if not isinstance(data, dict) or "benchmarks" not in data or not isinstance(data["benchmarks"], list):
105        console.warning(
106            f"benchmarks.json at {benchmarks_json} missing valid 'benchmarks' list; "
107            "skipping analysis and exiting successfully."
108        )
109        sys.exit(0)
110
111    # Check if benchmarks list is empty
112    if not data["benchmarks"]:
113        console.warning(
114            f"benchmarks.json at {benchmarks_json} contains no benchmarks; skipping analysis and exiting successfully."
115        )
116        sys.exit(0)
117
118    # Extract relevant info: Benchmark name, Mean (ms), OPS
119    benchmarks = []
120    for bench in data["benchmarks"]:
121        mean_s = bench["stats"]["mean"]
122        benchmarks.append(
123            {
124                "Benchmark": bench["name"],
125                "Mean_ms": mean_s * 1000,  # convert seconds → milliseconds
126                "OPS": 1 / mean_s,
127            }
128        )
129
130    # Create DataFrame and sort fastest → slowest
131    df = pd.DataFrame(benchmarks)
132    df = df.sort_values("Mean_ms")
133
134    # Display reduced table
135    console.info("Benchmark Results:")
136    print(df[["Benchmark", "Mean_ms", "OPS"]].to_string(index=False, float_format="%.3f"))
137
138    # Create interactive Plotly bar chart
139    fig = px.bar(
140        df,
141        x="Benchmark",
142        y="Mean_ms",
143        color="Mean_ms",
144        color_continuous_scale="Viridis_r",
145        title="Benchmark Mean Runtime (ms) per Test",
146        text="Mean_ms",
147    )
148
149    fig.update_traces(texttemplate="%{text:.2f} ms", textposition="outside")
150    fig.update_layout(
151        xaxis_tickangle=-45,
152        yaxis_title="Mean Runtime (ms)",
153        coloraxis_colorbar={"title": "ms"},
154        height=600,
155        margin={"t": 100, "b": 200},
156    )
157
158    # Create output directory if it doesn't exist
159    output_html.parent.mkdir(parents=True, exist_ok=True)
160
161    # Save HTML visualization
162    fig.write_html(output_html)
163    console.success(f"Visualization saved to {output_html}")
164
165    # Show interactive plot in browser
166    fig.show()

Analyze pytest-benchmark results and visualize them.

This command reads a benchmarks.json file produced by pytest-benchmark, prints a reduced table with benchmark name, mean milliseconds, and operations per second, and renders an interactive Plotly bar chart of mean runtimes.

Arguments:
  • benchmarks_json: Path to the benchmarks.json file. Defaults to _benchmarks/benchmarks.json.
  • output_html: Path to save the HTML visualization. Defaults to _benchmarks/benchmarks.html.
Raises:
  • SystemExit: If benchmarks.json is missing, invalid, or has no valid benchmarks.
Example:

Analyze benchmarks with default paths::

analyze_benchmarks_command()

Use custom paths::

analyze_benchmarks_command(
    benchmarks_json=Path("tests/benchmarks.json"),
    output_html=Path("reports/benchmarks.html")
)
def bump_command(options: rhiza_tools.commands.bump.BumpOptions) -> None:
825def bump_command(options: BumpOptions) -> None:
826    """Bump version using bump-my-version.
827
828    This function handles the complete version bumping workflow including
829    configuration loading, version parsing, interactive selection (if needed),
830    and executing the bump operation.
831
832    Supports multiple languages:
833    - Python: uses pyproject.toml
834    - Go: uses VERSION file with go.mod
835    - Other: uses VERSION file
836
837    Args:
838        options: Configuration options for the bump command.
839
840    Raises:
841        typer.Exit: If project files are missing, configuration is invalid, or
842            bump operation fails.
843
844    Example:
845        Bump to patch version::
846
847            bump_command(BumpOptions(version="patch"))
848
849        Bump with dry run::
850
851            bump_command(BumpOptions(version="1.2.3", dry_run=True))
852
853        Interactive bump with commit::
854
855            bump_command(BumpOptions(commit=True))
856
857        Bump and push to remote::
858
859            bump_command(BumpOptions(version="minor", push=True))
860    """
861    # Detect or use provided language
862    if options.language is None:
863        detected_language = Language.detect()
864        if detected_language is None:
865            console.error("Unable to detect project language.")
866            console.error("Please specify language explicitly with --language option.")
867            console.error("Supported languages: python, go")
868            raise typer.Exit(code=1)
869        language = detected_language
870        console.info(f"Detected language: {typer.style(language.value, fg=typer.colors.CYAN, bold=True)}")
871    else:
872        language = options.language
873        console.info(f"Using language: {typer.style(language.value, fg=typer.colors.CYAN, bold=True)}")
874
875    _validate_project_exists(language)
876
877    # Handle branch checkout if specified
878    original_branch = _handle_branch_checkout(options.branch, options.dry_run)
879
880    # Determine commit/push settings
881    # In non-interactive mode (version specified), flags control behaviour directly.
882    # In interactive mode (no version), the user is prompted for each step.
883    is_interactive = not options.version
884    commit = options.commit or options.push
885    push = options.push
886
887    current_version_str = get_current_version(language)
888    config, config_path = _build_configuration(current_version_str, options.allow_dirty, commit)
889
890    # Get current branch for display
891    current_git_branch = get_current_git_branch()
892
893    console.info(f"Current branch: {typer.style(current_git_branch, fg=typer.colors.CYAN, bold=True)}")
894    console.info(f"Current version: {typer.style(current_version_str, fg=typer.colors.CYAN, bold=True)}")
895
896    # Determine new version string
897    if options.version:
898        new_version_str = _parse_version_argument(options.version, current_version_str)
899    else:
900        new_version_str = get_interactive_bump_type(current_version_str)
901
902    console.info(f"New version will be: {typer.style(new_version_str, fg=typer.colors.GREEN, bold=True)}")
903
904    # Show preview of file changes
905    _preview_file_modifications(config, current_version_str, new_version_str)
906
907    # Interactive preview and confirmation (only in true interactive mode)
908    if is_interactive:
909        proceed, commit, push = _show_interactive_preview(
910            current_version_str,
911            new_version_str,
912            current_git_branch,
913        )
914        if not proceed:
915            console.info("Version bump cancelled by user")
916            raise typer.Exit(code=0)
917        # Rebuild configuration with the user's commit decision
918        config, config_path = _build_configuration(current_version_str, options.allow_dirty, commit)
919
920    # Preflight: validate bump would succeed before making any changes
921    if not options.dry_run:
922        _preflight_bump(new_version_str, config, config_path)
923        # Rebuild configuration to avoid stale state from dry-run
924        config, config_path = _build_configuration(current_version_str, options.allow_dirty, commit)
925
926    _execute_bump(new_version_str, config, config_path, options.dry_run)
927
928    if options.dry_run:
929        console.info("[DRY-RUN] Bump completed (no changes made)")
930        if commit:
931            console.info("[DRY-RUN] Would commit the changes")
932        if push:
933            console.info("[DRY-RUN] Would push changes to remote")
934    else:
935        _log_bump_success(current_version_str, config, language)
936
937        # Handle push
938        if push:
939            _handle_push_to_remote(options.version)
940
941    # Restore original branch if we switched
942    _restore_original_branch(original_branch, options.dry_run)

Bump version using bump-my-version.

This function handles the complete version bumping workflow including configuration loading, version parsing, interactive selection (if needed), and executing the bump operation.

Supports multiple languages:

  • Python: uses pyproject.toml
  • Go: uses VERSION file with go.mod
  • Other: uses VERSION file
Arguments:
  • options: Configuration options for the bump command.
Raises:
  • typer.Exit: If project files are missing, configuration is invalid, or bump operation fails.
Example:

Bump to patch version::

bump_command(BumpOptions(version="patch"))

Bump with dry run::

bump_command(BumpOptions(version="1.2.3", dry_run=True))

Interactive bump with commit::

bump_command(BumpOptions(commit=True))

Bump and push to remote::

bump_command(BumpOptions(version="minor", push=True))
def release_command( bump_type: str | None = None, push: bool = False, dry_run: bool = False, non_interactive: bool = False, with_bump: bool = False, language: rhiza_tools.commands.bump.Language | None = None) -> None:
554def release_command(
555    bump_type: str | None = None,
556    push: bool = False,
557    dry_run: bool = False,
558    non_interactive: bool = False,
559    with_bump: bool = False,
560    language: Language | None = None,
561) -> None:
562    """Push a release tag to remote.
563
564    This command performs the following steps:
565    1. Detects the project language (Python or Go) unless explicitly specified
566    2. Optionally bumps the version if bump_type is provided or with_bump is True
567    3. Reads the current version from pyproject.toml (Python) or VERSION file (Go)
568    4. Validates the git repository state (clean working tree, up-to-date with remote)
569    5. Checks that a tag exists for the current version (created by bump-my-version)
570    6. Pushes the tag to remote, triggering the release workflow
571
572    Args:
573        bump_type: Optional bump type (MAJOR, MINOR, PATCH) to apply before release.
574        push: If True, push changes without prompting.
575        dry_run: If True, show what would be done without making any changes.
576        non_interactive: If True, skip all confirmation prompts.
577        with_bump: If True, enable interactive bump selection (works with dry-run).
578        language: Programming language (python or go). Auto-detected if not specified.
579
580    Raises:
581        typer.Exit: If no supported project files are found, repository is not clean,
582            tag doesn't exist, or any git operations fail.
583
584    Example:
585        Push a release tag::
586
587            release_command()
588
589        Preview what would happen::
590
591            release_command(dry_run=True)
592
593        Non-interactive mode::
594
595            release_command(non_interactive=True)
596
597        Bump and release::
598
599            release_command(bump_type="MINOR", push=True)
600
601        Interactive bump with dry-run::
602
603            release_command(with_bump=True, push=True, dry_run=True)
604
605        Release a Go project::
606
607            release_command(language=Language.GO)
608    """
609    # Detect or validate project language
610    if language is None:
611        language = Language.detect()
612        if language is None:
613            console.error("No supported project files found in current directory.")
614            console.error("Python projects need pyproject.toml; Go projects need go.mod and VERSION.")
615            raise typer.Exit(code=1)
616    else:
617        from rhiza_tools.commands.bump import _validate_project_exists
618
619        _validate_project_exists(language)
620
621    # Get current branch early
622    result = run_git_command(["git", "rev-parse", "--abbrev-ref", "HEAD"])
623    current_branch = result.stdout.strip()
624    console.info(f"Current branch: {typer.style(current_branch, fg=typer.colors.CYAN, bold=True)}")
625
626    # Interactive mode: ask if user wants to bump version
627    should_bump, new_version = _get_bump_type_interactively(
628        non_interactive, bump_type, dry_run, with_bump, language=language
629    )
630
631    # ── Preflight validation: check everything BEFORE making any changes ──
632    default_branch = get_default_branch()
633    _check_repository_state(dry_run, current_branch, default_branch)
634
635    # If bumping, pre-validate that the new tag won't conflict with remote
636    if should_bump and new_version and not dry_run:
637        new_tag = f"v{new_version}"
638        _, exists_remotely = check_tag_exists(new_tag)
639        if exists_remotely:
640            console.error(f"Tag '{new_tag}' already exists on remote")
641            console.error(f"The release for version {new_version} has already been published.")
642            console.error("No changes were made. To resolve:")
643            console.error(f"  Delete the remote tag:  git push origin :refs/tags/{new_tag}")
644            console.error("  Or choose a different version to bump to.")
645            raise typer.Exit(code=1)
646        console.success(f"Preflight: tag '{new_tag}' is available on remote")
647
648    # ── Execute: all preflight checks passed, safe to make changes ──
649
650    # Perform bump if requested (bump_command runs its own internal preflight)
651    bumped_new_version: str | None = None
652    if should_bump and new_version:
653        bumped_new_version = _perform_version_bump(new_version, dry_run, language)
654
655    # Get current version and tag
656    current_version, tag = _get_release_version(dry_run, bumped_new_version, language)
657
658    # Validate tag state (for non-bump cases, ensures local tag exists)
659    _handle_tag_validation(dry_run, bumped_new_version, tag, current_version)
660
661    # Push tag
662    console.info("Preparing to push tag to remote...")
663    console.info(f"Pushing tag '{tag}' to origin will trigger the release workflow.")
664
665    # Show commits since last tag (if any)
666    _show_commits_since_last_tag(tag)
667
668    # Confirm and push (bump commit + tag together)
669    _confirm_and_push_tag(
670        tag, push, dry_run, non_interactive, bump_branch=current_branch if bumped_new_version else None
671    )
672
673    if dry_run:
674        console.info("[DRY-RUN] Release process completed (no changes made)")
675    else:
676        console.success("Release process completed successfully!")

Push a release tag to remote.

This command performs the following steps:

  1. Detects the project language (Python or Go) unless explicitly specified
  2. Optionally bumps the version if bump_type is provided or with_bump is True
  3. Reads the current version from pyproject.toml (Python) or VERSION file (Go)
  4. Validates the git repository state (clean working tree, up-to-date with remote)
  5. Checks that a tag exists for the current version (created by bump-my-version)
  6. Pushes the tag to remote, triggering the release workflow
Arguments:
  • bump_type: Optional bump type (MAJOR, MINOR, PATCH) to apply before release.
  • push: If True, push changes without prompting.
  • dry_run: If True, show what would be done without making any changes.
  • non_interactive: If True, skip all confirmation prompts.
  • with_bump: If True, enable interactive bump selection (works with dry-run).
  • language: Programming language (python or go). Auto-detected if not specified.
Raises:
  • typer.Exit: If no supported project files are found, repository is not clean, tag doesn't exist, or any git operations fail.
Example:

Push a release tag::

release_command()

Preview what would happen::

release_command(dry_run=True)

Non-interactive mode::

release_command(non_interactive=True)

Bump and release::

release_command(bump_type="MINOR", push=True)

Interactive bump with dry-run::

release_command(with_bump=True, push=True, dry_run=True)

Release a Go project::

release_command(language=Language.GO)
def rollback_command(options: rhiza_tools.commands.rollback.RollbackOptions) -> None:
611def rollback_command(options: RollbackOptions) -> None:
612    """Rollback a release and/or version bump.
613
614    This command safely reverses release and bump operations by:
615
616    1. Deleting the release tag from remote (stops/prevents the release workflow)
617    2. Deleting the release tag locally
618    3. Optionally reverting the version bump commit (with ``--revert-bump``)
619    4. Optionally pushing the revert commit to remote
620
621    The command uses ``git revert`` rather than ``git reset`` to create a new
622    revert commit, making it safe even when changes have been pushed to remote.
623
624    Args:
625        options: Configuration options for the rollback.
626
627    Raises:
628        typer.Exit: If the tag doesn't exist, pyproject.toml is missing,
629            or any git operations fail.
630
631    Example:
632        Rollback the most recent release::
633
634            rollback_command(RollbackOptions())
635
636        Preview rollback::
637
638            rollback_command(RollbackOptions(dry_run=True))
639
640        Rollback a specific tag with bump revert::
641
642            rollback_command(RollbackOptions(tag="v1.2.3", revert_bump=True))
643
644        Non-interactive rollback::
645
646            rollback_command(RollbackOptions(
647                tag="v1.2.3",
648                revert_bump=True,
649                non_interactive=True,
650            ))
651    """
652    validate_pyproject_exists()
653
654    result = run_git_command(["git", "rev-parse", "--abbrev-ref", "HEAD"])
655    current_branch = result.stdout.strip()
656    console.info(f"Current branch: {typer.style(current_branch, fg=typer.colors.CYAN, bold=True)}")
657
658    tag = _resolve_tag(options)
659    exists_locally, exists_remotely = _validate_rollback_preconditions(tag)
660
661    tag_details = _get_tag_details(tag) if exists_locally else {}
662    is_bump = _is_bump_commit(tag) if exists_locally else False
663    previous_tag = _get_previous_version_from_tags(tag)
664    revert_bump = _should_revert_bump(options, exists_locally, is_bump)
665
666    _show_rollback_plan(tag, exists_locally, exists_remotely, revert_bump, is_bump, previous_tag, tag_details)
667
668    if not options.dry_run and not _confirm_rollback(options.non_interactive):
669        console.info("Rollback cancelled by user.")
670        raise typer.Exit(code=0)
671
672    success = _execute_rollback(
673        tag, exists_locally, exists_remotely, revert_bump, is_bump, options.dry_run, options.non_interactive
674    )
675    _print_rollback_summary(options.dry_run, success, previous_tag)

Rollback a release and/or version bump.

This command safely reverses release and bump operations by:

  1. Deleting the release tag from remote (stops/prevents the release workflow)
  2. Deleting the release tag locally
  3. Optionally reverting the version bump commit (with --revert-bump)
  4. Optionally pushing the revert commit to remote

The command uses git revert rather than git reset to create a new revert commit, making it safe even when changes have been pushed to remote.

Arguments:
  • options: Configuration options for the rollback.
Raises:
  • typer.Exit: If the tag doesn't exist, pyproject.toml is missing, or any git operations fail.
Example:

Rollback the most recent release::

rollback_command(RollbackOptions())

Preview rollback::

rollback_command(RollbackOptions(dry_run=True))

Rollback a specific tag with bump revert::

rollback_command(RollbackOptions(tag="v1.2.3", revert_bump=True))

Non-interactive rollback::

rollback_command(RollbackOptions(
    tag="v1.2.3",
    revert_bump=True,
    non_interactive=True,
))
def update_readme_command(dry_run: bool = False) -> None:
225def update_readme_command(dry_run: bool = False) -> None:
226    """Update README.md with the current output from `make help`.
227
228    This command synchronizes the README.md file with the current Makefile help
229    output by finding the marker line and updating the code block that follows.
230
231    Args:
232        dry_run: If True, only show what would be done without making changes.
233
234    Raises:
235        typer.Exit: If README.md is not found or cannot be accessed.
236
237    Example:
238        Update README::
239
240            update_readme_command()
241
242        Preview changes::
243
244            update_readme_command(dry_run=True)
245    """
246    readme_path = Path("README.md")
247
248    if not readme_path.exists():
249        console.error("README.md not found in current directory")
250        raise typer.Exit(code=1)
251
252    # Get the help output
253    console.info("Generating help output from Makefile...")
254    help_output = _get_make_help_output()
255
256    if dry_run:
257        console.info("DRY RUN: Would update README.md with the following content:")
258        console.info("-" * 50)
259        console.info(help_output)
260        console.info("-" * 50)
261        return
262
263    # Update the README
264    console.info("Updating README.md...")
265    updated = _update_readme_with_help(readme_path, help_output)
266
267    if updated:
268        console.success("README.md updated with current 'make help' output")
269    else:
270        console.info("README.md was not modified (no marker found)")

Update README.md with the current output from make help.

This command synchronizes the README.md file with the current Makefile help output by finding the marker line and updating the code block that follows.

Arguments:
  • dry_run: If True, only show what would be done without making changes.
Raises:
  • typer.Exit: If README.md is not found or cannot be accessed.
Example:

Update README::

update_readme_command()

Preview changes::

update_readme_command(dry_run=True)
def version_matrix_command( pyproject_path: pathlib.Path | None = None, candidates: list[str] | None = None) -> None:
214def version_matrix_command(
215    pyproject_path: Path | None = None,
216    candidates: list[str] | None = None,
217) -> None:
218    """Emit the list of supported Python versions from pyproject.toml as JSON.
219
220    This command reads pyproject.toml, parses the requires-python field, and outputs
221    a JSON array of Python versions that satisfy the constraint. This is used in
222    GitHub Actions to compute the test matrix.
223
224    Args:
225        pyproject_path: Path to pyproject.toml. Defaults to ./pyproject.toml.
226        candidates: List of candidate Python versions to evaluate. Defaults to
227            ["3.11", "3.12", "3.13", "3.14"].
228
229    Raises:
230        SystemExit: If pyproject.toml is missing, invalid, or no versions match.
231
232    Example:
233        Get supported versions (output to stdout)::
234
235            version_matrix_command()
236            # Output: ["3.11", "3.12"]
237
238        Use custom pyproject.toml path::
239
240            version_matrix_command(pyproject_path=Path("/path/to/pyproject.toml"))
241
242        Use custom candidates::
243
244            version_matrix_command(candidates=["3.10", "3.11", "3.12"])
245    """
246    if pyproject_path is None:
247        pyproject_path = Path("pyproject.toml")
248
249    if candidates is None:
250        candidates = ["3.11", "3.12", "3.13", "3.14"]
251
252    try:
253        versions = get_supported_versions(pyproject_path, candidates)
254        # Output as JSON array (matches the behavior of the original script)
255        print(json.dumps(versions))
256    except (PyProjectError, VersionSpecifierError) as e:
257        console.error(str(e))
258        sys.exit(1)

Emit the list of supported Python versions from pyproject.toml as JSON.

This command reads pyproject.toml, parses the requires-python field, and outputs a JSON array of Python versions that satisfy the constraint. This is used in GitHub Actions to compute the test matrix.

Arguments:
  • pyproject_path: Path to pyproject.toml. Defaults to ./pyproject.toml.
  • candidates: List of candidate Python versions to evaluate. Defaults to ["3.11", "3.12", "3.13", "3.14"].
Raises:
  • SystemExit: If pyproject.toml is missing, invalid, or no versions match.
Example:

Get supported versions (output to stdout)::

version_matrix_command()
# Output: ["3.11", "3.12"]

Use custom pyproject.toml path::

version_matrix_command(pyproject_path=Path("/path/to/pyproject.toml"))

Use custom candidates::

version_matrix_command(candidates=["3.10", "3.11", "3.12"])