From f6d767b0fc0139ead55cc04220265c8978d4b8ff Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 19 Jul 2026 21:29:53 -0400 Subject: [PATCH 1/2] feat(taxonomy): export Siren's full typed taxonomy; wire mypy strict lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Siren owns its domain vocabulary (global-taxonomy-and-sdk-doctrine); this SDK is the typed carrier of it so integrations never hand-roll magic strings. - Add EventSlug (built-in ingestion slugs) and status enums: ConversionStatus, TransactionStatus, ObligationStatus, PayoutStatus, FulfillmentStatus, OpportunityStatus, ApiKeyStatus, WebhookSubscriptionStatus (all str-valued, exported from the package root). - WebhookEventType: add CREDIT_ISSUED, CREDIT_REDEEMED, CURRENCY_CREATED, CURRENCY_DELETED — dispatched by the service but missing from the SDK and openapi.yaml. - Pin the canonical vocabularies in tests/test_taxonomy.py (mirrors the service's dispatchers and REST validations). - Wire mypy --strict as the repo lint (pyproject [tool.mypy], navigator.yaml ci.lint, CI lint job); fix the two findings it surfaced. Charter 761 (SDKs speak the taxonomy). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138jQKyzwyDfEbbAeMmYNnL --- .github/workflows/ci.yml | 19 ++++++ CHANGELOG.md | 13 ++++ README.md | 8 ++- navigator.yaml | 1 + openapi.yaml | 4 ++ pyproject.toml | 6 +- src/siren/__init__.py | 19 ++++++ src/siren/_client.py | 2 +- src/siren/types.py | 132 ++++++++++++++++++++++++++++++++++++++- src/siren/webhooks.py | 4 +- tests/test_taxonomy.py | 127 +++++++++++++++++++++++++++++++++++++ tests/test_webhooks.py | 9 ++- 12 files changed, 335 insertions(+), 9 deletions(-) create mode 100644 tests/test_taxonomy.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 817ce34..a1bbbca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,22 @@ jobs: - name: Run tests run: pytest + + lint: + name: Lint (mypy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run mypy + run: mypy diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e8ef98..1dd2481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Typed taxonomy exports so integrations use SDK enums instead of magic + strings: `EventSlug` (built-in ingestion slugs) and the status vocabularies + `ConversionStatus`, `TransactionStatus`, `ObligationStatus`, `PayoutStatus`, + `FulfillmentStatus`, `OpportunityStatus`, `ApiKeyStatus`, and + `WebhookSubscriptionStatus`. +- `WebhookEventType`: added the missing `CREDIT_ISSUED`, `CREDIT_REDEEMED`, + `CURRENCY_CREATED`, and `CURRENCY_DELETED` events (also added to + `openapi.yaml`), matching the full set Siren dispatches. +- mypy strict type checking wired as the repo's lint command (`mypy`), + declared in `navigator.yaml` `ci.lint` and enforced by a CI lint job. + ## [0.1.0] - 2026-07-11 ### Added diff --git a/README.md b/README.md index 10fee8b..f8d6838 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,11 @@ client.webhooks.subscriptions.delete(subscription.id) - **Automatic retries** — exponential backoff on network errors and 429/5xx for idempotent reads and event ingestion (never for secret-minting writes). - **Fully typed** — ships `py.typed`; works with mypy and Pyright out of the box. +- **Typed taxonomy** — Siren's domain vocabulary as enums, so no magic strings + cross the boundary: `WebhookEventType`, `EventSlug`, and the status + vocabularies (`ConversionStatus`, `TransactionStatus`, `ObligationStatus`, + `PayoutStatus`, `FulfillmentStatus`, `OpportunityStatus`, `ApiKeyStatus`, + `WebhookSubscriptionStatus`). ### API keys @@ -184,7 +189,8 @@ print(page.total) client.transactions.list() client.obligations.list() -client.payouts.list(status="paid") # extra filters pass through as query args +# Extra filters pass through as query args; use the typed taxonomy for values. +client.payouts.list(status=siren.PayoutStatus.PAID) ``` ### Errors diff --git a/navigator.yaml b/navigator.yaml index f4f21f9..f564b54 100644 --- a/navigator.yaml +++ b/navigator.yaml @@ -2,6 +2,7 @@ initiative: siren ci: setup: pip install -e ".[dev]" test: pytest + lint: mypy knowledgeDependencies: - initiative: phpnomad description: "Org-wide engineering doctrine" diff --git a/openapi.yaml b/openapi.yaml index 7476aab..117ab6e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -403,6 +403,10 @@ components: - conversion.rejected - conversion.renewed - coupon.applied + - credit.issued + - credit.redeemed + - currency.created + - currency.deleted - distribution.completed - engagement.awarded - engagement.completed diff --git a/pyproject.toml b/pyproject.toml index 8fa279f..e8aa3bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ Documentation = "https://sirenaffiliates.com" Issues = "https://github.com/Novatorius/siren-python/issues" [project.optional-dependencies] -dev = ["pytest>=7.0", "respx>=0.21", "build>=1.0"] +dev = ["pytest>=7.0", "respx>=0.21", "build>=1.0", "mypy>=1.8"] [tool.hatch.build.targets.wheel] packages = ["src/siren"] @@ -64,3 +64,7 @@ include = [ [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-q" + +[tool.mypy] +strict = true +files = ["src/siren"] diff --git a/src/siren/__init__.py b/src/siren/__init__.py index ad31591..3a5e51b 100644 --- a/src/siren/__init__.py +++ b/src/siren/__init__.py @@ -30,11 +30,20 @@ ) from .types import ( ApiKey, + ApiKeyStatus, + ConversionStatus, EventResult, + EventSlug, + FulfillmentStatus, ListPage, + ObligationStatus, + OpportunityStatus, + PayoutStatus, + TransactionStatus, WebhookEvent, WebhookEventType, WebhookSubscription, + WebhookSubscriptionStatus, ) __all__ = [ @@ -59,4 +68,14 @@ "WebhookEvent", "WebhookEventType", "WebhookSubscription", + # Taxonomy + "ApiKeyStatus", + "ConversionStatus", + "EventSlug", + "FulfillmentStatus", + "ObligationStatus", + "OpportunityStatus", + "PayoutStatus", + "TransactionStatus", + "WebhookSubscriptionStatus", ] diff --git a/src/siren/_client.py b/src/siren/_client.py index 82caf64..bcf75e1 100644 --- a/src/siren/_client.py +++ b/src/siren/_client.py @@ -87,7 +87,7 @@ def request( def _backoff_delay(self, attempt: int, error: SirenError) -> float: if isinstance(error, RateLimitError) and error.retry_after is not None: return float(error.retry_after) - return min(_INITIAL_BACKOFF * (2**attempt), _MAX_BACKOFF) + return float(min(_INITIAL_BACKOFF * (2**attempt), _MAX_BACKOFF)) def parse_json(response: httpx.Response) -> Any: diff --git a/src/siren/types.py b/src/siren/types.py index 002c379..aafb676 100644 --- a/src/siren/types.py +++ b/src/siren/types.py @@ -1,4 +1,8 @@ -"""Typed result objects and the webhook event-type catalog.""" +"""Typed result objects and Siren's domain taxonomy (event and status catalogs). + +Siren owns these vocabularies; this module is the typed source for them so +integrations never hand-roll magic strings. +""" from __future__ import annotations @@ -19,6 +23,10 @@ class WebhookEventType(str, Enum): CONVERSION_REJECTED = "conversion.rejected" CONVERSION_RENEWED = "conversion.renewed" COUPON_APPLIED = "coupon.applied" + CREDIT_ISSUED = "credit.issued" + CREDIT_REDEEMED = "credit.redeemed" + CURRENCY_CREATED = "currency.created" + CURRENCY_DELETED = "currency.deleted" DISTRIBUTION_COMPLETED = "distribution.completed" ENGAGEMENT_AWARDED = "engagement.awarded" ENGAGEMENT_COMPLETED = "engagement.completed" @@ -43,6 +51,128 @@ def __str__(self) -> str: return self.value +class EventSlug(str, Enum): + """URL slugs for the built-in ingestion event types (``POST /event/{slug}``). + + ``client.events.sale()`` / ``.refund()`` / ``.site_visited()`` already use + these internally; the members exist for code that routes slugs dynamically + (e.g. wrapping ``client.events.ingest``). + """ + + SALE = "sale" + REFUND = "refund" + SITE_VISITED = "site-visited" + + def __str__(self) -> str: + return self.value + + +class ConversionStatus(str, Enum): + """Statuses a conversion can hold (``client.conversions`` records and + ``conversion.*`` webhook payloads).""" + + PENDING = "pending" + APPROVED = "approved" + REJECTED = "rejected" + EXPIRED = "expired" + #: Soft-delete bucket written by the bulk delete action. + DELETED = "deleted" + + def __str__(self) -> str: + return self.value + + +class TransactionStatus(str, Enum): + """Statuses a transaction can hold (``client.transactions`` records and + ``transaction.*`` webhook payloads).""" + + COMPLETE = "complete" + CANCELLED = "cancelled" + REFUNDED = "refunded" + + def __str__(self) -> str: + return self.value + + +class ObligationStatus(str, Enum): + """Statuses an obligation can hold (``client.obligations`` records and + ``obligation.*`` webhook payloads). + + Note: Siren's machine paths (fulfillment generation, bulk actions) write + ``complete``, while its management REST surface accepts ``fulfilled`` — + both appear in the wild, so both are listed here. + """ + + PENDING = "pending" + COMPLETE = "complete" + FULFILLED = "fulfilled" + CANCELLED = "cancelled" + + def __str__(self) -> str: + return self.value + + +class PayoutStatus(str, Enum): + """Statuses a payout can hold (``client.payouts`` records and + ``payout.*`` webhook payloads).""" + + UNPAID = "unpaid" + PROCESSING = "processing" + PAID = "paid" + FAILED = "failed" + + def __str__(self) -> str: + return self.value + + +class FulfillmentStatus(str, Enum): + """Statuses a fulfillment can hold (``fulfillment.created`` / + ``fulfillment.updated`` webhook payloads).""" + + PENDING = "pending" + PROCESSING = "processing" + COMPLETE = "complete" + FAILED = "failed" + + def __str__(self) -> str: + return self.value + + +class OpportunityStatus(str, Enum): + """Statuses an opportunity can hold (``opportunity.created`` / + ``opportunity.invalidated`` webhook payloads; the ``tracking_id`` on a + sale refers to an opportunity).""" + + ACTIVE = "active" + INACTIVE = "inactive" + #: Set by Siren's invalidation service; never operator-settable. + INVALID = "invalid" + + def __str__(self) -> str: + return self.value + + +class ApiKeyStatus(str, Enum): + """Statuses an API key can hold (``client.api_keys`` records).""" + + ACTIVE = "active" + REVOKED = "revoked" + + def __str__(self) -> str: + return self.value + + +class WebhookSubscriptionStatus(str, Enum): + """Statuses a webhook subscription can hold + (``client.webhooks.subscriptions`` records).""" + + ACTIVE = "active" + PAUSED = "paused" + + def __str__(self) -> str: + return self.value + + @dataclass(frozen=True) class EventResult: """Result of an event ingestion call. diff --git a/src/siren/webhooks.py b/src/siren/webhooks.py index afec235..dc865b9 100644 --- a/src/siren/webhooks.py +++ b/src/siren/webhooks.py @@ -6,7 +6,7 @@ import hmac import json from enum import Enum -from typing import Any, List, Optional, Sequence, Union +from typing import Any, Dict, List, Optional, Sequence, Union from ._client import HttpClient, parse_json from .errors import SignatureVerificationError @@ -48,7 +48,7 @@ def create( store it; it cannot be retrieved later. Pass ``events=[WebhookEventType.ALL]`` (``["*"]``) to subscribe to everything. """ - payload: dict = { + payload: Dict[str, Any] = { "targetUrl": target_url, "events": [_event_value(event) for event in events], } diff --git a/tests/test_taxonomy.py b/tests/test_taxonomy.py new file mode 100644 index 0000000..3ce27c3 --- /dev/null +++ b/tests/test_taxonomy.py @@ -0,0 +1,127 @@ +"""Pins the SDK's taxonomy to Siren's canonical vocabulary. + +If one of these fails, either the SDK drifted or the Siren service changed its +domain language — reconcile against the service (the taxonomy owner), not by +editing the expectation to match the code. +""" + +import siren + + +class TestWebhookEventType: + def test_matches_canonical_set(self): + expected = { + "*", + "allocation.completed", + "collaborator.created", + "collaborator.registered", + "conversion.approved", + "conversion.created", + "conversion.rejected", + "conversion.renewed", + "coupon.applied", + "credit.issued", + "credit.redeemed", + "currency.created", + "currency.deleted", + "distribution.completed", + "engagement.awarded", + "engagement.completed", + "engagement.created", + "fulfillment.created", + "fulfillment.updated", + "lead.created", + "metrics.updated", + "obligation.completed", + "obligation.created", + "opportunity.created", + "opportunity.invalidated", + "payout.created", + "payout.paid", + "refund.created", + "renewal.created", + "sale.created", + "transaction.completed", + "transaction.created", + } + assert {member.value for member in siren.WebhookEventType} == expected + assert len(siren.WebhookEventType) == len(expected) + + +class TestEventSlug: + def test_matches_built_in_ingestion_slugs(self): + assert {member.value for member in siren.EventSlug} == { + "sale", + "refund", + "site-visited", + } + + def test_is_str_enum(self): + assert isinstance(siren.EventSlug.SALE, str) + assert str(siren.EventSlug.SITE_VISITED) == "site-visited" + + +class TestStatusVocabularies: + def test_conversion_status(self): + assert {member.value for member in siren.ConversionStatus} == { + "pending", + "approved", + "rejected", + "expired", + "deleted", + } + + def test_transaction_status(self): + assert {member.value for member in siren.TransactionStatus} == { + "complete", + "cancelled", + "refunded", + } + + def test_obligation_status(self): + assert {member.value for member in siren.ObligationStatus} == { + "pending", + "complete", + "fulfilled", + "cancelled", + } + + def test_payout_status(self): + assert {member.value for member in siren.PayoutStatus} == { + "unpaid", + "processing", + "paid", + "failed", + } + + def test_fulfillment_status(self): + assert {member.value for member in siren.FulfillmentStatus} == { + "pending", + "processing", + "complete", + "failed", + } + + def test_opportunity_status(self): + assert {member.value for member in siren.OpportunityStatus} == { + "active", + "inactive", + "invalid", + } + + def test_api_key_status(self): + assert {member.value for member in siren.ApiKeyStatus} == { + "active", + "revoked", + } + + def test_webhook_subscription_status(self): + assert {member.value for member in siren.WebhookSubscriptionStatus} == { + "active", + "paused", + } + + def test_statuses_are_str_enums(self): + assert isinstance(siren.ConversionStatus.APPROVED, str) + assert str(siren.ConversionStatus.APPROVED) == "approved" + assert siren.PayoutStatus.PAID == "paid" diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 65eec42..d0654fa 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -152,8 +152,9 @@ def test_delete(self, client, mocked_api): class TestWebhookEventType: - def test_all_27_types_plus_wildcard(self): - assert len(siren.WebhookEventType) == 28 + def test_all_31_types_plus_wildcard(self): + # The full canonical set is pinned in tests/test_taxonomy.py. + assert len(siren.WebhookEventType) == 32 def test_wildcard_member(self): assert siren.WebhookEventType.ALL == "*" @@ -164,7 +165,9 @@ def test_catalog_matches_design(self): "*", "allocation.completed", "collaborator.created", "collaborator.registered", "conversion.approved", "conversion.created", "conversion.rejected", - "conversion.renewed", "coupon.applied", "distribution.completed", + "conversion.renewed", "coupon.applied", "credit.issued", + "credit.redeemed", "currency.created", "currency.deleted", + "distribution.completed", "engagement.awarded", "engagement.completed", "engagement.created", "fulfillment.created", "fulfillment.updated", "lead.created", "metrics.updated", "obligation.completed", "obligation.created", From b3ed8e833a0bf568d505f5f81aeac214d4a8a8e0 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 19 Jul 2026 21:30:48 -0400 Subject: [PATCH 2/2] docs(agents): reflect ci.lint in the verification contract Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138jQKyzwyDfEbbAeMmYNnL --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 38d430f..f9c3a53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,7 @@ after every change and leave them green: ```bash pip install -e ".[dev]" # ci.setup pytest # ci.test +mypy # ci.lint ``` What "done" means (PR audit, testing tiers, UAT proof) is defined in the KB: