diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..2ffb06a --- /dev/null +++ b/.github/workflows/docs.yml @@ -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 diff --git a/.gitignore b/.gitignore index 3f18ee9..02f326d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ coverage.json coverage-*.json coverage-comment.md htmlcov/ +site/ diff --git a/README.md b/README.md index 1d4b50e..35befc5 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/README.md b/docs/README.md index c711d65..2514fe1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..01bc23d --- /dev/null +++ b/docs/api-reference.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index db79136..2e1a6ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/scripts/gen_docs.py b/scripts/gen_docs.py new file mode 100644 index 0000000..7c8f6a4 --- /dev/null +++ b/scripts/gen_docs.py @@ -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()) diff --git a/warpedpinball/cli.py b/warpedpinball/cli.py index 9f941b5..1894bf4 100644 --- a/warpedpinball/cli.py +++ b/warpedpinball/cli.py @@ -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) @@ -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) @@ -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] @@ -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: @@ -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) @@ -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" ) @@ -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) diff --git a/warpedpinball/machine.py b/warpedpinball/machine.py index 4cae748..60d0c67 100644 --- a/warpedpinball/machine.py +++ b/warpedpinball/machine.py @@ -147,6 +147,7 @@ def _preflight_auth(self, path: str, authenticated: bool) -> None: # -- device info ----------------------------------------------------------- def version(self) -> Any: + """Firmware version from ``/api/version`` (also cached for error messages).""" result = self.call("/api/version") if isinstance(result, dict): self._firmware_version = str( @@ -157,21 +158,27 @@ def version(self) -> Any: return result def machine_id(self) -> Any: + """Board identity from ``/api/machine_id``.""" return self.call("/api/machine_id") def game_name(self) -> Any: + """Configured game/machine name from ``/api/game/name``.""" return self.call("/api/game/name") def game_status(self) -> Any: + """Current gameplay status (balls, scores, active flag) from ``/api/game/status``.""" return self.call("/api/game/status") def active_config(self) -> Any: + """Active machine configuration from ``/api/game/active_config``.""" return self.call("/api/game/active_config") def wifi_status(self) -> Any: + """Wi-Fi connection status from ``/api/wifi/status``.""" return self.call("/api/wifi/status") def faults(self) -> Any: + """Reported hardware/firmware faults from ``/api/fault``.""" return self.call("/api/fault") def peers(self) -> Any: @@ -206,41 +213,53 @@ def wait_until_reachable(self, timeout: float = 120.0, interval: float = 2.0) -> # -- scores / players -------------------------------------------------------- def leaderboard(self) -> Any: + """High-score leaderboard from ``/api/leaders``.""" return self.call("/api/leaders") def tournament(self) -> Any: + """Tournament standings from ``/api/tournament``.""" return self.call("/api/tournament") def reset_leaderboard(self) -> Any: + """Clear the leaderboard via ``/api/leaders/reset`` (authenticated).""" return self._call_gated("/api/leaders/reset", authenticated=True) def reset_tournament(self) -> Any: + """Clear tournament standings via ``/api/tournament/reset`` (authenticated).""" return self._call_gated("/api/tournament/reset", authenticated=True) def claimable_scores(self) -> Any: + """Scores awaiting a player claim from ``/api/scores/claimable``.""" return self.call("/api/scores/claimable") def claim_score(self, initials: str, player_index: int, score: int) -> Any: + """Claim a pending score for ``initials`` via ``/api/scores/claim``.""" return self.call( "/api/scores/claim", body={"initials": initials, "player_index": player_index, "score": score}, ) def players(self) -> Any: + """Registered players from ``/api/players``.""" return self.call("/api/players") def update_player( self, id: int, initials: str, full_name: Optional[str] = None ) -> Any: + """Create/update a player's initials (and optional full name) via + ``/api/player/update`` (authenticated).""" body: Dict[str, Any] = {"id": id, "initials": initials} if full_name is not None: body["full_name"] = full_name return self._call_gated("/api/player/update", body=body, authenticated=True) def export_scores(self) -> Any: + """Export all stored scores from ``/api/export/scores``.""" return self.call("/api/export/scores") def import_scores(self, data: Any) -> Any: + """Import a previously exported score payload via ``/api/import/scores`` + (authenticated).""" return self._call_gated("/api/import/scores", body=data, authenticated=True) # -- updates ----------------------------------------------------------------- @@ -318,9 +337,12 @@ def logs(self) -> Iterator[bytes]: # -- adjustments ------------------------------------------------------------------- def adjustments(self) -> Any: + """Saved adjustment profiles and their status from ``/api/adjustments/status``.""" return self.call("/api/adjustments/status") def capture_adjustments(self, index: int) -> Any: + """Capture the machine's current adjustments into profile ``index`` + via ``/api/adjustments/capture`` (authenticated).""" return self._call_gated( "/api/adjustments/capture", body={"index": index}, authenticated=True ) @@ -332,6 +354,8 @@ def restore_adjustments(self, index: int) -> Any: ) def name_adjustment(self, index: int, name: str) -> Any: + """Label adjustment profile ``index`` via ``/api/adjustments/name`` + (authenticated).""" return self._call_gated( "/api/adjustments/name", body={"index": index, "name": name}, authenticated=True, diff --git a/warpedpinball/models.py b/warpedpinball/models.py index ae339da..1f13bd4 100644 --- a/warpedpinball/models.py +++ b/warpedpinball/models.py @@ -27,6 +27,8 @@ def _pick(raw: Any, *keys: str) -> Any: @dataclass class Score: + """A single leaderboard/tournament score. See :meth:`from_raw`.""" + initials: Optional[str] = None full_name: Optional[str] = None score: Optional[int] = None @@ -36,6 +38,7 @@ class Score: @classmethod def from_raw(cls, raw: Any) -> "Score": + """Build a :class:`Score` from one raw device payload entry.""" return cls( initials=_pick(raw, "initials"), full_name=_pick(raw, "full_name", "fullname", "name"), @@ -48,6 +51,8 @@ def from_raw(cls, raw: Any) -> "Score": @dataclass class Player: + """A registered player record. See :meth:`from_raw`.""" + id: Optional[int] = None initials: Optional[str] = None full_name: Optional[str] = None @@ -55,6 +60,10 @@ class Player: @classmethod def from_raw(cls, raw: Any, id: Optional[int] = None) -> "Player": + """Build a :class:`Player` from one raw device payload entry. + + ``id`` is used as a fallback when the payload carries no id/index field. + """ return cls( id=_pick(raw, "id", "index") if _pick(raw, "id", "index") is not None else id, initials=_pick(raw, "initials"), @@ -65,6 +74,8 @@ def from_raw(cls, raw: Any, id: Optional[int] = None) -> "Player": @dataclass class GameStatus: + """A snapshot of live gameplay state. See :meth:`from_raw`.""" + game_active: Optional[bool] = None ball_in_play: Optional[int] = None scores: Any = None @@ -72,6 +83,7 @@ class GameStatus: @classmethod def from_raw(cls, raw: Any) -> "GameStatus": + """Build a :class:`GameStatus` from a ``/api/game/status`` payload.""" return cls( game_active=_pick(raw, "game_active", "gameactive", "in_game", "active"), ball_in_play=_pick(raw, "ball_in_play", "ballinplay", "ball"), @@ -82,6 +94,8 @@ def from_raw(cls, raw: Any) -> "GameStatus": @dataclass class UpdateInfo: + """Firmware update availability. See :meth:`from_raw`.""" + current_version: Optional[str] = None available_version: Optional[str] = None url: Optional[str] = None @@ -90,6 +104,7 @@ class UpdateInfo: @classmethod def from_raw(cls, raw: Any) -> "UpdateInfo": + """Build an :class:`UpdateInfo` from a ``/api/update/check`` payload.""" return cls( current_version=_pick(raw, "current_version", "current", "version"), available_version=_pick(raw, "available_version", "latest", "new_version"),