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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*.yml text eol=lf
*.md text eol=lf
*.toml text eol=lf
*.lock text eol=lf
*.py text eol=lf
*.rs text eol=lf
LICENSE text eol=lf
45 changes: 45 additions & 0 deletions .github/workflows/ci-conformance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Bootstrap conformance

on:
push:
branches: [main]
paths:
- "implementations/**"
- "spec/**"
- "tools/conformance.py"
- ".github/workflows/ci-conformance.yml"
pull_request:
paths:
- "implementations/**"
- "spec/**"
- "tools/conformance.py"
- ".github/workflows/ci-conformance.yml"
workflow_dispatch:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: implementations/node/package-lock.json
- uses: actions/setup-python@v6
with:
python-version: "3.13"
- run: npm ci
working-directory: implementations/node
- run: npm run build
working-directory: implementations/node
- run: cargo build --locked
working-directory: implementations/rust
- run: >-
python tools/conformance.py
--node implementations/node/dist/cli.js
--python
--rust implementations/rust/target/debug/cubic-rs
46 changes: 46 additions & 0 deletions .github/workflows/ci-python.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Python CI

on:
push:
branches: [main]
paths:
- "implementations/python/**"
- "spec/**"
- ".github/workflows/ci-python.yml"
pull_request:
paths:
- "implementations/python/**"
- "spec/**"
- ".github/workflows/ci-python.yml"
workflow_dispatch:

permissions:
contents: read

defaults:
run:
working-directory: implementations/python

jobs:
test:
name: ${{ matrix.os }} / Python ${{ matrix.python }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest, macos-latest]
python: ["3.10", "3.13"]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: implementations/python/pyproject.toml
- run: python -m pip install --upgrade build
- run: python -m unittest discover -s tests -v
env:
PYTHONPATH: src
- run: python -m build
- run: python -m pip install .
- run: cubic-py --version
38 changes: 38 additions & 0 deletions .github/workflows/ci-rust.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Rust CI

on:
push:
branches: [main]
paths:
- "implementations/rust/**"
- "spec/**"
- ".github/workflows/ci-rust.yml"
pull_request:
paths:
- "implementations/rust/**"
- "spec/**"
- ".github/workflows/ci-rust.yml"
workflow_dispatch:

permissions:
contents: read

defaults:
run:
working-directory: implementations/rust

jobs:
test:
name: ${{ matrix.os }} / stable
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v6
- run: cargo fmt --check
- run: cargo clippy --all-targets -- -D warnings
- run: cargo test --locked
- run: cargo build --release --locked
- run: cargo package --locked
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ DevTools HTTP API.
| Implementation | Status | Package | Command |
| --- | --- | --- | --- |
| [Node.js](implementations/node) | Beta candidate | `@tim-1e/holocubic-cli` | `cubic` |
| Python | Planned experiment | Not published | `cubic-py` |
| Rust | Planned experiment | Not published | `cubic-rs` |
| [Python](implementations/python) | Bootstrap experiment | Not published | `cubic-py` |
| [Rust](implementations/rust) | Bootstrap experiment | Not published | `cubic-rs` |

Node.js is the current reference implementation. Python and Rust builds will
be evaluated against the same API and CLI compatibility contract before they
are considered stable.
Node.js is the current reference implementation. Python and Rust currently
implement only the read-only bootstrap handshake and are evaluated against the
same compatibility fixture before they are considered stable.

## Shared contract

Expand All @@ -22,6 +22,9 @@ The deployed firmware API and compatibility rules are documented in
designs, but filesystem safety, HTTP behavior, JSON output, and exit-code
semantics should remain compatible.

The first shared black-box slice is documented in
[`spec/cli-bootstrap-v1.md`](spec/cli-bootstrap-v1.md).

## Node.js quick start

```sh
Expand Down
2 changes: 1 addition & 1 deletion implementations/node/docs/RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ This checklist intentionally stops before `npm publish`.
- [x] `npm run verify:package`
- [x] Windows local verification
- [x] Clean Linux verification with official Node 22 and 24 Docker images
- [ ] GitHub Actions Windows/Ubuntu/macOS matrix
- [x] GitHub Actions Windows/Ubuntu/macOS matrix
- [x] Real-device E2E with cleanup

## Package review
Expand Down
3 changes: 2 additions & 1 deletion implementations/node/docs/TEST_RESULTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Date: 2026-07-14
- The repository's `.github/workflows/ci-node.yml` covers Windows, Ubuntu, and
macOS with Node 22 and 24.
- The workflow passes `actionlint` locally.
- macOS execution remains pending until the repository is pushed to GitHub.
- The first pushed matrix completed successfully on Windows, Ubuntu, and macOS
for both Node 22 and Node 24.
- npm publication was not attempted. The registry currently returns E404 for
`@tim-1e/holocubic-cli`; npm scope ownership still needs user confirmation.
21 changes: 21 additions & 0 deletions implementations/python/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Tim-1e

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
21 changes: 21 additions & 0 deletions implementations/python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# HoloCubic CLI — Python experiment

This package is an experimental Python implementation of the shared HoloCubic
CLI bootstrap contract. It currently supports version output and the read-only
device handshake; use the Node.js implementation for file and app operations.

The `Private :: Do Not Upload` classifier intentionally prevents accidental
PyPI publication while the package name and release policy are under review.

```sh
python -m pip install .
cubic-py --version
cubic-py --host 192.0.2.42 --json info
```

Development checks:

```sh
python -m unittest discover -s tests -v
python -m build
```
26 changes: 26 additions & 0 deletions implementations/python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[build-system]
requires = ["hatchling>=1.26,<2"]
build-backend = "hatchling.build"

[project]
name = "holocubic-cli-python"
version = "0.1.0a1"
description = "Experimental Python CLI for the HoloCubic DevTools API"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Tim-1e" }]
keywords = ["holocubic", "esp32", "cli", "devtools"]
classifiers = ["Private :: Do Not Upload"]
dependencies = []

[project.scripts]
cubic-py = "holocubic_cli_python.cli:main"

[project.urls]
Repository = "https://github.com/Tim-1e/holocubic-cli"
Issues = "https://github.com/Tim-1e/holocubic-cli/issues"

[tool.hatch.build.targets.wheel]
packages = ["src/holocubic_cli_python"]
3 changes: 3 additions & 0 deletions implementations/python/src/holocubic_cli_python/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Experimental Python implementation of the HoloCubic CLI."""

__version__ = "0.1.0a1"
3 changes: 3 additions & 0 deletions implementations/python/src/holocubic_cli_python/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .cli import main

raise SystemExit(main())
44 changes: 44 additions & 0 deletions implementations/python/src/holocubic_cli_python/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import argparse
import json
import os
import sys
from collections.abc import Sequence

from . import __version__
from .client import fetch_info


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="cubic-py", description="Experimental Python HoloCubic CLI")
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
parser.add_argument("--host", help="device host or DevTools URL")
parser.add_argument("--timeout", type=int, default=60_000, metavar="MILLISECONDS")
parser.add_argument("--json", action="store_true", dest="as_json")
subcommands = parser.add_subparsers(dest="command", required=True)
subcommands.add_parser("info", help="show device capabilities and transfer limits")
return parser


def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
host = args.host or os.environ.get("CUBIC_HOST")
if not host:
parser.error("a device target is required through --host or CUBIC_HOST")

try:
info = fetch_info(host, args.timeout)
except (ValueError, RuntimeError) as error:
print(f"cubic-py: {error}", file=sys.stderr)
return 1

if args.as_json:
print(json.dumps(info, ensure_ascii=False, separators=(",", ":")))
else:
print(f"URL: {info['url']}")
print(f"Version: {info['version'] or 'unknown'}")
print(f"API: v{info['api_version']}")
print(f"Root: {info['root_path']}")
print(f"Chunk size: {info['chunk_size']} bytes")
print(f"Max file: {info['max_file_size']} bytes")
return 0
85 changes: 85 additions & 0 deletions implementations/python/src/holocubic_cli_python/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import json
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

from .url import normalize_device_url

LEGACY_CAPABILITIES = [
"fs.list",
"fs.stat",
"fs.read",
"fs.write",
"fs.mkdir",
"fs.rename",
"fs.remove",
"fs.rmdir",
"apps.list",
"devrun.read",
"devrun.save",
"devrun.run",
]


def _positive_integer(value: Any, field: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise ValueError(f"Device response has an invalid {field}.")
return value


def public_info(raw: Any, url: str) -> dict[str, Any]:
if not isinstance(raw, dict) or raw.get("ok") is not True:
raise ValueError("Device handshake did not return ok=true.")

api_version = raw.get("api_version", 1)
if isinstance(api_version, bool) or not isinstance(api_version, int) or api_version != 1:
raise ValueError(f"Unsupported DevTools API version: {api_version}.")
if raw.get("root_path") != "/sd":
raise ValueError("Device response has an invalid root_path.")

chunk_size = _positive_integer(raw.get("chunk_size"), "chunk_size")
max_file_size = _positive_integer(raw.get("max_file_size"), "max_file_size")
run_app_id = raw.get("run_app_id")
run_app_main = raw.get("run_app_main")
if not isinstance(run_app_id, str) or not isinstance(run_app_main, str):
raise ValueError("Device response is missing run app metadata.")
if not run_app_main.startswith("/sd/"):
raise ValueError("Device response has an invalid run_app_main.")

capabilities = raw.get("capabilities")
if not isinstance(capabilities, list) or not all(isinstance(item, str) for item in capabilities):
capabilities = LEGACY_CAPABILITIES
max_code_bytes = raw.get("max_code_bytes", 192 * 1024)
max_code_bytes = _positive_integer(max_code_bytes, "max_code_bytes")

return {
"name": None,
"url": url,
"version": raw.get("version") if isinstance(raw.get("version"), str) else None,
"api_version": api_version,
"route_base": "/devtools",
"root_path": "/sd",
"chunk_size": chunk_size,
"max_file_size": max_file_size,
"max_code_bytes": max_code_bytes,
"run_app_id": run_app_id,
"run_app_main": run_app_main,
"capabilities": capabilities,
}


def fetch_info(host: str, timeout_ms: int = 60_000) -> dict[str, Any]:
if timeout_ms <= 0:
raise ValueError("Timeout must be greater than zero.")
base_url = normalize_device_url(host)
request = Request(f"{base_url}/api/info", headers={"Accept": "application/json"})
try:
with urlopen(request, timeout=timeout_ms / 1000) as response:
raw = json.load(response)
except HTTPError as error:
raise RuntimeError(f"Device returned HTTP {error.code} for GET /api/info.") from error
except URLError as error:
raise RuntimeError(f"Could not connect to HoloCubic: {error.reason}") from error
except (json.JSONDecodeError, UnicodeDecodeError) as error:
raise RuntimeError("Device returned invalid JSON for GET /api/info.") from error
return public_info(raw, base_url)
Loading
Loading