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
2 changes: 1 addition & 1 deletion .github/workflows/run_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]

steps:
- uses: actions/checkout@v7
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
__pycache__
.python-version
.vscode
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ GitHub Action to create a [Boutiques descriptor](https://boutiques.github.io/) f
|------|-------------|----------|---------|
| `parser-type` | Type of parser to serialize. Either `"argparse"` or `"click"`. | yes | `argparse` |
| `parser-location` | Module path to and name of function that produces the `argparse.ArgumentParser` or `click.Command` object. E.g., `"my_package.my_module:my_func_name"`. | yes | — |
| `prog-name` | Program name to use. **Highly recommended** if the name is not explicitly set in the parser source code. | no | — |
| `output-path` | Where the output Boutiques description file should be written. | yes | — |
| `open-pr` | Whether to open a pull request with the generated descriptor. A GitHub token must be provided as well. | no | `true` |
| `token` | GitHub token with write permissions for `contents` and `pull-requests`. Required if `open-pr` is true. | no | — |
| `click-prog-name` | Program name to use for the Click parser type. By default this will be the name of the decorated function. | no | — |
| `click-parent-location` | Module path to and name of function that produces the `click.Group` object that contains the `click.Command` object to serialize. E.g., `"my_package.my_module:my_func_name"`. Needed for nested commands. | no | — |
| `exclude-version` | Whether to exclude the `tool-version` field in the Boutiques descriptor even if version information is available. Enabled by default because dynamic versioning schemes can cause the descriptor to be updated on every commit. | no | `true` |
| `updates-file` | Path to a JSON file with updates to apply to the generated Boutiques descriptor. The file should contain a map of dot/bracket paths to values (e.g. `{"description": "new desc"}`). | no | — |
Expand Down Expand Up @@ -46,6 +46,7 @@ jobs:
with:
parser-type: argparse
parser-location: my_tool.cli:get_parser
prog-name: my_tool
output-path: descriptor.json
token: ${{ secrets.TOKEN }}
```
Expand All @@ -62,6 +63,7 @@ This can be used for example to add a `"container-image"` field:
with:
parser-type: argparse
parser-location: my_tool.cli:get_parser
prog-name: my_tool
output-path: descriptor.json
token: ${{ secrets.TOKEN }}
updates-str: '{"container-image.image": "my-container-image:tag", "container-image.type": "docker"}'
Expand All @@ -77,7 +79,7 @@ This can be used for example to add a `"container-image"` field:
with:
parser-type: click
parser-location: my_tool.cli:cli
prog-name: my-tool
output-path: descriptor.json
token: ${{ secrets.TOKEN }}
click-prog-name: my-tool
```
16 changes: 12 additions & 4 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ inputs:
parser-location:
description: Module path to and name of function that produces the argparse.ArgumentParser or click.Command objects. E.g., "my_package.my_module:my_func_name"
required: true
prog-name:
description: Program name to use. Highly recommended if the name is not explicitly set in the parser source code.
required: false
output-path:
description: Where the output Boutiques description file should be written
required: true
Expand All @@ -18,9 +21,6 @@ inputs:
token:
description: GitHub token with write permissions for "contents" and "pull-requests". Required if open-pr is true.
required: false
click-prog-name:
description: Program name to use for the Click parser type. By default this will be the name of the decorated function.
required: false
click-parent-location:
description: Module path to and name of function that produces the click.Group object that contains the click.Command object to serialize. E.g., "my_package.my_module:my_func_name". This is needed for nested commands.
required: false
Expand All @@ -38,6 +38,11 @@ inputs:
description: Whether to validate the generated Boutiques descriptor. If this is set to true, the action will fail if the descriptor is invalid.
required: false
default: false
# TODO deprecate in v2
click-prog-name:
description: Program name to use for the Click parser type. By default this will be the name of the decorated function.
deprecationMessage: This input is deprecated and will be removed in a future release. Use the "prog-name" input instead.
required: false

runs:
using: composite
Expand All @@ -53,6 +58,9 @@ runs:
echo "::error::token must be provided if open-pr is true"
exit 2
fi
if [[ ! -z "${{ inputs.click-prog-name }}" && ! -z "${{ inputs.prog-name }}" ]]; then
echo "::warning::Both click-prog-name and prog-name were provided: using prog-name."
fi
shell: bash

- name: Set up Node.js
Expand Down Expand Up @@ -82,9 +90,9 @@ runs:
env:
PARSER_TYPE: ${{ inputs.parser-type }}
PARSER_LOCATION: ${{ inputs.parser-location }}
PROG_NAME: ${{ inputs.prog-name || inputs.click-prog-name }}
OUTPUT_PATH: ${{ github.workspace }}/${{ inputs.output-path }}
DUMP_FILE: dump.json
CLICK_PROG_NAME: ${{ inputs.click-prog-name }}
CLICK_PARENT_LOCATION: ${{ inputs.click-parent-location }}
EXCLUDE_VERSION: ${{ inputs.exclude-version }}
UPDATES_FILE: ${{ inputs.updates-file && format('{0}/{1}', github.workspace, inputs.updates-file) || '' }}
Expand Down
11 changes: 7 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ name = "python-cli-to-boutiques"
version = "1.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.9"
requires-python = ">=3.10"
dependencies = [
"argdump>=0.1.2",
"argdump>=0.1.3",
"clickdump>=0.1.0",
]

Expand All @@ -19,6 +19,9 @@ pythonpath = ["scripts", "tests"]

[dependency-groups]
test = [
"pytest>=8.4.2",
"pytest>=9.0.3",
]
dev = [
"pre-commit>=4.6.0",
{include-group = "test"},
]
dev = ["pre-commit", {include-group = "test"}]
10 changes: 7 additions & 3 deletions scripts/create_descriptor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@ set -euo pipefail
# -- Create dump file --
if [[ "$PARSER_TYPE" == "click" ]]; then
CLICK_ARGS=(--output "$DUMP_FILE")
if [[ -n "$CLICK_PROG_NAME" ]]; then
CLICK_ARGS+=(--prog "$CLICK_PROG_NAME")
if [[ -n "$PROG_NAME" ]]; then
CLICK_ARGS+=(--prog "$PROG_NAME")
fi
if [[ -n "$CLICK_PARENT_LOCATION" ]]; then
CLICK_ARGS+=(--parent "$CLICK_PARENT_LOCATION")
fi
python ./scripts/run_clickdump.py "${CLICK_ARGS[@]}" "$PARSER_LOCATION"
elif [[ "$PARSER_TYPE" == "argparse" ]]; then
python ./scripts/run_argdump.py --output "$DUMP_FILE" "$PARSER_LOCATION"
ARGPARSE_ARGS=(--output "$DUMP_FILE")
if [[ -n "$PROG_NAME" ]]; then
ARGPARSE_ARGS+=(--prog "$PROG_NAME")
fi
python ./scripts/run_argdump.py "${ARGPARSE_ARGS[@]}" "$PARSER_LOCATION"
else
echo "Error: PARSER_TYPE must be 'argparse' or 'click', got '$PARSER_TYPE'" >&2
exit 1
Expand Down
14 changes: 11 additions & 3 deletions scripts/run_argdump.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import argparse
import importlib
from pathlib import Path
from typing import Optional

import argdump

Expand All @@ -29,11 +30,13 @@ def load_parser(module_path: str, parser_func_name: str) -> argparse.ArgumentPar
return parser


def run_argdump(location: str, output_path: Path, indent=DEFAULT_INDENT):
def run_argdump(
location: str, output_path: Path, indent=DEFAULT_INDENT, prog: Optional[str] = None
):
"""Serialize an `argparse.ArgumentParser` to JSON using argdump."""
module_path, parser_name = location.split(":")
parser_to_serialize = load_parser(module_path, parser_name)
serialized_parser = argdump.dumps(parser_to_serialize, indent=indent)
serialized_parser = argdump.dumps(parser_to_serialize, indent=indent, prog=prog)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(f"{serialized_parser}\n")

Expand All @@ -58,13 +61,18 @@ def build_parser() -> argparse.ArgumentParser:
default=DEFAULT_INDENT,
help="JSON indentation. Default: %(default)s",
)
parser.add_argument(
"--prog",
default=None,
help="Override the program name.",
)
return parser


def main():
parser = build_parser()
args = parser.parse_args()
run_argdump(args.location, args.output_path, indent=args.indent)
run_argdump(args.location, args.output_path, indent=args.indent, prog=args.prog)


if __name__ == "__main__":
Expand Down
17 changes: 15 additions & 2 deletions tests/test_create_descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ def _base_env(tmp_path: Path) -> dict[str, str]:
env["PYTHONPATH"] = str(REPO_ROOT / "tests")
env["PARSER_TYPE"] = "argparse"
env["PARSER_LOCATION"] = "conftest:make_parser"
env["PROG_NAME"] = ""
env["DUMP_FILE"] = "dump.json"
env["OUTPUT_PATH"] = str(tmp_path / "desc.json")
env["CLICK_PROG_NAME"] = ""
env["CLICK_PARENT_LOCATION"] = ""
env["EXCLUDE_VERSION"] = "false"
env["UPDATES_FILE"] = ""
Expand Down Expand Up @@ -56,6 +56,19 @@ def test_does_not_use_clickdump(self, tmp_path):
result = _run(tmp_path)
assert "run_clickdump" not in result.stderr

def test_custom_args(self, tmp_path):
result = _run(
tmp_path,
{
"PROG_NAME": "custom_name",
},
)
assert result.returncode == 0
out_file = tmp_path / "desc.json"
assert out_file.exists()
data = json.loads(out_file.read_text())
assert data["name"] == "custom_name"


class TestClickFlow:
def test_creates_output_file(self, tmp_path):
Expand All @@ -77,7 +90,7 @@ def test_with_prog(self, tmp_path):
{
"PARSER_TYPE": "click",
"PARSER_LOCATION": "conftest:test_cli",
"CLICK_PROG_NAME": "myprog",
"PROG_NAME": "myprog",
},
)
assert result.returncode == 0
Expand Down
24 changes: 22 additions & 2 deletions tests/test_run_argdump.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ def test_custom_indent(self, parser, _make_fake_module, tmp_path):
text = out.read_text()
assert " " in text

@pytest.mark.parametrize("prog", ["custom_name1", "custom_name2"])
def test_with_prog_override(self, parser, _make_fake_module, tmp_path, prog):
_make_fake_module("dump_mod4", "make_parser", lambda: parser)
out = tmp_path / "out.json"
run_argdump("dump_mod4:make_parser", out, prog=prog)
data = json.loads(out.read_text())
assert data["prog"] == prog


class TestBuildParser:
def test_returns_argument_parser(self):
Expand All @@ -68,9 +76,21 @@ def test_defaults(self):
args = parser.parse_args(["mymod:myfunc"])
assert args.output_path == Path("argdump.json")
assert args.indent == 2
assert args.prog is None

def test_custom_output_and_indent(self):
def test_custom_args(self):
parser = build_parser()
args = parser.parse_args(["mymod:myfunc", "-o", "custom.json", "--indent", "4"])
args = parser.parse_args(
[
"mymod:myfunc",
"-o",
"custom.json",
"--indent",
"4",
"--prog",
"custom_name",
]
)
assert args.output_path == Path("custom.json")
assert args.indent == 4
assert args.prog == "custom_name"
Loading