diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aeec3b..1d9dea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,14 @@ ## Unreleased +- Added the public template-instantiation resource and ticket-release list, replace, and close + operations. Template instantiation uses exact header replay; ticket-release mutations remain + deliberately single-attempt. +- Prepared the additive `0.4.0` package version and replaced the obsolete chart-copy template + quick start with the public template catalog flow. + - **Security/reliability:** Mutations now default to a single attempt. Automatic header-replay - retries are limited to chart create/copy, event create, and workspace create, preventing + retries are limited to chart create/copy, template instantiation, event create, and workspace create, preventing transient failures from duplicating holds or best-available results and from issuing extra show-once credentials. - Aligned event, inventory, webhook, manage-session, and Designer requests with the generated diff --git a/README.md b/README.md index 8c5d736..6f1695b 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,8 @@ from seatlayer import SeatLayer seatlayer = SeatLayer(os.environ["SEATLAYER_SECRET_KEY"]) -# 1. Provision a venue for a new organiser from one of your templates. -chart = seatlayer.charts.copy("c_template_arena")["meta"] +# 1. Provision a venue from the public template catalog as a new draft chart. +chart = seatlayer.templates.instantiate_template("tpl_arena")["meta"] seatlayer.charts.publish(chart["id"]) # 2. Create an event on it. @@ -251,7 +251,7 @@ requests. ## Reliability **Retries and idempotency.** Reads retry 408, 429 and 5xx responses with backoff. Only chart create, -chart copy, event create and workspace create opt into mutation retries: the SDK generates one +chart copy, template instantiation, event create and workspace create opt into mutation retries: the SDK generates one `Idempotency-Key` and reuses it for every attempt. All other mutations are single-attempt, even if you supply a key. @@ -263,7 +263,7 @@ before trying again. ```python SeatLayer( os.environ["SEATLAYER_SECRET_KEY"], - max_retries=3, # attempts for reads and the four replay-safe creates + max_retries=3, # attempts for reads and the five replay-safe creates timeout=30.0, # seconds, per attempt ) ``` @@ -282,7 +282,8 @@ seatlayer.request("POST", "/v1/events/ev_1/some-new-route", body={...}) | Resource | Methods | | --- | --- | | `charts` | `list` `list_all` `create` `retrieve` `update` `delete` `copy` `archive` `unarchive` `publish` | -| `events` | `list` `list_all` `create` `retrieve` `update` `delete` `update_poster` `delete_poster` `update_chart` `close` `reopen` `archive` `retrieve_hold_ttl` `update_hold_ttl` `retrieve_report` `retrieve_log` | +| `templates` | `instantiate_template` | +| `events` | `list` `list_all` `create` `retrieve` `update` `delete` `update_poster` `delete_poster` `update_chart` `close` `reopen` `archive` `list_ticket_releases` `update_ticket_releases` `close_ticket_release` `retrieve_hold_ttl` `update_hold_ttl` `retrieve_report` `retrieve_log` | | `inventory` | `hold` `hold_best_available` `book_best_available` `extend_hold` `retrieve_hold` `release` `book` `box_office_book` `unbook` `list_bookings` `retrieve_booking` `block` `unblock` `unblock_all` `retrieve_availability` `update_availability` | | `channels` | `list_channels` `create_channel` `update_channel` `update_assignments` `list_allocation` `retrieve_access_preview` `retrieve_report` `pause` `unpause` `archive` `create_buyer_access_session` `list_buyer_access_sessions` `revoke_buyer_access_session` `create_access_link` `list_access_links` `rotate_access_link` `revoke_access_link` | | `sessions` | `create_manage_session` `revoke_manage_session` `create_designer_session` `revoke_designer_session` | @@ -329,7 +330,7 @@ the public manifest, not just from the wrapper. | Flutter | [`seatlayer`](https://pub.dev/packages/seatlayer) | | Node.js (server) | [`@seatlayer/server`](https://www.npmjs.com/package/@seatlayer/server) | | PHP (server) | [`seatlayer/seatlayer-php`](https://packagist.org/packages/seatlayer/seatlayer-php) | -| Java (server) | [`io.seatlayer:seatlayer-java`](https://central.sonatype.com/artifact/io.seatlayer/seatlayer-java/0.1.0) | +| Java (server) | [`io.seatlayer:seatlayer-java`](https://central.sonatype.com/artifact/io.seatlayer/seatlayer-java) | | Go (server) | [`github.com/seatlayer/seatlayer-go`](https://pkg.go.dev/github.com/seatlayer/seatlayer-go) | | Ruby (server) | [`seatlayer`](https://rubygems.org/gems/seatlayer) | | .NET (server) | [`SeatLayer`](https://www.nuget.org/packages/SeatLayer) | diff --git a/pyproject.toml b/pyproject.toml index 803d2b9..b299cc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "hatchling.build" [project] name = "seatlayer" -version = "0.3.0" +version = "0.4.0" description = "Official Python server SDK for the SeatLayer reserved-seating API." readme = "README.md" license = "MIT" diff --git a/src/seatlayer/__init__.py b/src/seatlayer/__init__.py index 08551de..a721898 100644 --- a/src/seatlayer/__init__.py +++ b/src/seatlayer/__init__.py @@ -31,6 +31,11 @@ InventoryItem, ManageCapability, ManageSession, + TemplateInstantiateRequest, + TicketRelease, + TicketReleaseAction, + TicketReleaseList, + TicketReleaseReplaceInput, WebhookCreateEnvelope, WebhookDelivery, WebhookDeliveryPage, @@ -41,7 +46,7 @@ ) from .webhooks import WebhookVerificationError, verify_webhook -__version__ = "0.3.0" +__version__ = "0.4.0" __all__ = [ "AccessLink", @@ -69,6 +74,11 @@ "SeatLayerNotFoundError", "SeatLayerRateLimitError", "SeatLayerValidationError", + "TemplateInstantiateRequest", + "TicketRelease", + "TicketReleaseAction", + "TicketReleaseList", + "TicketReleaseReplaceInput", "WebhookCreateEnvelope", "WebhookDelivery", "WebhookDeliveryPage", diff --git a/src/seatlayer/client.py b/src/seatlayer/client.py index c2eb41e..0145855 100644 --- a/src/seatlayer/client.py +++ b/src/seatlayer/client.py @@ -10,7 +10,16 @@ from typing import Any from .http import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, HttpClient -from .resources import Channels, Charts, Events, Inventory, Sessions, Webhooks, Workspaces +from .resources import ( + Channels, + Charts, + Events, + Inventory, + Sessions, + Templates, + Webhooks, + Workspaces, +) class SeatLayer: @@ -33,6 +42,7 @@ def __init__( self.mode = self._http.mode self.charts = Charts(self._http) + self.templates = Templates(self._http) self.events = Events(self._http) self.inventory = Inventory(self._http) self.channels = Channels(self._http) diff --git a/src/seatlayer/resources.py b/src/seatlayer/resources.py index 506444c..d94f0e0 100644 --- a/src/seatlayer/resources.py +++ b/src/seatlayer/resources.py @@ -20,6 +20,9 @@ HoldInspection, ManageCapability, ManageSession, + TemplateInstantiateRequest, + TicketReleaseList, + TicketReleaseReplaceInput, WebhookCreateEnvelope, WebhookDeliveryPage, WebhookEnvelope, @@ -184,6 +187,42 @@ def publish(self, chart_id: str) -> Any: return self._http.post(f"/v1/charts/{quote(chart_id)}/publish") +class Templates: + """Versioned catalog templates that can be instantiated as draft charts.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def instantiate_template( + self, + template_id: str, + *, + name: str | None = None, + workspace_id: str | None = None, + edited_doc: dict[str, Any] | None = None, + version: int | None = None, + sha256: str | None = None, + idempotency_key: str | None = None, + ) -> Any: + """Create a draft chart from a catalog template using exact header replay.""" + body: TemplateInstantiateRequest = {} + if name is not None: + body["name"] = name + if workspace_id is not None: + body["workspaceId"] = workspace_id + if edited_doc is not None: + body["editedDoc"] = edited_doc + if version is not None: + body["version"] = version + if sha256 is not None: + body["sha256"] = sha256 + return self._http.post_with_header_replay( + f"/v1/templates/{quote(template_id)}/instantiate", + body=body, + idempotency_key=idempotency_key, + ) + + class Events: def __init__(self, http: HttpClient) -> None: self._http = http @@ -324,6 +363,33 @@ def reopen(self, event_key: str) -> Any: def archive(self, event_key: str) -> Any: return self._http.post(f"/v1/events/{quote(event_key)}/archive") + def list_ticket_releases(self, event_key: str) -> TicketReleaseList: + return cast( + TicketReleaseList, + self._http.get(f"/v1/events/{quote(event_key)}/releases"), + ) + + def update_ticket_releases( + self, + event_key: str, + releases: Sequence[TicketReleaseReplaceInput], + ) -> TicketReleaseList: + return cast( + TicketReleaseList, + self._http.put( + f"/v1/events/{quote(event_key)}/releases", + body={"releases": list(releases)}, + ), + ) + + def close_ticket_release(self, event_key: str, release_id: str) -> TicketReleaseList: + return cast( + TicketReleaseList, + self._http.post( + f"/v1/events/{quote(event_key)}/releases/{quote(release_id)}/close" + ), + ) + def retrieve_hold_ttl(self, event_key: str) -> Any: return self._http.get(f"/v1/events/{quote(event_key)}/hold-ttl") diff --git a/src/seatlayer/types.py b/src/seatlayer/types.py index ca10c37..03be9c5 100644 --- a/src/seatlayer/types.py +++ b/src/seatlayer/types.py @@ -30,6 +30,56 @@ "event:boxoffice", ] +TicketReleaseAction = Literal["buy", "apply", "invoice"] + + +class TemplateInstantiateRequest(TypedDict, total=False): + name: str + workspaceId: str + editedDoc: dict[str, Any] + version: int + sha256: str + + +class _TicketReleaseOptional(TypedDict, total=False): + consumed: int + remaining: int | None + + +class TicketRelease(_TicketReleaseOptional): + id: str + position: int + name: str + categoryKey: str | None + price: int + previousPrice: int | None + quota: int | None + startsAt: int | None + endsAt: int | None + action: TicketReleaseAction + actionUrl: str | None + soldOutAt: int | None + + +class _TicketReleaseReplaceOptional(TypedDict, total=False): + id: str | None + categoryKey: str | None + previousPrice: int | None + quota: int | None + startsAt: int | None + endsAt: int | None + action: TicketReleaseAction + actionUrl: str | None + + +class TicketReleaseReplaceInput(_TicketReleaseReplaceOptional): + name: str + price: int + + +class TicketReleaseList(TypedDict): + releases: list[TicketRelease] + class _InventoryItemOptional(TypedDict, total=False): quantity: int diff --git a/tests/test_client.py b/tests/test_client.py index 5ea519c..a4dcbd3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -113,6 +113,7 @@ def test_generated_idempotency_key_only_on_header_replay_mutations(self) -> None {"status": 201, "body": {}}, {"status": 201, "body": {}}, {"status": 201, "body": {}}, + {"status": 201, "body": {}}, {"status": 200, "body": {}}, ]) sdk.events.list() @@ -120,12 +121,43 @@ def test_generated_idempotency_key_only_on_header_replay_mutations(self) -> None sdk.charts.create(name="Arena") sdk.charts.copy("c_1") sdk.workspaces.create(name="Promoter") + sdk.templates.instantiate_template("tpl_1") sdk.inventory.hold("ev_1", labels=["A-1"]) assert calls[0].get_header("Idempotency-key") is None - for call in calls[1:5]: + for call in calls[1:6]: assert call.get_header("Idempotency-key") - assert calls[5].get_header("Idempotency-key") is None + assert calls[6].get_header("Idempotency-key") is None + + def test_template_and_ticket_release_wire_contracts(self) -> None: + sdk, calls = make_client([ + {"status": 201, "body": {"meta": {"id": "c_1"}}}, + {"status": 200, "body": {"releases": []}}, + {"status": 200, "body": {"releases": []}}, + {"status": 200, "body": {"releases": []}}, + ]) + + sdk.templates.instantiate_template("tpl/a") + sdk.events.list_ticket_releases("ev/a") + sdk.events.update_ticket_releases( + "ev/a", + [{"name": "Early", "price": 2500, "action": "buy"}], + ) + sdk.events.close_ticket_release("ev/a", "rel/a") + + assert calls[0].full_url.endswith("/v1/templates/tpl%2Fa/instantiate") + assert json.loads(calls[0].data) == {} + assert calls[0].get_header("Idempotency-key") + assert calls[1].method == "GET" + assert calls[1].full_url.endswith("/v1/events/ev%2Fa/releases") + assert calls[2].method == "PUT" + assert json.loads(calls[2].data) == { + "releases": [{"name": "Early", "price": 2500, "action": "buy"}] + } + assert calls[2].get_header("Idempotency-key") is None + assert calls[3].method == "POST" + assert calls[3].full_url.endswith("/v1/events/ev%2Fa/releases/rel%2Fa/close") + assert calls[3].get_header("Idempotency-key") is None def test_honours_caller_supplied_idempotency_key(self) -> None: sdk, calls = make_client([{"status": 201, "body": {}}]) @@ -242,6 +274,26 @@ def test_retries_429_and_reuses_idempotency_key(self) -> None: # Same key on the retry, or the server would create two events. assert calls[0].get_header("Idempotency-key") == calls[1].get_header("Idempotency-key") + def test_template_instantiate_retries_but_release_mutations_do_not(self) -> None: + template, template_calls = make_client([ + {"status": 503, "body": {"error": "unavailable"}}, + {"status": 201, "body": {"meta": {"id": "c_1"}}}, + ]) + template.templates.instantiate_template("tpl_1") + assert len(template_calls) == 2 + assert template_calls[0].get_header("Idempotency-key") == template_calls[1].get_header( + "Idempotency-key" + ) + + releases, release_calls = make_client([ + {"status": 503, "body": {"error": "unavailable"}}, + ]) + with pytest.raises(SeatLayerError): + releases.events.update_ticket_releases( + "ev_1", [{"name": "Early", "price": 2500}] + ) + assert len(release_calls) == 1 + def test_booking_mutations_are_single_attempt_even_with_a_supplied_key(self) -> None: sdk, calls = make_client([ {"status": 500, "body": {"error": "internal"}},