diff --git a/.genignore b/.genignore index 8d6d495..99b2bef 100644 --- a/.genignore +++ b/.genignore @@ -1,7 +1,18 @@ +# NOTE: .genignore is Speakeasy's protection mechanism and is DORMANT while +# generation runs on openapi-python-client (scripts/generate.sh enforces the +# same hand-owned paths via rsync excludes). Kept current so a switch back to +# Speakeasy needs no rebuild. + # Hand-written webhook signature verification — never overwrite with generated # code. It lives inside the generated src/convoy tree so the import path stays -# `from convoy.utils.webhook import Webhook`; only this file is hand-owned. -src/convoy/utils/webhook.py +# `from convoy.utils.webhook import Webhook`; only this dir is hand-owned. +src/convoy/utils/** +src/convoy/py.typed + +# Hand-owned packaging + generator config. +pyproject.toml +.openapi-python-client.yml +scripts/** # Shared signature contract + verify unit tests (hand-authored). test/signature-vectors.json diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 6ffc446..c223629 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -19,16 +19,11 @@ jobs: python-version: "3.11" - name: Install package + # Installation must succeed — a packaging regression (e.g. wheel + # omitting webhook verify) has to fail CI, not hide. run: | python -m pip install --upgrade pip - # pyproject.toml arrives with the first Speakeasy generation. Once - # it exists, installation must succeed — a packaging regression - # (e.g. wheel omitting webhook verify) has to fail CI, not hide. - if [ -f pyproject.toml ]; then - pip install -e . - else - echo "pre-generation: no installable package yet; tests import from src/" - fi + pip install -e . pip install pytest - name: Verify hand-written modules are present @@ -38,17 +33,9 @@ jobs: test -f test/test_shared_vectors.py - name: Verify installed package exposes webhook verify - if: hashFiles('pyproject.toml') != '' # No PYTHONPATH: this must resolve from the installed distribution. run: python -c "from convoy.utils.webhook import Webhook" - name: Execute verify + shared vector tests - run: | - if [ -f pyproject.toml ]; then - # Import from the installed distribution so packaging bugs surface. - pytest test/test_webhook.py test/test_shared_vectors.py -q - else - # PEP 420 namespace packages: resolve convoy.utils.webhook from - # src/ before generation adds real __init__.py files. - PYTHONPATH=src pytest test/test_webhook.py test/test_shared_vectors.py -q - fi + # Import from the installed distribution so packaging bugs surface. + run: pytest test/test_webhook.py test/test_shared_vectors.py -q diff --git a/.github/workflows/sdk_generation.yaml b/.github/workflows/sdk_generation.yaml index 414fb40..468a376 100644 --- a/.github/workflows/sdk_generation.yaml +++ b/.github/workflows/sdk_generation.yaml @@ -1,10 +1,20 @@ name: SDK Generation +# Active generator: openapi-python-client (OSS). The Speakeasy pipeline is +# dormant in speakeasy_generation.yaml (free tier allows one generated SDK +# per workspace; convoy.js holds that slot). +# +# Keeps the same workflow filename and dispatch inputs as the Speakeasy +# version so the frain-dev/convoy dispatcher (speakeasy-sdk.yml) works +# unchanged. + on: workflow_dispatch: inputs: force: - description: Force SDK generation even if no changes are detected + # Accepted for dispatcher compatibility. Generation is deterministic + # from the spec, so there is nothing to force: no diff means no PR. + description: Accepted for compatibility; regeneration is always run required: false default: "false" type: string @@ -12,10 +22,6 @@ on: description: Branch for SDK changes required: false type: string - environment: - description: Environment variables (e.g., TAG=branch-name) - required: false - type: string schedule: - cron: "0 6 * * 1" @@ -28,20 +34,85 @@ concurrency: permissions: contents: write pull-requests: write - checks: write - statuses: write jobs: generate: - # Pin to commit SHA for v15 (mutable tags can be retargeted). - uses: speakeasy-api/sdk-generation-action/.github/workflows/workflow-executor.yaml@7276a3ae83eafb7fe37630fb1fefea3f2649debd # v15 - with: - mode: pr - force: ${{ inputs.force || 'false' }} - feature_branch: ${{ inputs.feature_branch }} - environment: ${{ inputs.environment }} - secrets: - # Prefer a PAT: PRs opened with GITHUB_TOKEN do not trigger - # pull_request workflows, so verify CI would never run on them. - github_access_token: ${{ secrets.SDK_BOT_PAT || secrets.GITHUB_TOKEN }} - speakeasy_api_key: ${{ secrets.SPEAKEASY_API_KEY }} + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + # Prefer a PAT: PRs opened with GITHUB_TOKEN do not trigger + # pull_request workflows, so verify CI would never run on them. + token: ${{ secrets.SDK_BOT_PAT || secrets.GITHUB_TOKEN }} + + - name: Setup Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.11" + + - name: Install generator + # Pin both so regeneration output is reproducible; ruff is the + # generator's post-processing formatter. + run: pip install openapi-python-client==0.29.0 ruff==0.15.22 + + - name: Regenerate client + run: ./scripts/generate.sh + + - name: Detect changes + id: diff + run: | + if git diff --quiet && [ -z "$(git status --porcelain)" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No client changes; skipping PR." >> "$GITHUB_STEP_SUMMARY" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Prepare feature branch + if: steps.diff.outputs.changed == 'true' + id: branch + env: + # Never interpolate free-form dispatch inputs into run: directly. + FEATURE_BRANCH_INPUT: ${{ inputs.feature_branch }} + run: | + if [ -n "$FEATURE_BRANCH_INPUT" ]; then + # SDK PRs must come from a reviewable feature branch, never a + # protected ref or an option-looking / metacharacter name. + case "$FEATURE_BRANCH_INPUT" in + main|master|release/*|-*|*[!a-zA-Z0-9._/-]*) + echo "::error::Invalid feature_branch '$FEATURE_BRANCH_INPUT'" + exit 1 + ;; + esac + echo "name=$FEATURE_BRANCH_INPUT" >> "$GITHUB_OUTPUT" + else + echo "name=sdk-regen-$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT" + fi + + - name: Push branch and open PR + if: steps.diff.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.SDK_BOT_PAT || secrets.GITHUB_TOKEN }} + BRANCH: ${{ steps.branch.outputs.name }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git checkout -B "$BRANCH" + git add -A + git commit -m "feat: regenerate API client from OpenAPI spec" + # Force push is safe: the regen branch is fully derived from main + # plus this deterministic generation; any previous content is stale. + git push --force origin "$BRANCH" + + existing=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') + if [ -z "$existing" ]; then + gh pr create \ + --head "$BRANCH" \ + --title "feat: regenerate API client from OpenAPI spec" \ + --body "Automated regeneration via openapi-python-client from \`docs/v3/openapi3.yaml\` on frain-dev/convoy main. Hand-written webhook verify (\`src/convoy/utils/\`) is untouched by the sync script." + echo "Opened PR for $BRANCH" >> "$GITHUB_STEP_SUMMARY" + else + echo "Updated existing PR #$existing" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/speakeasy_generation.yaml b/.github/workflows/speakeasy_generation.yaml new file mode 100644 index 0000000..6685bce --- /dev/null +++ b/.github/workflows/speakeasy_generation.yaml @@ -0,0 +1,51 @@ +name: Speakeasy SDK Generation (dormant) + +# DORMANT: Python generation moved to openapi-python-client +# (.github/workflows/sdk_generation.yaml) because the Speakeasy free tier +# allows one generated SDK per workspace and convoy.js holds that slot. +# This workflow is kept manual-only so switching back is a trigger change, +# not a rebuild. Config lives on in .speakeasy/ and .genignore. + +on: + workflow_dispatch: + inputs: + force: + description: Force SDK generation even if no changes are detected + required: false + default: "false" + type: string + feature_branch: + description: Branch for SDK changes + required: false + type: string + environment: + description: Environment variables (e.g., TAG=branch-name) + required: false + type: string + +# Serialize generations: overlapping cron/dispatch runs race on the same +# branch/PR. Queue instead of cancel so a triggered regen is never dropped. +concurrency: + group: sdk-generation + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + checks: write + statuses: write + +jobs: + generate: + # Pin to commit SHA for v15 (mutable tags can be retargeted). + uses: speakeasy-api/sdk-generation-action/.github/workflows/workflow-executor.yaml@7276a3ae83eafb7fe37630fb1fefea3f2649debd # v15 + with: + mode: pr + force: ${{ inputs.force || 'false' }} + feature_branch: ${{ inputs.feature_branch }} + environment: ${{ inputs.environment }} + secrets: + # Prefer a PAT: PRs opened with GITHUB_TOKEN do not trigger + # pull_request workflows, so verify CI would never run on them. + github_access_token: ${{ secrets.SDK_BOT_PAT || secrets.GITHUB_TOKEN }} + speakeasy_api_key: ${{ secrets.SPEAKEASY_API_KEY }} diff --git a/.gitignore b/.gitignore index ccc2d4b..3eceeb5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ build convoy_python.egg-info dist/ __pycache__/ -*.pyc \ No newline at end of file +*.pyc +.venv-proof/ +.ruff_cache/ diff --git a/.openapi-python-client.yml b/.openapi-python-client.yml new file mode 100644 index 0000000..dbb1737 --- /dev/null +++ b/.openapi-python-client.yml @@ -0,0 +1,5 @@ +# openapi-python-client config (active generator). +# PyPI name stays convoy-python; imports stay `from convoy ...` so the +# hand-written verify path (convoy/utils/webhook.py) keeps working. +package_name_override: convoy +project_name_override: convoy-python diff --git a/MIGRATION.md b/MIGRATION.md index 2a2faa4..aed0248 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,15 +1,19 @@ -# convoy-python 1.x migration (Speakeasy) +# convoy-python 1.x migration ## What changed -- The **public HTTP API client** will be generated from Convoy's OpenAPI spec (`docs/v3/openapi3.yaml`) via [Speakeasy](https://www.speakeasy.com/). -- **Webhook signature verification stays hand-written.** Generators do not own crypto. `src/convoy/utils/webhook.py` and the shared `test/signature-vectors.json` contract remain the source of truth for verify (see `.genignore`). +- The **public HTTP API client** is generated from Convoy's OpenAPI spec (`docs/v3/openapi3.yaml`) via [openapi-python-client](https://github.com/openapi-generators/openapi-python-client). +- **Webhook signature verification stays hand-written.** Generators do not own crypto. `src/convoy/utils/webhook.py` and the shared `test/signature-vectors.json` contract remain the source of truth for verify. The generation sync script (`scripts/generate.sh`) never touches `src/convoy/utils/`. + +## Generator choice + +Generation originally bootstrapped on Speakeasy, but the Speakeasy free tier allows one generated SDK per workspace and `convoy.js` holds that slot. The Speakeasy pipeline is kept **dormant** (`.speakeasy/`, `.genignore`, `.github/workflows/speakeasy_generation.yaml`) so the provider can be switched back without a rebuild; the active pipeline is openapi-python-client (`.github/workflows/sdk_generation.yaml`). ## Breaking change policy -Shipping the Speakeasy client is an intentional **1.x** break from the hand-written `0.x` surfaces. Method shapes are **not** silently preserved. +Shipping the generated client is an intentional **1.x** break from the hand-written `0.x` surfaces. Method shapes are **not** silently preserved. -1. This bootstrap PR wires Speakeasy, removes the deprecated hand-written HTTP client, and relocates verify to `src/convoy/utils/webhook.py` (inside the generated module tree, so `from convoy.utils.webhook import Webhook` keeps resolving — `moduleName: convoy` in `.speakeasy/gen.yaml`). +1. This PR wires openapi-python-client generation; the hand-written HTTP client stays removed and verify lives at `src/convoy/utils/webhook.py` (inside the generated module tree, so `from convoy.utils.webhook import Webhook` keeps resolving — `package_name_override: convoy` in `.openapi-python-client.yml`). 2. The first `sdk_generation.yaml` run opens a PR that adds the OpenAPI-generated client and publishes as `1.x`. 3. Consumers pin `0.x` until they migrate call sites. @@ -28,8 +32,9 @@ if not webhook.verify_signature(payload, signature): ## Regenerating the API client -CI on `frain-dev/convoy` triggers Speakeasy when OpenAPI artifacts change. Locally (requires `SPEAKEASY_API_KEY`): +CI on `frain-dev/convoy` dispatches `sdk_generation.yaml` when OpenAPI artifacts change. Locally: ```bash -speakeasy run +pip install openapi-python-client==0.29.0 ruff==0.15.22 +./scripts/generate.sh ``` diff --git a/README.md b/README.md index 6a17e27..6b53d0f 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,6 @@ Please see [CONTRIBUTING](CONTRIBUTING.MD) for details. The MIT License (MIT). Please see [License File](LICENSE) for more information. -## Speakeasy-generated API client +## Generated API client -The HTTP API client is generated from Convoy OpenAPI via Speakeasy. **Webhook signature verification remains hand-written** (`convoy/utils/webhook.py`) and is covered by shared `test/signature-vectors.json`. See [MIGRATION.md](./MIGRATION.md). +The HTTP API client is generated from Convoy's OpenAPI spec via [openapi-python-client](https://github.com/openapi-generators/openapi-python-client). **Webhook signature verification remains hand-written** (`convoy/utils/webhook.py`) and is covered by shared `test/signature-vectors.json`. See [MIGRATION.md](./MIGRATION.md). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c022eb6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "convoy-python" +version = "1.0.0a1" +description = "Convoy Python SDK: OpenAPI-generated API client and hand-written webhook signature verification" +readme = "README.md" +license = { text = "MIT" } +authors = [{ name = "Frain Inc." }] +requires-python = ">=3.11" +dependencies = [ + "httpx>=0.23.1,<0.29.0", + "attrs>=22.2.0", +] + +[project.urls] +Homepage = "https://github.com/frain-dev/convoy-python" +Documentation = "https://getconvoy.io/docs" + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +# namespaces=true (the default) so `convoy` resolves both before the first +# generation run (only convoy/utils exists) and after (generated __init__.py). +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +convoy = ["py.typed"] diff --git a/scripts/generate.sh b/scripts/generate.sh new file mode 100755 index 0000000..4d2b1cc --- /dev/null +++ b/scripts/generate.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Regenerate the API client from Convoy's OpenAPI spec with +# openapi-python-client, then sync it into src/convoy/ without touching +# hand-owned paths (webhook verify, py.typed). +# +# Requires: openapi-python-client, rsync, curl. Run from the repo root. + +SPEC_URL="${SPEC_URL:-https://raw.githubusercontent.com/frain-dev/convoy/main/docs/v3/openapi3.yaml}" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +curl -fsSL "$SPEC_URL" -o "$tmp/openapi3.yaml" + +# --meta none emits only the package contents; project metadata is hand-owned +# in pyproject.toml. +# --fail-on-warning: a warning means the generator skipped part of the spec; +# never mirror a partial client into src/convoy (rsync --delete would drop +# previously generated modules). +openapi-python-client generate \ + --path "$tmp/openapi3.yaml" \ + --config .openapi-python-client.yml \ + --meta none \ + --fail-on-warning \ + --output-path "$tmp/gen" + +# --delete keeps src/convoy an exact mirror of generator output; excluded +# paths are hand-written and never created or removed by this script. +# .ruff_cache is a side effect of the generator's post-processing formatter. +rsync -a --delete \ + --exclude 'utils/' \ + --exclude 'py.typed' \ + --exclude '.ruff_cache/' \ + "$tmp/gen/" src/convoy/ + +echo "Generated client synced into src/convoy/" diff --git a/src/convoy/py.typed b/src/convoy/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/convoy/utils/__init__.py b/src/convoy/utils/__init__.py new file mode 100644 index 0000000..656e6eb --- /dev/null +++ b/src/convoy/utils/__init__.py @@ -0,0 +1 @@ +"""Hand-written utilities: webhook signature verification lives here."""