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
61 changes: 61 additions & 0 deletions .github/workflows/publish-python.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Publish Python package

on:
push:
tags:
- "python-v*"

permissions:
contents: read

concurrency:
group: publish-python-${{ github.ref }}
cancel-in-progress: false

jobs:
publish:
if: github.repository == 'Tim-1e/holocubic-cli'
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/holocubic-cli-python/
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: implementations/python

steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
- name: Install Python 3.13
run: uv python install 3.13
- name: Verify release tag
shell: bash
run: |
VERSION="$(uv version --short)"
EXPECTED_TAG="python-v${VERSION}"
if [[ "${GITHUB_REF_NAME}" != "${EXPECTED_TAG}" ]]; then
echo "Expected tag ${EXPECTED_TAG}, got ${GITHUB_REF_NAME}" >&2
exit 1
fi
- name: Sync locked environment
run: uv sync --locked
- name: Lint
run: uvx ruff check src tests
- name: Check formatting
run: uvx ruff format --check src tests
- name: Test
run: uv run --locked python -m unittest discover -s tests -v
- name: Build distributions
run: uv build --clear --no-sources
- name: Check distribution metadata
run: uvx twine check dist/*
- name: Smoke-test wheel
run: uv run --isolated --no-project --with dist/*.whl cubic-py --version
- name: Smoke-test source distribution
run: uv run --isolated --no-project --with dist/*.tar.gz cubic-py --version
- name: Publish to PyPI with OIDC
run: uv publish --trusted-publishing always dist/*
51 changes: 40 additions & 11 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,28 +44,57 @@ Configure npm Trusted Publishing with these exact values:

## Python to PyPI

The public package is `holocubic-cli-python` and uses PyPI Trusted Publishing.
Confirm the name and version in `implementations/python/pyproject.toml`, then
build and inspect both distributions:

```sh
cd implementations/python
python -m pip install --upgrade build twine
python -m pip install --editable .
python -m unittest discover -s tests -v
python -m build
python -m twine check dist/*
uv sync --locked
uvx ruff check src tests
uvx ruff format --check src tests
uv run --locked python -m unittest discover -s tests -v
uv build --clear --no-sources
uvx twine check dist/*
uv publish --dry-run dist/*
```

TestPyPI and production PyPI use separate accounts and credentials:
Smoke-test the built wheel and source distribution rather than the editable
checkout. For example:

```sh
python -m twine upload --repository testpypi dist/*
python -m twine upload dist/*
uv run --isolated --no-project --with dist/*.whl cubic-py --version
uv run --isolated --no-project --with dist/*.tar.gz cubic-py --version
```

Verify installation in a clean virtual environment after each upload. For
later releases, add PyPI Trusted Publishing through GitHub Actions instead of
storing a long-lived API token.
After confirming the version, create and push a tag that exactly matches
`python-v<pyproject.toml version>`. For example, version `0.1.0` must use:

```sh
git tag python-v0.1.0
git push origin python-v0.1.0
```

The tag starts `.github/workflows/publish-python.yml`. The workflow rejects a
tag that differs from `pyproject.toml`, repeats the checks above, tests both
distribution formats, and publishes through OIDC without a `PYPI_TOKEN`
secret.

Configure the PyPI Trusted Publisher with these exact values:

| Field | Value |
| --- | --- |
| PyPI project | `holocubic-cli-python` |
| Owner | `Tim-1e` |
| Repository | `holocubic-cli` |
| Workflow filename | `publish-python.yml` |
| Environment name | `pypi` |

For an existing project, add the publisher under `Manage` → `Publishing`. For
a project that has not been created yet, the same values can be registered as
a pending publisher under the account-level `Publishing` page. TestPyPI is a
separate registry and requires its own account, credentials, and trusted
publisher configuration.

## Rust to crates.io

Expand Down
99 changes: 69 additions & 30 deletions implementations/python/src/holocubic_cli_python/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,32 @@
from .url import normalize_device_url


class _HelpAfterErrorParser(argparse.ArgumentParser):
"""Keep argparse semantics while showing the relevant command help on errors."""

_missing_command: str | None = None

def show_help_when_command_is_missing(self, destination: str) -> None:
self._missing_command = destination

def error(self, message: str) -> None:
if message == f"the following arguments are required: {self._missing_command}":
self.print_help(sys.stderr)
self.exit(2)
print(f"{self.prog}: error: {message}", file=sys.stderr)
print(file=sys.stderr)
self.print_help(sys.stderr)
self.exit(2)


def _add_command(
subparsers: Any, name: str, description: str, **kwargs: Any
) -> argparse.ArgumentParser:
return subparsers.add_parser(
name, help=description, description=description, **kwargs
)


def _positive_integer(value: str) -> int:
try:
parsed = int(value)
Expand Down Expand Up @@ -70,7 +96,7 @@ def _add_transfer_options(


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
parser = _HelpAfterErrorParser(
prog="cubic-py",
description="Manage HoloCubic DevTools devices and SD-card files",
)
Expand All @@ -94,76 +120,89 @@ def build_parser() -> argparse.ArgumentParser:
)
parser.add_argument("--config", help="override the configuration file")
commands = parser.add_subparsers(dest="command", required=True)
parser.show_help_when_command_is_missing("command")

device = commands.add_parser("device", help="manage saved devices")
device = _add_command(commands, "device", "manage saved devices")
device_commands = device.add_subparsers(dest="device_command", required=True)
device_add = device_commands.add_parser("add", help="verify and save a device")
device.show_help_when_command_is_missing("device_command")
device_add = _add_command(device_commands, "add", "verify and save a device")
device_add.add_argument("name")
device_add.add_argument("device_host")
device_add.add_argument("--no-use", action="store_false", dest="use", default=True)
device_commands.add_parser("list", help="list saved devices")
device_use = device_commands.add_parser("use", help="select a saved device")
_add_command(device_commands, "list", "list saved devices")
device_use = _add_command(device_commands, "use", "select a saved device")
device_use.add_argument("name")
device_remove = device_commands.add_parser("remove", help="remove a saved device")
device_remove = _add_command(device_commands, "remove", "remove a saved device")
device_remove.add_argument("name")

commands.add_parser("ping", help="test the selected device")
commands.add_parser("info", help="show device capabilities and transfer limits")
list_parser = commands.add_parser("ls", help="list a remote directory")
_add_command(commands, "ping", "test the selected device")
_add_command(commands, "info", "show device capabilities and transfer limits")
list_parser = _add_command(commands, "ls", "list a remote directory")
list_parser.add_argument("remote", nargs="?")
stat_parser = commands.add_parser(
"stat", help="show remote file or directory metadata"
stat_parser = _add_command(
commands, "stat", "show remote file or directory metadata"
)
stat_parser.add_argument("remote")
cat_parser = commands.add_parser("cat", help="write a remote file to stdout")
cat_parser = _add_command(commands, "cat", "write a remote file to stdout")
cat_parser.add_argument("remote")
mkdir_parser = commands.add_parser(
"mkdir", help="create a remote directory and missing parents"
mkdir_parser = _add_command(
commands, "mkdir", "create a remote directory and missing parents"
)
mkdir_parser.add_argument("remote")
move_parser = commands.add_parser("mv", help="rename or move a remote path")
move_parser = _add_command(commands, "mv", "rename or move a remote path")
move_parser.add_argument("source")
move_parser.add_argument("target")
remove_parser = commands.add_parser("rm", help="remove a remote file or directory")
remove_parser = _add_command(commands, "rm", "remove a remote file or directory")
remove_parser.add_argument("remote")
remove_parser.add_argument("-r", "--recursive", action="store_true")
remove_parser.add_argument("-y", "--yes", action="store_true")

push_parser = commands.add_parser(
"push", aliases=["upload"], help="upload a file or directory recursively"
push_parser = _add_command(
commands,
"push",
"upload a file or directory recursively",
aliases=["upload"],
)
push_parser.add_argument("local")
push_parser.add_argument("remote", nargs="?")
_add_transfer_options(push_parser)
pull_parser = commands.add_parser(
"pull", aliases=["download"], help="download a file or directory recursively"
pull_parser = _add_command(
commands,
"pull",
"download a file or directory recursively",
aliases=["download"],
)
pull_parser.add_argument("remote")
pull_parser.add_argument("local", nargs="?")
_add_transfer_options(pull_parser, download=True)

devrun = commands.add_parser("devrun", help="read, save, or run DevRun source")
devrun = _add_command(commands, "devrun", "read, save, or run DevRun source")
devrun_commands = devrun.add_subparsers(dest="devrun_command", required=True)
devrun_read = devrun_commands.add_parser("read", help="read DevRun source")
devrun.show_help_when_command_is_missing("devrun_command")
devrun_read = _add_command(devrun_commands, "read", "read DevRun source")
devrun_read.add_argument("output", nargs="?")
devrun_read.add_argument("-f", "--force", action="store_true")
for name in ("save", "run"):
child = devrun_commands.add_parser(
name, help=f"{'save and run' if name == 'run' else 'save'} DevRun source"
description = f"{'save and run' if name == 'run' else 'save'} DevRun source"
child = _add_command(
devrun_commands,
name,
description,
)
child.add_argument("file")

app = commands.add_parser("app", help="list and install SD-card apps")
app = _add_command(commands, "app", "list and install SD-card apps")
app_commands = app.add_subparsers(dest="app_command", required=True)
app_commands.add_parser("list", help="list installed apps")
app_install = app_commands.add_parser(
"install", help="validate and upload an app directory"
app.show_help_when_command_is_missing("app_command")
_add_command(app_commands, "list", "list installed apps")
app_install = _add_command(
app_commands, "install", "validate and upload an app directory"
)
app_install.add_argument("directory")
app_install.add_argument("--id")
app_install.add_argument("-f", "--force", action="store_true")
app_remove = app_commands.add_parser(
"remove", help="remove an installed app directory"
app_remove = _add_command(
app_commands, "remove", "remove an installed app directory"
)
app_remove.add_argument("id")
app_remove.add_argument("-y", "--yes", action="store_true", required=True)
Expand Down
30 changes: 30 additions & 0 deletions implementations/python/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,36 @@ def test_usage_errors_exit_two_and_json_stdout_is_clean(self) -> None:
self.assertEqual(result.returncode, 2)
self.assertEqual(result.stdout, "")

def test_parse_errors_show_full_relevant_help(self) -> None:
with tempfile.TemporaryDirectory() as directory:
cwd = Path(directory)
config = cwd / "config.json"
cases = (
((), False, "Manage HoloCubic DevTools", "device"),
(
("unknown-command",),
True,
"Manage HoloCubic DevTools",
"device",
),
(("stat",), True, "usage: cubic-py stat", "remote"),
(("device",), False, "manage saved devices", "add"),
(
("--timeout", "invalid", "info"),
True,
"Manage HoloCubic DevTools",
"device",
),
)
for arguments, expect_error, help_marker, command_marker in cases:
with self.subTest(arguments=arguments):
result = self.run_cli(cwd, config, *arguments, check=False)
self.assertEqual(result.returncode, 2)
self.assertEqual(result.stdout, "")
self.assertEqual("error:" in result.stderr, expect_error)
self.assertIn(help_marker, result.stderr)
self.assertIn(command_marker, result.stderr)


if __name__ == "__main__":
unittest.main()
Loading