From 84a17dd79713a6225339476269a09ba1a28189a8 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sat, 11 Jul 2026 01:54:34 +0800 Subject: [PATCH] Add API docs and PyPI publishing --- .github/workflows/ci.yml | 4 +- .github/workflows/docs.yml | 48 +++++++++++++++++ .github/workflows/release.yml | 57 ++++++++++++++++++++ .gitignore | 1 + CONTRIBUTING.md | 7 +++ README.md | 35 +++++++------ docs/api/backtesting.md | 31 +++++++++++ docs/api/index.md | 43 ++++++++++++++++ docs/api/models.md | 6 +++ docs/api/providers.md | 55 ++++++++++++++++++++ docs/api/requests.md | 6 +++ docs/api/server.md | 17 ++++++ docs/api/streaming.md | 17 ++++++ docs/index.md | 4 +- docs/releasing.md | 62 ++++++++++++++++++++++ mkdocs.yml | 97 +++++++++++++++++++++++++++++++++++ pyproject.toml | 11 ++++ scripts/verify_release.py | 55 ++++++++++++++++++++ tests/test_release.py | 38 ++++++++++++++ 19 files changed, 576 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/release.yml create mode 100644 docs/api/backtesting.md create mode 100644 docs/api/index.md create mode 100644 docs/api/models.md create mode 100644 docs/api/providers.md create mode 100644 docs/api/requests.md create mode 100644 docs/api/server.md create mode 100644 docs/api/streaming.md create mode 100644 docs/releasing.md create mode 100644 mkdocs.yml create mode 100644 scripts/verify_release.py create mode 100644 tests/test_release.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d3b351..2770aa3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,12 +22,14 @@ jobs: python-version: ${{ matrix.python-version }} cache: pip - run: python -m pip install --upgrade pip - - run: python -m pip install -e '.[dev,ccxt]' + - run: python -m pip install -e '.[dev,ccxt,release]' - run: ruff check . - run: mypy src - run: pytest - if: matrix.python-version == '3.13' run: python -m build + - if: matrix.python-version == '3.13' + run: python -m twine check --strict dist/* docker: runs-on: ubuntu-latest diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..de44292 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,48 @@ +name: Documentation + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + cache: pip + - run: python -m pip install --upgrade pip + - run: python -m pip install -e '.[docs]' + - run: mkdocs build --strict + - if: github.event_name != 'pull_request' + uses: actions/configure-pages@v5 + - if: github.event_name != 'pull_request' + uses: actions/upload-pages-artifact@v4 + with: + path: site + + deploy: + if: github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + permissions: + pages: write + id-token: write + steps: + - name: Deploy GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..35d103f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,57 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.release.tag_name }} + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + cache: pip + - run: python -m pip install --upgrade pip + - run: python -m pip install '.[release]' + - name: Verify release tag + run: python scripts/verify_release.py "${{ github.event.release.tag_name }}" + - name: Build wheel and source distribution + run: python -m build + - name: Check distribution metadata + run: python -m twine check --strict dist/* + - name: Install wheel in a clean environment + run: | + python -m venv /tmp/pineforge-wheel + /tmp/pineforge-wheel/bin/python -m pip install dist/*.whl + /tmp/pineforge-wheel/bin/python -c "import pineforge_data; assert 'Bar' in pineforge_data.__all__" + - name: Upload validated distributions + uses: actions/upload-artifact@v5 + with: + name: python-package-distributions + path: dist/ + if-no-files-found: error + + publish: + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/pineforge-data/ + permissions: + id-token: write + steps: + - name: Download validated distributions + uses: actions/download-artifact@v6 + with: + name: python-package-distributions + path: dist/ + - name: Publish to PyPI with Trusted Publishing + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 1435798..85c2360 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ __pycache__/ .venv/ build/ dist/ +site/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18bbb41..2a565b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,9 +67,16 @@ became available; using today's revised value in an old backtest is lookahead. ## Checks ```bash +python -m pip install -e '.[dev,ccxt,database,server,docs,release]' ruff check . mypy src pytest +mkdocs build --strict python -m build +python -m twine check dist/* PINEFORGE_DOCKER_TEST=1 pytest tests/test_docker_integration.py ``` + +Maintainers should follow the version, tag, artifact, and Trusted Publishing +steps in the [release guide](docs/releasing.md). Contributors cannot publish a +package from a pull request. diff --git a/README.md b/README.md index 8ad82e6..cb3d739 100644 --- a/README.md +++ b/README.md @@ -16,18 +16,19 @@ the engine receives only normalized bars and ordered trades. ## Documentation -- [Documentation home](docs/index.md) — architecture, guarantees, and guide map. -- [Getting started](docs/getting-started.md) — installation, first provider +- [Documentation home](https://pineforge-4pass.github.io/pineforge-data/) — architecture, guarantees, and guide map. +- [Getting started](https://pineforge-4pass.github.io/pineforge-data/getting-started/) — installation, first provider request, and local or remote backtest. -- [Normalized data model](docs/data-model.md) — instruments, contracts, bars, +- [Python API reference](https://pineforge-4pass.github.io/pineforge-data/api/) — generated signatures, types, and docstrings. +- [Normalized data model](https://pineforge-4pass.github.io/pineforge-data/data-model/) — instruments, contracts, bars, live trades, macro vintages, and validation rules. -- [Provider catalog](docs/providers.md) — shared lifecycle and second-level API +- [Provider catalog](https://pineforge-4pass.github.io/pineforge-data/providers/) — shared lifecycle and second-level API guides for CCXT, CSV, SQLite, and SQLAlchemy. -- [Backtesting](docs/backtesting.md) — CLI options, configuration files, runtime +- [Backtesting](https://pineforge-4pass.github.io/pineforge-data/backtesting/) — CLI options, configuration files, runtime channels, report schema, and reproducibility. -- [FastAPI server](docs/server.md) — concurrency, authentication, timeouts, +- [FastAPI server](https://pineforge-4pass.github.io/pineforge-data/server/) — concurrency, authentication, timeouts, compile cache, and deployment. -- [Provider contract](docs/provider-contract.md) — implementing and testing a +- [Provider contract](https://pineforge-4pass.github.io/pineforge-data/provider-contract/) — implementing and testing a community exchange or broker adapter. ## Why Python first @@ -129,9 +130,11 @@ schema = await provider.inspect_schema() SQL identifiers are validated against reflected metadata; filter values are bound parameters. For complex transformations, expose a database view rather than putting raw SQL in harness configuration. See the complete -[provider catalog](docs/providers.md), with dedicated API guides for -[CSV](docs/providers/csv.md), [SQLite](docs/providers/sqlite.md), and -[SQLAlchemy](docs/providers/sqlalchemy.md). +[provider catalog](https://pineforge-4pass.github.io/pineforge-data/providers/), +with dedicated API guides for +[CSV](https://pineforge-4pass.github.io/pineforge-data/providers/csv/), +[SQLite](https://pineforge-4pass.github.io/pineforge-data/providers/sqlite/), and +[SQLAlchemy](https://pineforge-4pass.github.io/pineforge-data/providers/sqlalchemy/). ## Direct backtest harness @@ -238,8 +241,8 @@ The server always transpiles Pine deterministically and hashes the generated C++. Its cache stores the compiled `.so` under a key containing that C++ hash plus the release, engine, architecture, and compile flags. Concurrent misses for the same key compile once; subsequent requests skip compilation. Cache -hit/key/hash are included in response provenance. See -[docs/server.md](docs/server.md) for endpoints, limits, deployment, and cache +hit/key/hash are included in response provenance. See the +[server guide](https://pineforge-4pass.github.io/pineforge-data/server/) for endpoints, limits, deployment, and cache settings. ## Contributing @@ -253,10 +256,10 @@ through `pineforge-release`. | Contribution | Primary location | Start here | |---|---|---| -| Exchange or broker adapter | `src/pineforge_data/providers/` | [Provider contract](docs/provider-contract.md) | +| Exchange or broker adapter | `src/pineforge_data/providers/` | [Provider contract](https://pineforge-4pass.github.io/pineforge-data/provider-contract/) | | Market, contract, bar, or request model | `src/pineforge_data/models.py`, `src/pineforge_data/requests.py`, `src/pineforge_data/providers/base.py` | Existing public models and protocols | | Backtest harness or HTTP client | `src/pineforge_data/cli/backtest.py`, `src/pineforge_data/server_client.py` | Harness unit tests | -| FastAPI concurrency or compile cache | `src/pineforge_data/server.py`, `src/pineforge_data/compile_cache.py` | [Server guide](docs/server.md) | +| FastAPI concurrency or compile cache | `src/pineforge_data/server.py`, `src/pineforge_data/compile_cache.py` | [Server guide](https://pineforge-4pass.github.io/pineforge-data/server/) | | Release-container integration | `src/pineforge_data/release_contract.py`, `src/pineforge_data/docker_runtime.py` | Pinned release contract and Docker tests | | Documentation or examples | `README.md`, `docs/` | A focused documentation PR | @@ -303,8 +306,8 @@ end-to-end backtest work. PINEFORGE_DOCKER_TEST=1 .venv/bin/pytest tests/test_docker_integration.py ``` -Read the [documentation home](docs/index.md) and -[CONTRIBUTING.md](CONTRIBUTING.md) for provider requirements, +Read the [documentation home](https://pineforge-4pass.github.io/pineforge-data/) +and [CONTRIBUTING.md](https://github.com/pineforge-4pass/pineforge-data/blob/main/CONTRIBUTING.md) for provider requirements, determinism rules, external provider entry points, and the complete checklist. For broad changes to public models or the report contract, open an issue first so providers and runtime consumers can agree on the shape before implementation. diff --git a/docs/api/backtesting.md b/docs/api/backtesting.md new file mode 100644 index 0000000..e49d31c --- /dev/null +++ b/docs/api/backtesting.md @@ -0,0 +1,31 @@ +# Backtesting + +## Runtime options and reports + +::: pineforge_data.backtest.BacktestOptions + +::: pineforge_data.backtest.MagnifierDistribution + +::: pineforge_data.backtest.BacktestReport + +::: pineforge_data.backtest.PineForgeBacktestRunner + +::: pineforge_data.backtest.EngineBacktestError + +## Published release container + +::: pineforge_data.docker_runtime.DockerBacktestRuntime + +::: pineforge_data.docker_runtime.DockerPrerequisiteError + +::: pineforge_data.docker_runtime.DockerExecutionError + +::: pineforge_data.release_contract.DEFAULT_RELEASE_IMAGE + +::: pineforge_data.release_contract.ReleaseContractError + +## Remote FastAPI client + +::: pineforge_data.server_client.FastApiBacktestClient + +::: pineforge_data.server_client.BacktestServerError diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..aa2cefa --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,43 @@ +# Python API reference + +The reference is generated from the package's type annotations, signatures, +and docstrings. Import stable user-facing objects from `pineforge_data` unless +a guide explicitly documents a submodule path. + +```python +from pineforge_data import Bar, BarRequest, CcxtProvider, Instrument +``` + +## Reference map + +| Area | Use it for | +|---|---| +| [Models](models.md) | instruments, contracts, OHLCV bars, trades, and macro vintages | +| [Requests](requests.md) | market discovery, historical ranges, live handoffs, and macro queries | +| [Providers](providers.md) | provider protocols, registry extensions, CCXT, CSV, and databases | +| [Backtesting](backtesting.md) | raw-Pine release execution, compiled strategies, reports, and remote clients | +| [Streaming and C ABI](streaming.md) | packing normalized data into native arrays and feeding live trades | +| [Server components](server.md) | embedding the FastAPI service and managing its compile cache | + +## Compatibility policy + +Names exported by `pineforge_data.__all__` are the intended public Python API. +Modules, functions, and classes whose names begin with an underscore are +internal. The package is currently alpha, so incompatible public API changes +may occur before 1.0, but releases document and version those changes rather +than silently changing an installed version. + +Provider implementations use structural protocols. A community package does +not need to inherit from a PineForge base class when it satisfies the required +typed methods. See the [provider contract](../provider-contract.md) for a +complete extension example. + +## Optional dependencies + +| Extra | Adds | +|---|---| +| `pineforge-data[ccxt]` | CCXT exchange provider | +| `pineforge-data[database]` | SQLAlchemy-compatible databases | +| `pineforge-data[server]` | FastAPI and Uvicorn server runtime | +| `pineforge-data[docs]` | local documentation build toolchain | +| `pineforge-data[release]` | distribution build and metadata checks | diff --git a/docs/api/models.md b/docs/api/models.md new file mode 100644 index 0000000..13ffe87 --- /dev/null +++ b/docs/api/models.md @@ -0,0 +1,6 @@ +# Models + +Normalized records keep vendor data out of the engine boundary while retaining +the source and instrument identity needed for reproducibility. + +::: pineforge_data.models diff --git a/docs/api/providers.md b/docs/api/providers.md new file mode 100644 index 0000000..bd72350 --- /dev/null +++ b/docs/api/providers.md @@ -0,0 +1,55 @@ +# Providers + +## Structural provider protocols + +::: pineforge_data.providers.base + +## Registry and third-party entry points + +::: pineforge_data.providers.registry.ProviderFactory + +::: pineforge_data.providers.registry.ProviderRegistry + +::: pineforge_data.providers.registry.ProviderRegistryError + +::: pineforge_data.providers.registry.ProviderNotFoundError + +::: pineforge_data.providers.registry.create_provider + +## Runtime tabular mapping + +::: pineforge_data.providers.tabular.TimestampUnit + +::: pineforge_data.providers.tabular.SourceColumn + +::: pineforge_data.providers.tabular.BarColumnMapping + +::: pineforge_data.providers.tabular.TabularSchema + +::: pineforge_data.providers.tabular.TabularBarProvider + +::: pineforge_data.providers.tabular.SchemaMappingError + +::: pineforge_data.providers.tabular.TabularDataError + +## Built-in providers + +::: pineforge_data.providers.ccxt.CcxtProvider + +::: pineforge_data.providers.local.CsvBarProvider + +::: pineforge_data.providers.local.SqliteBarProvider + +::: pineforge_data.providers.sqlalchemy.SqlAlchemyBarProvider + +## Provider errors + +::: pineforge_data.providers.ccxt.CcxtError + +::: pineforge_data.providers.ccxt.CcxtDependencyError + +::: pineforge_data.providers.ccxt.CcxtCapabilityError + +::: pineforge_data.providers.ccxt.CcxtDataError + +::: pineforge_data.providers.sqlalchemy.SqlAlchemyDependencyError diff --git a/docs/api/requests.md b/docs/api/requests.md new file mode 100644 index 0000000..f409484 --- /dev/null +++ b/docs/api/requests.md @@ -0,0 +1,6 @@ +# Requests + +Request objects describe provider-neutral filters and time ranges. Provider- +specific query parameters remain inside each adapter. + +::: pineforge_data.requests diff --git a/docs/api/server.md b/docs/api/server.md new file mode 100644 index 0000000..b4e917c --- /dev/null +++ b/docs/api/server.md @@ -0,0 +1,17 @@ +# Server components + +Most users run the packaged `pineforge-backtest-server` command or submit work +through `FastApiBacktestClient`. These objects support applications that embed +the service or manage its compiled-strategy cache directly. + +::: pineforge_data.server.create_app + +::: pineforge_data.server.BacktestService + +::: pineforge_data.server.BacktestServiceError + +::: pineforge_data.compile_cache.CompileCache + +The running service also publishes an interactive OpenAPI schema at `/docs`. +See the [server guide](../server.md) for HTTP behavior, concurrency, security, +and deployment configuration. diff --git a/docs/api/streaming.md b/docs/api/streaming.md new file mode 100644 index 0000000..185c22b --- /dev/null +++ b/docs/api/streaming.md @@ -0,0 +1,17 @@ +# Streaming and C ABI + +The dependency-free `ctypes` bridge packs normalized records into the arrays +accepted by PineForge strategy libraries. Providers remain responsible for +ordering and provenance before the handoff. + +::: pineforge_data.engine.PfBar + +::: pineforge_data.engine.PfTradeTick + +::: pineforge_data.engine.pack_bars + +::: pineforge_data.engine.pack_trade_ticks + +::: pineforge_data.engine.EngineStreamSink + +::: pineforge_data.errors.EngineStreamError diff --git a/docs/index.md b/docs/index.md index 5901be3..bdbc11f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,7 +27,9 @@ exchange / broker / macro API | Configure local and remote raw-Pine backtests | [Backtesting](backtesting.md) | | Deploy and operate the concurrent FastAPI service | [FastAPI server](server.md) | | Implement a new exchange or broker adapter | [Provider contract](provider-contract.md) | -| Prepare a contribution | [Contributing](../CONTRIBUTING.md) | +| Look up Python signatures, types, and docstrings | [Python API reference](api/index.md) | +| Publish a verified package release | [Releasing](releasing.md) | +| Prepare a contribution | [Contributing](https://github.com/pineforge-4pass/pineforge-data/blob/main/CONTRIBUTING.md) | ## Package boundaries diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..af7d821 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,62 @@ +# Releasing to PyPI + +PineForge Data publishes pure-Python wheels and source distributions through +PyPI Trusted Publishing. GitHub exchanges a short-lived OpenID Connect token +for upload permission; the repository stores no PyPI API token. + +## One-time setup + +Create the `pypi` GitHub environment and require a maintainer's approval before +deployment. Then configure a PyPI Trusted Publisher with these exact values: + +| Field | Value | +|---|---| +| PyPI project | `pineforge-data` | +| GitHub owner | `pineforge-4pass` | +| Repository | `pineforge-data` | +| Workflow | `release.yml` | +| Environment | `pypi` | + +For the first release, register a pending publisher from the +[PyPI publishing settings](https://pypi.org/manage/account/publishing/). For an +existing project, add it from that project's publishing settings. The workflow +and PyPI configuration must match exactly. + +Enable GitHub Pages with **Source: GitHub Actions** in the repository Pages +settings. The documentation workflow builds pull requests strictly and deploys +the `main` branch to `https://pineforge-4pass.github.io/pineforge-data/`. + +## Release checklist + +1. Update `project.version` in `pyproject.toml` using a valid Python package + version. +2. Update user-facing documentation and merge the release changes to `main`. +3. Run the complete local checks: + + ```bash + python -m pip install -e '.[dev,ccxt,database,server,docs,release]' + ruff check . + mypy src + pytest + mkdocs build --strict + python -m build + python -m twine check dist/* + ``` + +4. Create a GitHub Release whose tag is exactly `v`, such as + `v0.1.0`. +5. Review the generated wheel and source-distribution artifact, then approve + the `pypi` environment deployment. +6. Confirm the version and project links on PyPI and install it into a clean + environment. + +## Workflow safeguards + +The release workflow runs only for a published GitHub Release. Before upload, +it verifies that the tag exactly matches `v` plus `project.version`, builds the +wheel and source archive once, validates their long-description metadata, and +installs the wheel in a clean virtual environment. The publish job receives +only those validated artifacts and has only `id-token: write` permission. + +PyPI versions are immutable. If a release is wrong, increment the package +version and publish a correction; do not attempt to replace an existing file. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..670682f --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,97 @@ +site_name: PineForge Data +site_description: Provider-neutral market and macro data adapters for PineForge +site_url: https://pineforge-4pass.github.io/pineforge-data/ +repo_url: https://github.com/pineforge-4pass/pineforge-data +repo_name: pineforge-4pass/pineforge-data +edit_uri: edit/main/docs/ +copyright: Copyright © PineForge contributors + +theme: + name: material + features: + - content.code.copy + - content.code.annotate + - navigation.footer + - navigation.indexes + - navigation.sections + - navigation.top + - search.highlight + - search.suggest + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Use dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Use light mode + +plugins: + - search + - mkdocstrings: + handlers: + python: + paths: [src] + options: + docstring_style: google + filters: ["!^_"] + heading_level: 2 + inherited_members: false + members_order: source + merge_init_into_class: true + separate_signature: true + show_object_full_path: false + show_root_full_path: false + show_root_heading: true + show_signature_annotations: true + show_source: false + signature_crossrefs: true + +markdown_extensions: + - admonition + - attr_list + - md_in_html + - pymdownx.details + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - toc: + permalink: true + +nav: + - Home: index.md + - Getting started: getting-started.md + - Concepts: + - Data model: data-model.md + - Provider contract: provider-contract.md + - Providers: + - Provider catalog: providers.md + - Shared tabular mapping: providers/tabular-schema.md + - CCXT: providers/ccxt.md + - CSV: providers/csv.md + - SQLite: providers/sqlite.md + - SQLAlchemy: providers/sqlalchemy.md + - Backtesting: + - Harness and reports: backtesting.md + - FastAPI server: server.md + - API reference: + - Overview: api/index.md + - Models: api/models.md + - Requests: api/requests.md + - Providers: api/providers.md + - Backtesting: api/backtesting.md + - Streaming and C ABI: api/streaming.md + - Server components: api/server.md + - Maintainers: + - Releasing: releasing.md + - Contributing: https://github.com/pineforge-4pass/pineforge-data/blob/main/CONTRIBUTING.md diff --git a/pyproject.toml b/pyproject.toml index 70be95d..7e14f33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,10 +29,20 @@ pineforge-backtest-server = "pineforge_data.server:main" [project.optional-dependencies] ccxt = ["ccxt>=4.5.64,<5"] database = ["sqlalchemy>=2.0,<3"] +docs = [ + "mkdocs>=1.6,<2", + "mkdocs-material>=9.7,<10", + "mkdocstrings-python>=2.0,<3", + "ruff>=0.8", +] server = [ "fastapi>=0.139,<0.140", "uvicorn>=0.51,<0.52", ] +release = [ + "build>=1.2,<2", + "twine>=6.2,<7", +] dev = [ "build>=1.2", "fastapi>=0.139,<0.140", @@ -46,6 +56,7 @@ dev = [ [project.urls] Homepage = "https://github.com/pineforge-4pass/pineforge-data" +Documentation = "https://pineforge-4pass.github.io/pineforge-data/" Issues = "https://github.com/pineforge-4pass/pineforge-data/issues" Source = "https://github.com/pineforge-4pass/pineforge-data" diff --git a/scripts/verify_release.py b/scripts/verify_release.py new file mode 100644 index 0000000..1074124 --- /dev/null +++ b/scripts/verify_release.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Require a GitHub release tag to match the package version exactly.""" + +from __future__ import annotations + +import argparse +import sys +import tomllib +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] + + +def project_version(pyproject: Path) -> str: + """Read and validate ``project.version`` from a pyproject file.""" + + with pyproject.open("rb") as handle: + value: dict[str, Any] = tomllib.load(handle) + project = value.get("project") + if not isinstance(project, dict): + raise ValueError(f"missing [project] table in {pyproject}") + version = project.get("version") + if not isinstance(version, str) or not version.strip(): + raise ValueError(f"project.version must be a non-empty string in {pyproject}") + return version + + +def verify_release_tag(tag: str, version: str) -> None: + """Raise when ``tag`` is not exactly ``v`` followed by ``version``.""" + + expected = f"v{version}" + if tag != expected: + raise ValueError( + f"release tag {tag!r} does not match project version; expected {expected!r}" + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("tag", help="GitHub release tag, for example v0.1.0") + parser.add_argument("--pyproject", type=Path, default=ROOT / "pyproject.toml") + args = parser.parse_args(argv) + try: + version = project_version(args.pyproject) + verify_release_tag(args.tag, version) + except (OSError, tomllib.TOMLDecodeError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + print(f"release tag {args.tag} matches project version {version}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..d6c8428 --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +VERIFY_RELEASE = ROOT / "scripts" / "verify_release.py" + + +def test_release_script_accepts_matching_pep_621_version(tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "example"\nversion = "1.2.3"\n', encoding="utf-8") + + result = subprocess.run( + [sys.executable, str(VERIFY_RELEASE), "v1.2.3", "--pyproject", str(pyproject)], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0 + assert "matches project version 1.2.3" in result.stdout + + +def test_release_script_rejects_mismatched_tag(tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "example"\nversion = "1.2.3"\n', encoding="utf-8") + + result = subprocess.run( + [sys.executable, str(VERIFY_RELEASE), "v1.2.4", "--pyproject", str(pyproject)], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 1 + assert "expected 'v1.2.3'" in result.stderr