From 94654f65267b0db9f03896a0997d781e9037af90 Mon Sep 17 00:00:00 2001 From: Keysat Date: Sat, 9 May 2026 09:08:51 -0500 Subject: [PATCH] =?UTF-8?q?v0.2.0=20=E2=80=94=20policy=5Fslug=20on=20start?= =?UTF-8?q?=5Fpurchase=20+=20list=5Fpublic=5Fpolicies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the TS SDK 0.2.0 + Rust SDK 0.2.0 changes for parity across all four language clients. StartPurchaseOptions gains `policy_slug: str | None`. New `Client.list_public_policies(product_slug)` returns a `PublicPoliciesResponse` carrying the buyer-visible tier list (slug, name, price_sats, entitlements, recurring/trial flags, "Most popular" flag). Same data the licensing service's /buy/ page reads server-side — public endpoint, no auth. client.start_purchase("recap", StartPurchaseOptions( policy_slug="pro", buyer_email="buyer@example.com", )) tiers = client.list_public_policies("recap") for p in tiers.policies: print(p.slug, p.name, p.price_sats, p.entitlements) Backwards compatible — existing positional / kwarg callers continue to work since policy_slug defaults to None. --- pyproject.toml | 2 +- src/keysat_licensing_client/__init__.py | 8 ++- src/keysat_licensing_client/online.py | 85 +++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0d677d9..6b365a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "keysat-licensing-client" -version = "0.1.0" +version = "0.2.0" description = "Python client for Keysat — a self-hosted Bitcoin-paid software licensing server that runs on Start9. Verifies signed license keys offline and wraps the HTTP API for purchase, redemption, and revocation checks." readme = "README.md" requires-python = ">=3.10" diff --git a/src/keysat_licensing_client/__init__.py b/src/keysat_licensing_client/__init__.py index a9fab6a..523e23c 100644 --- a/src/keysat_licensing_client/__init__.py +++ b/src/keysat_licensing_client/__init__.py @@ -67,6 +67,9 @@ try: ValidateResponse, ValidateOptions, StartPurchaseOptions, + PublicPolicy, + PublicPoliciesProduct, + PublicPoliciesResponse, PurchaseSession, PollResponse, RedeemFreeOptions, @@ -78,6 +81,9 @@ try: "ValidateResponse", "ValidateOptions", "StartPurchaseOptions", + "PublicPolicy", + "PublicPoliciesProduct", + "PublicPoliciesResponse", "PurchaseSession", "PollResponse", "RedeemFreeOptions", @@ -87,4 +93,4 @@ except ImportError: # httpx not installed — that's fine, online client is optional. pass -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/src/keysat_licensing_client/online.py b/src/keysat_licensing_client/online.py index c3b4c3e..03b2158 100644 --- a/src/keysat_licensing_client/online.py +++ b/src/keysat_licensing_client/online.py @@ -51,10 +51,56 @@ class ValidateOptions: @dataclass class StartPurchaseOptions: + """Optional extras for :meth:`Client.start_purchase`. + + All fields are optional. To buy a specific tier, set + ``policy_slug`` to one of the slugs returned by + :meth:`Client.list_public_policies`. When omitted, the + licensing service falls back to the product's default policy. + """ + buyer_email: str | None = None buyer_note: str | None = None redirect_url: str | None = None code: str | None = None + policy_slug: str | None = None + + +@dataclass +class PublicPolicy: + """One tier returned by :meth:`Client.list_public_policies`. + + Mirrors what the licensing service's ``/buy/`` page reads + server-side, so an in-app tier picker can render identical text + and pricing. + """ + + slug: str + name: str + description: str + price_sats: int + duration_seconds: int + max_machines: int + is_trial: bool + entitlements: list[str] + highlighted: bool + is_recurring: bool + renewal_period_days: int + trial_days: int + + +@dataclass +class PublicPoliciesProduct: + slug: str + name: str + description: str + base_price_sats: int + + +@dataclass +class PublicPoliciesResponse: + product: PublicPoliciesProduct + policies: list[PublicPolicy] @dataclass @@ -205,6 +251,7 @@ class Client: "buyer_note": merged.buyer_note, "redirect_url": merged.redirect_url, "code": merged.code, + "policy_slug": merged.policy_slug, } body = {k: v for k, v in body.items() if v is not None} raw = self._post("/v1/purchase", body) @@ -218,6 +265,44 @@ class Client: poll_url=raw["poll_url"], ) + def list_public_policies(self, product_slug: str) -> PublicPoliciesResponse: + """List public, buyer-visible policies (tiers) for a product. + + No auth required — same data the licensing service's + ``/buy/`` page reads server-side. Use this to render an + in-app tier picker that stays in sync with the operator's + admin-side tier setup. Internal fields (id, tip recipients, + raw metadata) are omitted by the server. + """ + raw = self._get(f"/v1/products/{product_slug}/policies") + product = raw.get("product", {}) or {} + policies_raw = raw.get("policies") or [] + return PublicPoliciesResponse( + product=PublicPoliciesProduct( + slug=product.get("slug", ""), + name=product.get("name", ""), + description=product.get("description", "") or "", + base_price_sats=int(product.get("base_price_sats", 0)), + ), + policies=[ + PublicPolicy( + slug=p.get("slug", ""), + name=p.get("name", ""), + description=p.get("description", "") or "", + price_sats=int(p.get("price_sats", 0)), + duration_seconds=int(p.get("duration_seconds", 0)), + max_machines=int(p.get("max_machines", 1)), + is_trial=bool(p.get("is_trial", False)), + entitlements=list(p.get("entitlements") or []), + highlighted=bool(p.get("highlighted", False)), + is_recurring=bool(p.get("is_recurring", False)), + renewal_period_days=int(p.get("renewal_period_days", 0)), + trial_days=int(p.get("trial_days", 0)), + ) + for p in policies_raw + ], + ) + def poll_purchase(self, invoice_id: str) -> PollResponse: raw = self._get(f"/v1/purchase/{invoice_id}") return PollResponse(