diff --git a/.github/scripts/docs_version.py b/.github/scripts/docs_version.py new file mode 100644 index 0000000..b308232 --- /dev/null +++ b/.github/scripts/docs_version.py @@ -0,0 +1,143 @@ +"""Decide how a ``release/*`` branch publishes its docs with mike. + +Used by ``.github/workflows/docs.yml``. The docs version is the PEP 440 version +in the branch name (``release/v2.5.0`` -> ``2.5.0``, ``release/v3.0.0a2`` -> +``3.0.0a2``). The alias depends on what is already on gh-pages: + +* A stable version takes ``latest`` (and becomes the site default) when it is + at least as new as every stable version already deployed; an older one, such + as a 2.4.2 patch after 2.5.0, is deployed with no alias. +* A pre-release takes the alias for its kind (``alpha``, ``beta``, ``rc`` or + ``dev``) when it is at least as new as every deployed version of that kind, + and never touches ``latest`` or the default. + +Usage:: + + python docs_version.py + +The decision is written to ``$GITHUB_OUTPUT`` as ``version``, ``alias`` (may be +empty), ``title``, ``set_default`` and ``prerelease``, and printed. +""" + +from __future__ import annotations + +import json +import os +import sys +from dataclasses import dataclass +from typing import Any + +from packaging.version import InvalidVersion, Version + +BRANCH_PREFIX = "release/" +LATEST = "latest" +_PRE_ALIASES = {"a": "alpha", "b": "beta", "rc": "rc"} + + +@dataclass(frozen=True) +class Decision: + """How mike should deploy one version.""" + + version: str + alias: str + title: str + set_default: bool + prerelease: bool + + +def parse_branch(ref_name: str) -> Version: + """Return the PEP 440 version named by a ``release/[v]X.Y.Z...`` branch. + + Raises: + ValueError: If the branch is not under ``release/`` or its name is not + a public PEP 440 version. + """ + if not ref_name.startswith(BRANCH_PREFIX): + raise ValueError(f"branch {ref_name!r} is not a {BRANCH_PREFIX}* branch") + name = ref_name.removeprefix(BRANCH_PREFIX) + name = name.removeprefix("v") + try: + version = Version(name) + except InvalidVersion: + raise ValueError( + f"branch {ref_name!r} does not name a PEP 440 version (expected e.g. release/v2.5.0 or release/v3.0.0a2)" + ) from None + if version.local is not None: + raise ValueError(f"branch {ref_name!r} names a local version ({version}); use a public version") + return version + + +def kind(version: Version) -> str | None: + """Return the alias for a pre-release's kind, or ``None`` for a stable version. + + A ``.devN`` suffix wins over the pre-release marker, so ``3.0.0a2.dev1`` is + ``dev``. + """ + if version.dev is not None: + return "dev" + if version.pre is not None: + return _PRE_ALIASES[version.pre[0]] + return None + + +def deployed_versions(mike_list: str) -> list[Version]: + """Return the PEP 440 versions in ``mike list --json`` output. + + Empty output (no gh-pages branch yet) means nothing is deployed. Entries + whose name is not a PEP 440 version are ignored. + """ + if not mike_list.strip(): + return [] + entries: list[dict[str, Any]] = json.loads(mike_list) + versions = [] + for entry in entries: + try: + versions.append(Version(str(entry["version"]))) + except InvalidVersion: + continue + return versions + + +def decide(ref_name: str, mike_list: str) -> Decision: + """Decide the docs version, alias and title for *ref_name*.""" + version = parse_branch(ref_name) + this_kind = kind(version) + peers = [v for v in deployed_versions(mike_list) if kind(v) == this_kind] + newest = all(version >= v for v in peers) + + if this_kind is None: + alias = LATEST if newest else "" + return Decision(str(version), alias, str(version), set_default=newest, prerelease=False) + + alias = this_kind if newest else "" + return Decision(str(version), alias, f"{version} ({this_kind})", set_default=False, prerelease=True) + + +def main(argv: list[str]) -> int: + """Write the decision for ``argv[1]`` (ref name) and ``argv[2]`` (mike list JSON).""" + if len(argv) != 3: + print("usage: docs_version.py ", file=sys.stderr) + return 2 + try: + decision = decide(argv[1], argv[2]) + except ValueError as exc: + print(f"::error::{exc}") + return 1 + + lines = [ + f"version={decision.version}", + f"alias={decision.alias}", + f"title={decision.title}", + f"set_default={str(decision.set_default).lower()}", + f"prerelease={str(decision.prerelease).lower()}", + ] + print("\n".join(lines)) + output = os.environ.get("GITHUB_OUTPUT") + if output: + with open(output, "a", encoding="utf-8") as fh: + fh.write("\n".join(lines) + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74401d4..ce425d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,7 +116,7 @@ jobs: run: uv sync --group docs - name: Build docs - run: uv run mkdocs build + run: uv run mkdocs build --strict # ── Build verification ───────────────────────────────────────────────── build: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index ca73c69..400f892 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,39 +1,81 @@ name: Deploy docs -# Publishes the MkDocs site to GitHub Pages (https://swgoh-utils.github.io/comlink-python/). +# Publishes the versioned MkDocs site (https://swgoh-utils.github.io/comlink-python/) +# with mike. Every `release/*` branch publishes its own folder on the `gh-pages` +# branch, named after the PEP 440 version in the branch name, and Material's version +# picker reads the versions.json that mike keeps there. # -# Deploys from `main` only, so the published docs always describe the latest release -# rather than unreleased work on `develop`. CI's `docs` job still build-checks every PR. +# .github/scripts/docs_version.py picks the alias from what is already deployed: # -# One-time setup: Settings -> Pages -> Build and deployment -> Source: "GitHub Actions". +# branch version alias title +# release/v2.5.0 2.5.0 latest (default) 2.5.0 newest stable so far +# release/v2.4.2 2.4.2 (none) 2.4.2 older than a deployed stable +# release/v3.0.0a2 3.0.0a2 alpha 3.0.0a2 (alpha) +# release/v3.0.0b1 3.0.0b1 beta 3.0.0b1 (beta) +# release/v3.0.0rc1 3.0.0rc1 rc 3.0.0rc1 (rc) +# release/v3.0.0.dev1 3.0.0.dev1 dev 3.0.0.dev1 (dev) +# +# A stable version takes `latest` only if it is at least as new as every deployed +# stable version; a pre-release takes its kind's alias only if it is at least as new +# as every deployed version of that kind, and never touches `latest`. The root URL +# redirects to `latest`. main, develop and tags do not deploy; CI's `docs` job +# build-checks every PR. +# +# Pushes made with GITHUB_TOKEN (such as prepare-release.yml's) do not start this +# workflow; run it by hand on that release branch instead. +# +# One-time setup: Settings -> Pages -> Build and deployment -> Source: +# "Deploy from a branch", branch `gh-pages`, folder `/ (root)`. +# See "Publishing the docs" in CONTRIBUTING.md. on: push: - branches: [main] + branches: ["release/**"] paths: - "docs/**" + - "overrides/**" - "src/**" - "mkdocs.yml" - "pyproject.toml" + - "uv.lock" - ".github/workflows/docs.yml" + - ".github/scripts/docs_version.py" + # Run by hand on a release/* branch, e.g. after prepare-release.yml pushed it. + # Runs started on any other ref are skipped by the job condition below. workflow_dispatch: permissions: - contents: read + contents: write +# Deploys push to the gh-pages branch, so they queue behind one another instead +# of racing. concurrency: - group: pages + group: gh-pages cancel-in-progress: false jobs: - build: - name: Build docs + deploy: + name: Deploy versioned docs + # Only release branches of the upstream repository publish; forks and runs on + # other refs never touch gh-pages. + if: github.repository == 'swgoh-utils/comlink-python' && startsWith(github.ref, 'refs/heads/release/') runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + # Full history: hatch-vcs needs the tags, and mike builds on top of the + # existing origin/gh-pages history. + fetch-depth: 0 + # `mike deploy --push` pushes gh-pages with the token checkout stores. + # No artifacts are uploaded, so it never leaves the runner. + persist-credentials: true - name: Install uv uses: astral-sh/setup-uv@v7 + with: + # This job publishes the site, so it must not restore a cache that + # other workflows (e.g. PR runs) can write. + enable-cache: false - name: Set up Python uses: actions/setup-python@v7 @@ -43,25 +85,42 @@ jobs: - name: Install dependencies run: uv sync --group docs - - name: Build docs + - name: Pick docs version + id: version + env: + REF_NAME: ${{ github.ref_name }} + run: | + # `[]` when gh-pages does not exist yet. + deployed="$(uv run mike list --json)" + uv run --with packaging python .github/scripts/docs_version.py "$REF_NAME" "$deployed" + + - name: Build docs (strict) + env: + DOCS_PRERELEASE: ${{ steps.version.outputs.prerelease }} run: uv run mkdocs build --strict - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v5 - with: - path: site/ + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - deploy: - name: Deploy to GitHub Pages - needs: build - runs-on: ubuntu-latest - permissions: - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy - id: deployment - uses: actions/deploy-pages@v5 + - name: Deploy with mike + env: + VERSION: ${{ steps.version.outputs.version }} + ALIAS: ${{ steps.version.outputs.alias }} + TITLE: ${{ steps.version.outputs.title }} + SET_DEFAULT: ${{ steps.version.outputs.set_default }} + # Shows the pre-release banner (mkdocs.yml extra.prerelease). + DOCS_PRERELEASE: ${{ steps.version.outputs.prerelease }} + run: | + if [[ -n "$ALIAS" ]]; then + uv run mike deploy --push --update-aliases --title "$TITLE" "$VERSION" "$ALIAS" + else + uv run mike deploy --push --title "$TITLE" "$VERSION" + fi + + # Only the newest stable version gets here. Re-running it when the root + # already redirects to `latest` is a no-op (mike skips the empty commit). + if [[ "$SET_DEFAULT" == "true" ]]; then + uv run mike set-default --push latest + fi diff --git a/.gitignore b/.gitignore index 9a36a4f..a9e2eba 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,8 @@ pip-delete-this-directory.txt # Exhaustive tests and support files (owner-only, not distributed) tests/exhaustive/ scripts/ +# ...but not the workflow helpers (docs_version.py). +!/.github/scripts/ data/ gameData.json .pythonrc.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4d5a019..8ae997e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,6 +10,9 @@ repos: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml + # mkdocs.yml uses MkDocs' `!ENV` tag, which the safe loader rejects; + # `mkdocs build --strict` (CI's docs job) validates it instead. + exclude: ^mkdocs\.yml$ - id: check-toml - id: check-added-large-files - id: debug-statements diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67d5343..b72b1b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,7 @@ Thanks for your interest in contributing to comlink-python! This guide covers ev - [Submitting a Pull Request](#submitting-a-pull-request) - [Issue Guidelines](#issue-guidelines) - [Release Process](#release-process) + - [Publishing the docs](#publishing-the-docs) - [Getting Help](#getting-help) --- @@ -135,7 +136,7 @@ comlink-python/ │ │ ├── backmerge.yml # Stages the main → develop back-merge after a release │ │ ├── ci.yml # CI pipeline (lint, type-check, test, docs, build) │ │ ├── commitlint.yml # Commit message validation (runs the pre-commit hook) -│ │ ├── docs.yml # Publishes the docs site to GitHub Pages from main +│ │ ├── docs.yml # Publishes the versioned docs site (mike → gh-pages) │ │ ├── integration.yml # Integration tests against live comlink services │ │ ├── issue-fixed-in-develop.yml # Comments on issues a merge into develop resolves │ │ ├── labeler.yml # Auto-label PRs by file path @@ -186,6 +187,7 @@ comlink-python/ ├── SECURITY.md # Supported versions and vulnerability reporting ├── SUPPORT.md # Where to get help / report what ├── mkdocs.yml # Docs site configuration +├── overrides/ # MkDocs Material theme overrides (version banners) ├── pyproject.toml # Project metadata, build config, tool settings └── uv.lock # Locked dependency versions ``` @@ -559,6 +561,68 @@ This is why conventional commit messages matter — they become the changelog an - **Minor** (2.x.0): New features, backward-compatible changes - **Major** (x.0.0): Breaking API changes +### Publishing the docs + +The docs site at [swgoh-utils.github.io/comlink-python](https://swgoh-utils.github.io/comlink-python/) is versioned with [mike](https://github.com/jimporter/mike). Each version has its own copy of the site in a folder on the `gh-pages` branch, and the version picker in the header (Material for MkDocs, `extra.version.provider: mike`) switches between them. + +Deploys run **only from `release/*` branches of `swgoh-utils/comlink-python`**. [`docs.yml`](.github/workflows/docs.yml) runs after every push to one that touches the docs. The docs version is the [PEP 440](https://peps.python.org/pep-0440/) version in the branch name (`release/` and an optional `v` are stripped), and [`.github/scripts/docs_version.py`](.github/scripts/docs_version.py) picks the alias from what is already deployed: + +| Branch | Version | Alias | Picker title | +|---|---|---|---| +| `release/v2.5.0` (newest stable so far) | `2.5.0` | `latest`, the site default | `2.5.0` | +| `release/v2.4.2` after `2.5.0` is deployed | `2.4.2` | none | `2.4.2` | +| `release/v2.6.0` after `2.5.0` | `2.6.0` | `latest` (moves off `2.5.0`) | `2.6.0` | +| `release/v3.0.0a2` | `3.0.0a2` | `alpha` | `3.0.0a2 (alpha)` | +| `release/v3.0.0b1` | `3.0.0b1` | `beta` | `3.0.0b1 (beta)` | +| `release/v3.0.0rc1` | `3.0.0rc1` | `rc` | `3.0.0rc1 (rc)` | +| `release/v3.0.0.dev1`, `release/v3.0.0a2.dev1` | as named | `dev` | `… (dev)` | +| `release/v3.0.0a2` after `3.0.0a3` is deployed | `3.0.0a2` | none | `3.0.0a2 (alpha)` | +| `release/next`, `release/v2.5.0+local` | the job fails: not a public PEP 440 version | | | + +- **Stable versions** take `latest` only when they are at least as new as every stable version already deployed, so a patch for an older line never takes it. The root URL redirects to `latest`. +- **Pre-releases** take the alias for their kind (`alpha`, `beta`, `rc`, or `dev`, which wins for `aN.devM`) only when they are at least as new as every deployed version of that kind. They never touch `latest` or the default, and every page shows a "pre-release" banner ([`overrides/main.html`](overrides/main.html); the deploy sets `DOCS_PRERELEASE`, which feeds `extra.prerelease` in `mkdocs.yml`). Stable versions instead get Material's "older version" banner whenever they are not `latest`. +- **Every release is its own folder.** `2.5.0` and `2.5.1` both stay in the picker; remove ones you no longer want with `mike delete` (below). +- **Nothing else deploys.** Pushes to `main`, `develop` or tags, runs in forks, and manual runs on a non-`release/*` ref skip the job. Deleting a release branch after its PR merges leaves its docs on `gh-pages`. +- **Release timing.** `prepare-release.yml` pushes `release/vX.Y.Z` before the release PR merges and before the PyPI publish, so a stable release's docs can go live (and take `latest`) before the package is on PyPI. That push is made with `GITHUB_TOKEN`, though, and GitHub does not start workflows from `GITHUB_TOKEN` pushes: run **Actions → Deploy docs → Run workflow** on the new release branch (later pushes to the branch, such as review fixes, deploy on their own). +- Deploys queue in the `gh-pages` concurrency group, so they never race. + +**Preview the versioned site locally** without touching the remote (the deploy commits to a local `gh-pages` branch only): + +```bash +uv sync --group docs +uv run mike deploy --title "2.5.0" 2.5.0 latest # or: --title "3.0.0a2 (alpha)" 3.0.0a2 alpha +uv run mike set-default latest +uv run mike serve # http://localhost:8000/ +git branch -D gh-pages # throw the local preview away +``` + +For a quick single-version preview, `uv run mkdocs serve` is still enough; the version picker only appears under `mike serve`. + +#### One-time cutover (maintainer) + +The site used to be deployed by `actions/deploy-pages` from `main`, as a single version. To switch to the versioned layout: + +1. **Merge the versioned docs setup into `develop`** (the normal PR), so every future `release/vX.Y.Z` branch carries it. +2. **Publish 2.5.0.** `main` is the 2.5.0 release, so cut `release/v2.5.0` from `main`, bring the docs setup onto it (cherry-pick the merged commits from `develop`), and push it to `swgoh-utils/comlink-python`. The deploy creates `gh-pages` with `2.5.0/`, the `latest` alias and the root redirect. The old site stays live while the Pages source is still "GitHub Actions". By hand instead: + + ```bash + git switch release/v2.5.0 + uv sync --group docs + uv run mike deploy --push --update-aliases --title "2.5.0" 2.5.0 latest + uv run mike set-default --push latest + ``` + +3. **Switch the Pages source.** In **Settings → Pages → Build and deployment**, change **Source** from "GitHub Actions" to **"Deploy from a branch"**, branch **`gh-pages`**, folder **`/ (root)`**. Check that the root URL opens the 2.5.0 docs. +4. **Protect `gh-pages`** (optional, recommended). Add a ruleset or branch protection rule for `gh-pages` that blocks force pushes and deletion. Leave direct pushes allowed for GitHub Actions: the deploy pushes with the workflow's `GITHUB_TOKEN`, so a rule that requires pull requests or status checks would block it (or add "GitHub Actions" to the bypass list). +5. **Clean up.** The old `github-pages` environment created by `deploy-pages` can be deleted, or kept for history. + +#### Maintaining versions + +- **Remove a version** (for example a superseded pre-release): `uv run mike delete --push 3.0.0a1`. +- **Retitle** a version: `uv run mike retitle --push 3.0.0b1 "3.0.0b1 (beta, final)"`. +- **Move an alias by hand**: `uv run mike alias --push --update-aliases 2.5.1 latest`. +- **Rebuild a version** by pushing to (or re-running the workflow on) its release branch; recreate the branch from its tag if it was deleted. + --- ## Getting Help diff --git a/README.md b/README.md index fa53521..c363eff 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,7 @@ For details on enabling and customizing log output, see [docs/logging.md](https: This release replaces `requests` with `httpx` and changes how logging is configured. If you are upgrading from a previous version, see the [Migration Guide](https://github.com/swgoh-utils/comlink-python/blob/main/docs/migration.md) for a walkthrough of required changes: - Replace `requests` with `httpx` in your dependencies -- Update `except requests.RequestException` to `except httpx.RequestError` (or catch `SwgohComlinkException`) +- Update `except requests.RequestException` to `except SwgohComlinkException` (network errors are wrapped in it) - Update any `get_logger(log_level=...)` calls — configure logging via the standard `logging` module instead - Use a context manager (`with SwgohComlink() ...`) or call `close()` to release connections diff --git a/docs/migration.md b/docs/migration.md index f0f769a..a188dc0 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -27,25 +27,27 @@ you only need to upgrade the package itself. ## 2. Update exception handling -The exception raised on HTTP errors changed from `requests.RequestException` to -`httpx.RequestError`. The library still wraps transport errors in its own -`SwgohComlinkException`, so if you catch that you are already covered. +v1.x let `requests.RequestException` escape on network failures. The client now +catches the transport errors raised by `httpx` (`httpx.RequestError`: connection +refused, timeouts, DNS failures) and re-raises them as `SwgohComlinkException`, so +code that caught the `requests` exception must catch `SwgohComlinkException` +instead. Do not switch it to `httpx.RequestError`; that exception never reaches +your code. ```diff from swgoh_comlink import SwgohComlink - import requests -+ import httpx ++ from swgoh_comlink.exceptions import SwgohComlinkException comlink = SwgohComlink() try: player = comlink.get_player(allycode=245866537) - except requests.RequestException as e: -+ except httpx.RequestError as e: ++ except SwgohComlinkException as e: print(f"Network error: {e}") ``` -**Recommended:** Catch `SwgohComlinkException` instead of transport-level errors — this -insulates your code from future HTTP library changes: +To tell argument errors apart from service and network errors, catch the subclasses first: ```python from swgoh_comlink import SwgohComlink @@ -71,6 +73,12 @@ The exception hierarchy is: - `SwgohComlinkException` — base for all library errors - `SwgohComlinkValueError` (also inherits `ValueError`) — invalid argument values - `SwgohComlinkTypeError` (also inherits `TypeError`) — invalid argument types + - `SwgohComlinkHTTPError` — comlink answered with an HTTP error status (v2.3+) + - `SwgohComlinkRateLimitError` — HTTP 429 or the game's `Rate exceeded!` + - `SwgohComlinkUnavailableError` — HTTP 503 + - `SwgohComlinkClientError` — any other 4xx + +See [Exceptions](api/exceptions.md) for details. ## 3. Update logging configuration @@ -304,7 +312,7 @@ added together. | Area | Before (v1.x) | After | |------|---------------|-------| | HTTP library | `requests` | `httpx` | -| Transport exception | `requests.RequestException` | `httpx.RequestError` | +| Transport exception | `requests.RequestException` | `SwgohComlinkException` (wraps `httpx.RequestError`) | | Exception hierarchy | `SwgohComlinkException`, `SwgohComlinkValueError` | Added `SwgohComlinkTypeError` | | `get_logger(log_level=)` | Accepted `log_level`, auto-configured handlers | No `log_level` param, no auto-configuration | | Default log output | Console output at INFO level | Silent (NullHandler only) | diff --git a/mkdocs.yml b/mkdocs.yml index 6ce5be0..0194780 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,6 +7,7 @@ edit_uri: edit/develop/docs/ theme: name: material + custom_dir: overrides palette: - scheme: default primary: indigo @@ -34,6 +35,18 @@ nav: - "Helpers": api/helpers.md - "Exceptions": api/exceptions.md +extra: + # Versioned with mike: each release/* branch publishes its own folder on the + # gh-pages branch (see "Publishing the docs" in CONTRIBUTING.md). + version: + provider: mike + default: latest + alias: true + # Pre-release banner on every page (overrides/main.html). The deploy sets + # DOCS_PRERELEASE from the release branch's version; local builds use the + # default, which matches this line (2.x stable releases). + prerelease: !ENV [DOCS_PRERELEASE, false] + extra_css: - stylesheets/extra.css diff --git a/overrides/main.html b/overrides/main.html new file mode 100644 index 0000000..43bc38e --- /dev/null +++ b/overrides/main.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} + +{# + Version banners for the mike-versioned site (see "Publishing the docs" in + CONTRIBUTING.md). Every version is built from its release/* branch, and + `extra.prerelease` (set by the deploy from the branch's version) decides its + banner: + + - pre-release builds (extra.prerelease: true) always show the announce banner; + - release builds show Material's "outdated" banner, which its JavaScript + reveals only when the version being viewed is not the default (`latest`). + + The `-` whitespace control matters: Material renders a banner whenever the + block's output is non-empty, so stray whitespace would draw an empty one. +#} + +{% block announce -%} + {%- if config.extra.prerelease %} + You are viewing the docs for a pre-release of swgoh_comlink. + APIs may change before the final release. + Go to the docs for the latest stable release. + {% endif -%} +{%- endblock %} + +{% block outdated -%} + {%- if not config.extra.prerelease %} + You are viewing the docs for an older release of swgoh_comlink. + + Go to the docs for the latest release. + + {% endif -%} +{%- endblock %} diff --git a/pyproject.toml b/pyproject.toml index 0297699..0de4cb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,7 @@ include = [ "/LICENSE", "/pyproject.toml", "/mkdocs.yml", + "/overrides", ] [tool.uv.sources] @@ -131,6 +132,7 @@ dev = [ {include-group = "lint"}, ] docs = [ + "mike>=2.2.0", # MkDocs 2.0 removes the plugin system (mkdocstrings) and theme overrides that this # site depends on, with no migration path — stay on 1.x until the ecosystem settles. "mkdocs>=1.6,<2", diff --git a/tests/unit/test_docs_version.py b/tests/unit/test_docs_version.py new file mode 100644 index 0000000..48ff417 --- /dev/null +++ b/tests/unit/test_docs_version.py @@ -0,0 +1,140 @@ +"""Tests for .github/scripts/docs_version.py (docs version/alias decisions).""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +_SCRIPT = Path(__file__).resolve().parents[2] / ".github" / "scripts" / "docs_version.py" + + +def _load() -> ModuleType: + spec = importlib.util.spec_from_file_location("docs_version", _SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + # Registered first: @dataclass resolves string annotations via sys.modules. + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +docs_version = _load() + + +def _mike(*versions: str) -> str: + """Return ``mike list --json`` output listing *versions*.""" + return json.dumps([{"version": v, "title": v, "aliases": [], "properties": None} for v in versions]) + + +def _row(ref: str, *deployed: str) -> tuple[str, str, str, bool]: + d = docs_version.decide(ref, _mike(*deployed)) + return d.version, d.alias, d.title, d.set_default + + +# ── stable ────────────────────────────────────────────────────────────── + + +def test_first_stable_takes_latest_with_empty_gh_pages() -> None: + assert _row("release/v2.5.0") == ("2.5.0", "latest", "2.5.0", True) + + +def test_missing_gh_pages_output_counts_as_nothing_deployed() -> None: + d = docs_version.decide("release/v2.5.0", "") + assert (d.alias, d.set_default) == ("latest", True) + + +def test_older_patch_after_newer_stable_gets_no_alias() -> None: + assert _row("release/v2.4.2", "2.5.0") == ("2.4.2", "", "2.4.2", False) + + +def test_newer_stable_takes_latest() -> None: + assert _row("release/v2.6.0", "2.5.0", "2.4.2") == ("2.6.0", "latest", "2.6.0", True) + + +def test_redeploying_the_newest_stable_keeps_latest() -> None: + assert _row("release/v2.5.0", "2.5.0", "2.4.2")[1] == "latest" + + +def test_prereleases_do_not_hold_back_a_stable_latest() -> None: + assert _row("release/v2.6.0", "2.5.0", "3.0.0a2", "3.0.0.dev1")[1] == "latest" + + +def test_branch_name_without_v() -> None: + assert _row("release/2.5.0") == ("2.5.0", "latest", "2.5.0", True) + + +# ── pre-releases ──────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("ref", "expected"), + [ + ("release/v3.0.0a2", ("3.0.0a2", "alpha", "3.0.0a2 (alpha)", False)), + ("release/v3.0.0a3", ("3.0.0a3", "alpha", "3.0.0a3 (alpha)", False)), + ("release/v3.0.0b1", ("3.0.0b1", "beta", "3.0.0b1 (beta)", False)), + ("release/v3.0.0rc1", ("3.0.0rc1", "rc", "3.0.0rc1 (rc)", False)), + ("release/v3.0.0.dev1", ("3.0.0.dev1", "dev", "3.0.0.dev1 (dev)", False)), + ("release/v3.0.0a2.dev1", ("3.0.0a2.dev1", "dev", "3.0.0a2.dev1 (dev)", False)), + ], +) +def test_prerelease_takes_the_alias_for_its_kind(ref: str, expected: tuple[str, str, str, bool]) -> None: + assert _row(ref, "2.5.0") == expected + + +def test_prerelease_is_flagged_and_never_sets_default() -> None: + d = docs_version.decide("release/v3.0.0a2", _mike()) + assert (d.prerelease, d.set_default) == (True, False) + assert docs_version.decide("release/v2.5.0", _mike()).prerelease is False + + +def test_newer_alpha_moves_the_alpha_alias() -> None: + assert _row("release/v3.0.0a3", "2.5.0", "3.0.0a2")[1] == "alpha" + + +def test_older_alpha_after_newer_alpha_gets_no_alias() -> None: + assert _row("release/v3.0.0a2", "2.5.0", "3.0.0a3") == ("3.0.0a2", "", "3.0.0a2 (alpha)", False) + + +def test_only_same_kind_versions_compete_for_an_alias() -> None: + # A newer beta or rc does not stop an alpha from taking `alpha`. + assert _row("release/v3.0.0a3", "3.0.0b1", "3.0.0rc1")[1] == "alpha" + + +def test_prerelease_name_is_normalized() -> None: + assert _row("release/v3.0.0-alpha2")[:2] == ("3.0.0a2", "alpha") + + +# ── errors and output ─────────────────────────────────────────────────── + + +@pytest.mark.parametrize("ref", ["release/next", "release/v3.0.0-foo", "release/", "main", "release/v2.5.0+local"]) +def test_invalid_branch_names_are_rejected(ref: str) -> None: + with pytest.raises(ValueError, match="release/|PEP 440|local version"): + docs_version.decide(ref, _mike()) + + +def test_unparseable_deployed_entries_are_ignored() -> None: + assert _row("release/v2.5.0", "latest-preview")[1] == "latest" + + +def test_main_writes_github_output(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + out = tmp_path / "out" + monkeypatch.setenv("GITHUB_OUTPUT", str(out)) + assert docs_version.main(["docs_version.py", "release/v2.4.2", _mike("2.5.0")]) == 0 + assert out.read_text().splitlines() == [ + "version=2.4.2", + "alias=", + "title=2.4.2", + "set_default=false", + "prerelease=false", + ] + + +def test_main_reports_invalid_name_as_github_error(capsys: pytest.CaptureFixture[str]) -> None: + assert docs_version.main(["docs_version.py", "release/vNext", ""]) == 1 + assert capsys.readouterr().out.startswith("::error::branch 'release/vNext' does not name a PEP 440 version") diff --git a/uv.lock b/uv.lock index 12ccc47..aa734cd 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15'", @@ -409,6 +409,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "inversion" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/b5/e2d34bdeb088e3d28f5466c7c687126bba8337dcc0997c6c0ae40d4c4add/inversion-1.0.1.tar.gz", hash = "sha256:39b0d99da7924bcb21fc3e96ef2ac85dc83346461f74dd6a758ecaff3c75bb8e", size = 27028, upload-time = "2026-10-15T16:02:08.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/78/d1caf51e136f98eef9f45fd417a21f2389bf8042e8d8f3f1ed7832a500ca/inversion-1.0.1-py3-none-any.whl", hash = "sha256:a8b7b7bb1bef85318179421ef11e5222bd492f477373f0dcb80f323f8b0595d2", size = 19846, upload-time = "2026-10-15T16:02:07.701Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -524,6 +533,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, ] +[[package]] +name = "mike" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "mkdocs" }, + { name = "pyparsing" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "verspec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/47/fa87e9d56bef16cdfe34b059a437e8c6f7ec6f1b9c378871c3cf95ebea9c/mike-2.2.0.tar.gz", hash = "sha256:1e3858e32c0f125aac14432fc7848434358f9ae0962c5c5cde387ad47f6ad25e", size = 38450, upload-time = "2026-04-14T04:59:03.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl", hash = "sha256:e1f4981c1152eec7c2490a3401142292cc47d686194188416db2648fdfe1d040", size = 34026, upload-time = "2026-04-14T04:59:02.602Z" }, +] + [[package]] name = "mkdocs" version = "1.6.1" @@ -711,6 +737,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -938,6 +973,7 @@ dev = [ { name = "ty" }, ] docs = [ + { name = "mike" }, { name = "mkdocs" }, { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, @@ -973,6 +1009,7 @@ dev = [ { name = "ty", specifier = ">=0.0.34" }, ] docs = [ + { name = "mike", specifier = ">=2.2.0" }, { name = "mkdocs", specifier = ">=1.6,<2" }, { name = "mkdocs-material", specifier = ">=9.5.0" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=0.28.0" }, @@ -1083,6 +1120,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] +[[package]] +name = "verspec" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "inversion" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/e3/cf3dcb426ca8dcc1421478e75582e1fea1d37e11522e31670c4e1b26701e/verspec-0.2.1.tar.gz", hash = "sha256:2046ea165be01b3ff4a8c2319d0ced57b1d7673fb05b38a75367531acde3b534", size = 1893, upload-time = "2026-10-15T15:33:24.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/9d/b0f5248cdaae8c34a779db8cb282b6ebab760cfa349f05f5d0bb2530e508/verspec-0.2.1-py3-none-any.whl", hash = "sha256:bc192db9f51ba0c14cd3874167656f84aca1736bef002385299dab675b07377d", size = 2632, upload-time = "2026-10-15T15:33:24.111Z" }, +] + [[package]] name = "watchdog" version = "6.0.0"