v0.2.0 — policy_slug on start_purchase + list_public_policies
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/<slug> 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.
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "keysat-licensing-client"
|
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."
|
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"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ try:
|
|||||||
ValidateResponse,
|
ValidateResponse,
|
||||||
ValidateOptions,
|
ValidateOptions,
|
||||||
StartPurchaseOptions,
|
StartPurchaseOptions,
|
||||||
|
PublicPolicy,
|
||||||
|
PublicPoliciesProduct,
|
||||||
|
PublicPoliciesResponse,
|
||||||
PurchaseSession,
|
PurchaseSession,
|
||||||
PollResponse,
|
PollResponse,
|
||||||
RedeemFreeOptions,
|
RedeemFreeOptions,
|
||||||
@@ -78,6 +81,9 @@ try:
|
|||||||
"ValidateResponse",
|
"ValidateResponse",
|
||||||
"ValidateOptions",
|
"ValidateOptions",
|
||||||
"StartPurchaseOptions",
|
"StartPurchaseOptions",
|
||||||
|
"PublicPolicy",
|
||||||
|
"PublicPoliciesProduct",
|
||||||
|
"PublicPoliciesResponse",
|
||||||
"PurchaseSession",
|
"PurchaseSession",
|
||||||
"PollResponse",
|
"PollResponse",
|
||||||
"RedeemFreeOptions",
|
"RedeemFreeOptions",
|
||||||
@@ -87,4 +93,4 @@ except ImportError:
|
|||||||
# httpx not installed — that's fine, online client is optional.
|
# httpx not installed — that's fine, online client is optional.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.2.0"
|
||||||
|
|||||||
@@ -51,10 +51,56 @@ class ValidateOptions:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class StartPurchaseOptions:
|
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_email: str | None = None
|
||||||
buyer_note: str | None = None
|
buyer_note: str | None = None
|
||||||
redirect_url: str | None = None
|
redirect_url: str | None = None
|
||||||
code: 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/<slug>`` 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
|
@dataclass
|
||||||
@@ -205,6 +251,7 @@ class Client:
|
|||||||
"buyer_note": merged.buyer_note,
|
"buyer_note": merged.buyer_note,
|
||||||
"redirect_url": merged.redirect_url,
|
"redirect_url": merged.redirect_url,
|
||||||
"code": merged.code,
|
"code": merged.code,
|
||||||
|
"policy_slug": merged.policy_slug,
|
||||||
}
|
}
|
||||||
body = {k: v for k, v in body.items() if v is not None}
|
body = {k: v for k, v in body.items() if v is not None}
|
||||||
raw = self._post("/v1/purchase", body)
|
raw = self._post("/v1/purchase", body)
|
||||||
@@ -218,6 +265,44 @@ class Client:
|
|||||||
poll_url=raw["poll_url"],
|
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/<slug>`` 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:
|
def poll_purchase(self, invoice_id: str) -> PollResponse:
|
||||||
raw = self._get(f"/v1/purchase/{invoice_id}")
|
raw = self._get(f"/v1/purchase/{invoice_id}")
|
||||||
return PollResponse(
|
return PollResponse(
|
||||||
|
|||||||
Reference in New Issue
Block a user