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
49 changes: 49 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Docs

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

# Allow the deploy job to publish to GitHub Pages via OIDC.
permissions:
contents: read
pages: write
id-token: write

# Never run two Pages deployments at once; let a newer run cancel a queued one.
concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install package and docs tooling
run: pip install -e ".[docs,usb]"
- name: Build API documentation
run: python scripts/gen_docs.py --output site
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: site

# Publish only from the default branch. On pull requests the build job above
# still runs, so broken docs are caught without publishing them.
deploy:
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ coverage.json
coverage-*.json
coverage-comment.md
htmlcov/
site/
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ Full guides live in the
- [Examples](https://github.com/warped-pinball/python-library/blob/main/docs/examples.md):
runnable scripts, including a live animated ELVIRA hurry-up display

The complete **[API reference](https://warped-pinball.github.io/python-library/)**
is generated from the source docstrings and published to GitHub Pages on every
push to `main`. See
[docs/api-reference.md](https://github.com/warped-pinball/python-library/blob/main/docs/api-reference.md)
for how it is built and how to enable Pages.

Curious about the hardware itself? Vector boards and the machines they fit are
at [warpedpinball.com](https://warpedpinball.com).

Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ a quickstart.
| [HTTP API reference](http-api.md) | Request/response shapes for the address routes and how to reach any firmware route with `Machine.call()` |
| [CLI guide](cli.md) | The `vector` command and its subcommands |
| [Examples](examples.md) | Runnable scripts in [`examples/`](../examples), including a live animated ELVIRA hurry-up display |
| [API reference site](api-reference.md) | The auto-generated [pdoc](https://pdoc.dev) reference, how to build it locally, and how to enable GitHub Pages publishing |

## Quick orientation

Expand Down
62 changes: 62 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# API reference site (auto-generated)

The full API reference — every public module, class, method, and function,
rendered from the docstrings in the source — is generated automatically by
[pdoc](https://pdoc.dev) and published to **GitHub Pages**.

Once Pages is enabled (see below), the site lives at:

```
https://warped-pinball.github.io/python-library/
```

It rebuilds and redeploys on every push to `main`, so it always reflects the
latest released code. Pull requests build the docs too (to catch breakage)
but do not publish.

## Building the docs locally

```bash
pip install -e ".[docs]"
python scripts/gen_docs.py --output site
# open site/index.html in a browser
```

`scripts/gen_docs.py` walks the `warpedpinball` package and hands pdoc an
explicit module list. This is needed because pdoc's automatic submodule
discovery honours a package's `__all__`, and ours lists the public API names
(`connect`, `Machine`, the exception classes, ...) rather than submodule
names — so a bare `pdoc warpedpinball` would only render the top-level page.
The script picks up new modules automatically, so nothing needs updating when
the package grows.

## What you need to do to enable publishing (one time)

The CI workflow (`.github/workflows/docs.yml`) is already committed. GitHub
Pages just needs to be switched on for the repository and pointed at GitHub
Actions as its source:

1. Go to the repository on GitHub → **Settings** → **Pages**
(`https://github.com/warped-pinball/python-library/settings/pages`).
2. Under **Build and deployment** → **Source**, choose **GitHub Actions**.
(Do *not* pick "Deploy from a branch" — this project builds the site in
CI rather than committing HTML.)
3. That's it. Push to `main` (or re-run the **Docs** workflow from the
**Actions** tab via **Run workflow**) and the `deploy` job will publish
the site. The live URL is shown in the workflow run's `deploy` job and
back on the Settings → Pages screen once the first deploy finishes.

### Notes

- **Permissions.** The workflow already requests the `pages: write` and
`id-token: write` permissions it needs, so no repository-wide permission
changes are required. If your organization restricts GitHub Actions or
Pages, an org owner may need to allow Pages for this repo.
- **First deploy.** Pages is only created after the first successful `deploy`
run, so the URL may 404 for a minute or two after you enable it.
- **Private repos.** Publishing a Pages site from a private repository
requires GitHub Enterprise/Team. On a public repo it works on the free
plan.
- **Custom domain (optional).** To serve the docs from your own domain, set
it under Settings → Pages → **Custom domain**; nothing in the workflow
needs to change.
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ dependencies = ["requests>=2.25"]

[project.optional-dependencies]
usb = ["pyserial>=3.5"]
dev = ["pytest>=7", "pytest-cov>=4", "ruff>=0.4", "pyserial>=3.5", "build"]
docs = ["pdoc>=14"]
dev = ["pytest>=7", "pytest-cov>=4", "ruff>=0.4", "pyserial>=3.5", "build", "pdoc>=14"]

[project.urls]
Homepage = "https://github.com/warped-pinball/python-library"
Expand Down
93 changes: 93 additions & 0 deletions scripts/gen_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Generate the API-reference website for warpedpinball with pdoc.

pdoc's automatic submodule discovery honours a package's ``__all__``. Ours
lists the public *API* (``connect``, ``Machine``, the exception classes, ...)
rather than submodule names, so ``pdoc warpedpinball`` on its own would only
render the top-level package page. This script walks the package and hands
pdoc an explicit module list so every submodule (``machine``, ``models``,
``cli``, the transports, ...) gets its own page. New modules are picked up
automatically, so the docs stay in sync with the source.

Usage::

python scripts/gen_docs.py [--output site]
"""

from __future__ import annotations

import argparse
import importlib
import pkgutil
import sys

PACKAGE = "warpedpinball"


def module_names(package: str) -> list[str]:
"""Return the module specs pdoc needs to document the whole package.

pdoc recurses into a package's submodules on its own, but skips any that a
package's ``__all__`` hides (ours lists API names, not submodules). So we
walk the tree and explicitly list only the modules a parent's ``__all__``
would exclude; pdoc reaches everything else itself, which keeps the spec
list minimal and avoids duplicate-module warnings.
"""
specs = [package]
_collect(importlib.import_module(package), specs)
return specs


def _collect(pkg: object, specs: list[str]) -> None:
pkg_all = getattr(pkg, "__all__", None)
for info in pkgutil.iter_modules(pkg.__path__, prefix=f"{pkg.__name__}."):
child = info.name.rpartition(".")[2]
hidden = pkg_all is not None and child not in pkg_all
if hidden:
# pdoc won't reach this one via its parent; list it explicitly.
specs.append(info.name)
if info.ispkg:
_collect(importlib.import_module(info.name), specs)


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"-o",
"--output",
default="site",
help="output directory for the generated HTML (default: site)",
)
parser.add_argument(
"--docformat",
default="restructuredtext",
help="docstring format passed to pdoc (default: restructuredtext)",
)
args = parser.parse_args(argv)

try:
import pdoc.__main__
except ImportError: # pragma: no cover - surfaced to the user directly
print(
"pdoc is not installed. Install the docs extra:\n"
' pip install -e ".[docs]"',
file=sys.stderr,
)
return 1

modules = module_names(PACKAGE)
pdoc_argv = [
*modules,
"--output-directory",
args.output,
"--docformat",
args.docformat,
]
print(f"pdoc {' '.join(pdoc_argv)}")
sys.argv = ["pdoc", *pdoc_argv]
pdoc.__main__.cli()
return 0


if __name__ == "__main__":
raise SystemExit(main())
10 changes: 10 additions & 0 deletions warpedpinball/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def _parse_int(text: str) -> int:


def cmd_discover(args: argparse.Namespace) -> int:
"""``vector discover``: list Vector boards found on the LAN."""
machines = warpedpinball.discover(timeout=args.timeout)
if not machines:
print("No machines found", file=sys.stderr)
Expand All @@ -52,24 +53,28 @@ def cmd_discover(args: argparse.Namespace) -> int:


def cmd_status(args: argparse.Namespace) -> int:
"""``vector status``: print live game status as JSON."""
with _connect(args) as m:
_print_json(m.game_status())
return 0


def cmd_version(args: argparse.Namespace) -> int:
"""``vector version``: print the board firmware version."""
with _connect(args) as m:
_print_json(m.version())
return 0


def cmd_leaders(args: argparse.Namespace) -> int:
"""``vector leaders``: print the leaderboard as JSON."""
with _connect(args) as m:
_print_json(m.leaderboard())
return 0


def cmd_read(args: argparse.Namespace) -> int:
"""``vector read``: read one or more SRAM bytes at an offset."""
with _connect(args) as m:
offset = _parse_int(args.offset)
data = m.read_bytes(offset, args.count)
Expand All @@ -81,6 +86,7 @@ def cmd_read(args: argparse.Namespace) -> int:


def cmd_write(args: argparse.Namespace) -> int:
"""``vector write``: write byte value(s) to an SRAM offset."""
with _connect(args) as m:
offset = _parse_int(args.offset)
values = [_parse_int(v) for v in args.values]
Expand All @@ -90,6 +96,7 @@ def cmd_write(args: argparse.Namespace) -> int:


def cmd_snapshot(args: argparse.Namespace) -> int:
"""``vector snapshot``: dump full SRAM to a file or stdout."""
with _connect(args) as m:
data = m.memory_snapshot()
if args.output:
Expand All @@ -102,6 +109,7 @@ def cmd_snapshot(args: argparse.Namespace) -> int:


def cmd_update(args: argparse.Namespace) -> int:
"""``vector update``: check for and (with confirmation) apply a firmware update."""
with _connect(args) as m:
info = m.check_for_updates()
_print_json(info)
Expand Down Expand Up @@ -150,6 +158,7 @@ def _add_target_args(p: argparse.ArgumentParser) -> None:


def build_parser() -> argparse.ArgumentParser:
"""Build and return the top-level ``vector`` argument parser."""
parser = argparse.ArgumentParser(
prog="vector", description="Warped Pinball Vector command-line client"
)
Expand Down Expand Up @@ -201,6 +210,7 @@ def build_parser() -> argparse.ArgumentParser:


def main(argv: Optional[list] = None) -> int:
"""Console entry point: parse ``argv`` and dispatch to the chosen subcommand."""
args = build_parser().parse_args(argv)
try:
return args.func(args)
Expand Down
Loading
Loading