Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand All @@ -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
)
```
Expand All @@ -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` |
Expand Down Expand Up @@ -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) |
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 11 additions & 1 deletion src/seatlayer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
InventoryItem,
ManageCapability,
ManageSession,
TemplateInstantiateRequest,
TicketRelease,
TicketReleaseAction,
TicketReleaseList,
TicketReleaseReplaceInput,
WebhookCreateEnvelope,
WebhookDelivery,
WebhookDeliveryPage,
Expand All @@ -41,7 +46,7 @@
)
from .webhooks import WebhookVerificationError, verify_webhook

__version__ = "0.3.0"
__version__ = "0.4.0"

__all__ = [
"AccessLink",
Expand Down Expand Up @@ -69,6 +74,11 @@
"SeatLayerNotFoundError",
"SeatLayerRateLimitError",
"SeatLayerValidationError",
"TemplateInstantiateRequest",
"TicketRelease",
"TicketReleaseAction",
"TicketReleaseList",
"TicketReleaseReplaceInput",
"WebhookCreateEnvelope",
"WebhookDelivery",
"WebhookDeliveryPage",
Expand Down
12 changes: 11 additions & 1 deletion src/seatlayer/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
66 changes: 66 additions & 0 deletions src/seatlayer/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
HoldInspection,
ManageCapability,
ManageSession,
TemplateInstantiateRequest,
TicketReleaseList,
TicketReleaseReplaceInput,
WebhookCreateEnvelope,
WebhookDeliveryPage,
WebhookEnvelope,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down
50 changes: 50 additions & 0 deletions src/seatlayer/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 54 additions & 2 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,19 +113,51 @@ 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()
sdk.events.create(chart_id="c_1")
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": {}}])
Expand Down Expand Up @@ -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"}},
Expand Down
Loading