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]
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") )
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))
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:
- Detects the project language (Python or Go) unless explicitly specified
- Optionally bumps the version if bump_type is provided or with_bump is True
- Reads the current version from pyproject.toml (Python) or VERSION file (Go)
- Validates the git repository state (clean working tree, up-to-date with remote)
- Checks that a tag exists for the current version (created by bump-my-version)
- 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)
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:
- Deleting the release tag from remote (stops/prevents the release workflow)
- Deleting the release tag locally
- Optionally reverting the version bump commit (with
--revert-bump) - 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, ))
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)
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"])