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
34 changes: 17 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![Python](https://img.shields.io/pypi/pyversions/seatlayer.svg)](https://pypi.org/project/seatlayer/)
[![License: MIT](https://img.shields.io/badge/license-MIT-111827.svg)](LICENSE)

SeatLayer is interactive seating chart software built for stadium scale. Platforms embed the white-label seat picker with their own checkout; organizers sell seated events on their own website with their own payment gateway.
The official Python client for the SeatLayer API. The `seatlayer` package lets a Python backend inspect seat holds, price orders from server data, book reserved seats and verify webhooks, with no runtime dependencies. SeatLayer is seating chart and reserved-seat ticketing software built for venues up to stadium scale.

SeatLayer's official Python server SDK is the trusted side of its reserved seating and seat
booking API. Inspect what a hold really contains, price from server-owned seating-chart data,
Expand Down Expand Up @@ -94,7 +94,7 @@ Version `0.7.0` exposes all 48 trusted organizer operations through
After the test hold/book/cancel journey and matching webhook deliveries,
`validate_season_buyer_rehearsal(season_key)` sends no evidence body; SeatLayer
discovers the retained chain automatically. Retrieved Season holds contain
inventory identity, not an authoritative amount—your platform owns package
inventory identity, not an authoritative amount. Your platform owns package
price, payment, order, tax, refunds, benefits, and ticket or pass delivery.

```python
Expand Down Expand Up @@ -123,7 +123,7 @@ if os.environ.get("ENV") == "production" and seatlayer.mode != "live":
## Book reserved seats from Python

**Buyer picks seats in the browser.** Your frontend holds them; your backend confirms the price and
books. Never price from what the browser sent you — `retrieve_hold` is authoritative.
books. Never price from what the browser sent you: `retrieve_hold` is authoritative.

```python
hold = seatlayer.inventory.retrieve_hold(event_key, hold_id)
Expand All @@ -139,7 +139,7 @@ seatlayer.inventory.book(event_key, hold_id=hold_id, booking_ref=charge.id)
**Your backend picks the seats.** Phone orders, box office, comps.

```python
# Payment already taken — book outright, so nothing is stranded if a second call fails.
# Payment already taken: book outright, so nothing is stranded if a second call fails.
seatlayer.inventory.book_best_available(event_key, qty=2, booking_ref="phone-1183")

# Or name the seats yourself.
Expand Down Expand Up @@ -170,7 +170,7 @@ pause/archive controls, audit-safe session listing, and channel reports are on
## Listing and pagination

`list()` returns one page plus a `nextCursor`. When you want everything, `list_all()` pages for you
and yields as it goes — a generator rather than a list, because the point of paginating is to *not*
and yields as it goes. It is a generator rather than a list, because the point of paginating is to *not*
hold an unbounded result set in memory.

```python
Expand All @@ -185,16 +185,16 @@ for event in seatlayer.events.list_all():
```

Listing events includes live availability `counts` by default, which costs the server one
round-trip **per event**. `list_all()` turns them off automatically — walking a whole catalogue is
exactly when you don't want that — and you can control it explicitly:
round-trip **per event**. `list_all()` turns them off automatically, since walking a whole catalogue is
exactly when you don't want that, and you can control it explicitly:

```python
seatlayer.events.list(limit=50, counts=False)
```

## Keeping a hold alive

When an order takes longer than the checkout window — an invoice, a phone sale — extend rather than
When an order takes longer than the checkout window (an invoice, a phone sale), extend rather than
release and re-hold. Releasing first hands the seats to whoever is racing for them in between.

```python
Expand All @@ -203,7 +203,7 @@ from seatlayer import SeatLayerConflictError
try:
seatlayer.inventory.extend_hold(event_key, hold_id, ttl_ms=10 * 60_000)
except SeatLayerConflictError:
# Gone, expired, or at its renewal cap — the buyer has to re-pick.
# Gone, expired, or at its renewal cap: the buyer has to re-pick.
...
```

Expand Down Expand Up @@ -241,8 +241,8 @@ The full set, all opt-in:
| `event:door:checkin` | Check tickets in and out |
| `event:boxoffice` | Use the managed box-office surface |

The two `event:channels:*` capabilities are **not** in the default — a token minted before sales
channels existed must not silently acquire channel authority — so ask for them explicitly if the
The two `event:channels:*` capabilities are **not** in the default (a token minted before sales
channels existed must not silently acquire channel authority), so ask for them explicitly if the
page manages channels.

The same pattern embeds the Designer in your own UI:
Expand Down Expand Up @@ -287,7 +287,7 @@ def seatlayer_webhook():
return "", 400

# The signed body carries `at`, but nothing enforces a freshness window, so
# a captured delivery stays valid indefinitely. Deduplicate on occurrenceId —
# a captured delivery stays valid indefinitely. Deduplicate on occurrenceId:
# this is your replay protection, not an optimisation.
if already_processed(event["occurrenceId"]):
return "", 200
Expand Down Expand Up @@ -315,7 +315,7 @@ except SeatLayerAuthError as error:
raise
```

Every error carries `status`, `code`, `body`, and `request_id` — quote the request id in support
Every error carries `status`, `code`, `body`, and `request_id`. Quote the request id in support
requests.

## Reliability
Expand Down Expand Up @@ -386,15 +386,15 @@ Some API surface is intentionally unwrapped, not merely pending:
There is no server-side subscribe; a secret-key caller gets authoritative state from
`events.retrieve_report()` and `inventory.retrieve_availability()`.

None of these are reachable through `request()` as a supported path either — they are excluded from
None of these are reachable through `request()` as a supported path either; they are excluded from
the public manifest, not just from the wrapper.

## Frequently asked questions

### How do I book seats from Python?

Create a client with your secret key, obtain a hold id — either from the buyer's
browser session or by holding server-side — and call `inventory.book(event_key, hold_id=..., booking_ref=...)`.
Create a client with your secret key, obtain a hold id (either from the buyer's
browser session or by holding server-side) and call `inventory.book(event_key, hold_id=..., booking_ref=...)`.
`booking_ref` is your own stable order id and is the join between SeatLayer
inventory and your commercial order, so the same reference identifies the booking
in Booking History and when you later cancel it. For phone orders, box office, and
Expand All @@ -417,7 +417,7 @@ and at what price, so charge from its `items` rather than from anything the brow
sent you. When an order runs longer than the checkout window, `inventory.extend_hold`
renews the hold instead of releasing and re-holding, which would hand the seats to
whoever is racing for them. Bookings carry the server's exact-selection plus
`booking_ref` safeguard, but the SDK sends each booking once — reconcile an unknown
`booking_ref` safeguard, but the SDK sends each booking once; reconcile an unknown
outcome before trying again.

### Can I use my own payment provider?
Expand Down
214 changes: 40 additions & 174 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,192 +1,58 @@
# Releasing `seatlayer` to PyPI

Publishing checklist for the SeatLayer Python server SDK. This package has **never been
published** — the first upload permanently claims the name `seatlayer` on PyPI.
Releases are published by `.github/workflows/release.yml` through PyPI Trusted
Publishing (OIDC). No API token is stored in this repository or in its Actions
secrets: PyPI issues a short-lived token for the one workflow run.

Everything below has been validated locally except the steps marked
**[needs a live account]**, which cannot be run until the account exists.
## One-time setup

---
- The `seatlayer` project on PyPI lists this repository and `release.yml` as a
trusted publisher.
- The publish job runs in the `pypi` GitHub environment. Add required reviewers
to that environment if a tag push should wait for approval before publishing.

## 1. What the owner must create first
## Release steps

### PyPI account
1. Update the version in **both** places, which must agree:
- `pyproject.toml` (`version`)
- `src/seatlayer/__init__.py` (`__version__`)
2. Add a dated entry at the top of `CHANGELOG.md`.
3. Run the gate locally:

| Item | Value |
|---|---|
| Registry | https://pypi.org (and https://test.pypi.org for the rehearsal) |
| Project name | `seatlayer` — verified **unclaimed** as of 2026-08-04 |
| Account | A PyPI account with **2FA enabled** (mandatory for all uploads since 2024) |
| Recovery | Save the 2FA recovery codes somewhere the team can reach them |
```bash
python -m venv .venv && .venv/bin/pip install -e ".[dev]" build twine
.venv/bin/ruff check src tests
.venv/bin/mypy
.venv/bin/pytest -q
```

> **Name squatting is the one mistake nothing can undo.** If `seatlayer` is taken between now and
> the upload, the name is gone permanently — PyPI does not transfer names on request except in
> narrow dispute cases. If the sweep is going to be delayed, claim the name early with a `0.0.0`
> placeholder rather than waiting.
4. Merge the release commit to `main`, then tag it and push the tag:

### Credential: pick ONE of these two paths
```bash
git tag v0.8.1 && git push origin v0.8.1
```

#### Path A — API token (simplest; right for a manual first publish)
The workflow re-runs the gate, refuses to publish if the tag and either version
literal disagree, builds the sdist and wheel, checks that the wheel carries
`py.typed` and no `tests/` or `.github/` files, and then publishes.

1. PyPI → Account settings → **API tokens** → *Add API token*.
2. **First upload only:** the project does not exist yet, so a project-scoped token cannot be
created. You must issue an **account-scoped** token (scope: *Entire account*).
3. Upload once (step 4 below). The project now exists.
4. **Immediately after:** create a new token scoped to **Project: seatlayer**, use that from then
on, and **revoke the account-scoped token**. An account-scoped token can publish or yank *every*
project you own; it should not outlive its one job.

Token format is `pypi-AgEIcHlwaS5vcmc…`. Use it with username `__token__`.

#### Path B — Trusted Publishing / OIDC (better long term; recommended once CI publishes)

No long-lived secret exists at all: PyPI mints a short-lived token for a specific GitHub Actions
workflow run. This is the better path and it is what the fleet should converge on, but note:

- It requires the GitHub repo `seatlayer/seatlayer-python` to **exist and be pushed** — it does not
today (`git remote -v` points at it, but nothing has been pushed).
- Configure it as a **pending publisher** (PyPI → *Your projects* → *Publishing* → *Add a pending
publisher*) so it works for the very first upload of a project that does not exist yet. You need:
owner `seatlayer`, repo `seatlayer-python`, workflow filename (e.g. `release.yml`), and
optionally a GitHub environment name.
- The publishing workflow needs `permissions: id-token: write` and
`pypa/gh-action-pypi-publish@release/v1`. **No release workflow exists in this repo yet** — only
`.github/workflows/ci.yml`. Writing it is a prerequisite for Path B.

**Recommendation:** use Path A for this first manual sweep, then move to Path B before 0.2.0.

---

## 2. Pre-flight (all verified locally on 2026-08-04)

```bash
cd python
python -m venv .venv && .venv/bin/pip install -e ".[dev]" build twine

.venv/bin/ruff check src tests # → All checks passed!
.venv/bin/mypy # → Success: no issues found in 6 source files
.venv/bin/pytest -q # → 33 passed
```

Confirm before building:

- [ ] `CHANGELOG.md` top entry reads `## 0.1.0 — 2026-08-04`. **If the sweep has slipped past that
date, change it** — the date is baked into the sdist and shown on the PyPI page.
- [ ] `pyproject.toml` `version` and `src/seatlayer/__init__.py` `__version__` both read `0.1.0`.
These are two separate literals with nothing enforcing agreement; check them by eye.
- [ ] Working tree is clean and the release commit is tagged (`git tag v0.1.0`).

---

## 3. Build and inspect
## Verify

```bash
rm -rf dist
.venv/bin/python -m build
.venv/bin/twine check dist/* # → PASSED for both artifacts
python -m venv /tmp/verify
/tmp/verify/bin/pip install seatlayer==0.8.1
/tmp/verify/bin/python -c "import seatlayer; print(seatlayer.__version__)"
```

The wheel must contain **exactly** these seven package files plus `dist-info`:

```
seatlayer/__init__.py client.py errors.py http.py py.typed resources.py webhooks.py
seatlayer-0.1.0.dist-info/{METADATA,WHEEL,RECORD,licenses/LICENSE}
```

- `py.typed` **must** be present — without it every downstream mypy/pyright user silently loses all
type information and sees `module is installed, but missing library stubs or py.typed marker`.
- No `tests/`, no `AGENTS.md`, no `.github/` in the wheel.
- The sdist deliberately **does** keep `tests/` (redistributors build from it) but must **not**
contain `AGENTS.md` or `.github/`.

Verify the type marker actually works before uploading:

```bash
python -m venv /tmp/tc && /tmp/tc/bin/pip install dist/*.whl mypy
printf 'from seatlayer import SeatLayer\nreveal_type(SeatLayer("sk_test_x").mode)\n' > /tmp/c.py
/tmp/tc/bin/mypy --strict /tmp/c.py # must reveal "str", NOT "Any"
```

---

## 4. Publish

Rehearse on TestPyPI first — it is a separate account and a separate token, and it is the only
way to see the rendered project page before the real name is spent.

```bash
# Rehearsal (needs a separate test.pypi.org account + token)
.venv/bin/twine upload --repository testpypi dist/*

# Real thing
.venv/bin/twine upload dist/*
# Username: __token__
# Password: pypi-AgEIcHlwaS5vcmc…
```

Non-interactive alternative:

```bash
TWINE_USERNAME=__token__ TWINE_PASSWORD='pypi-…' .venv/bin/twine upload dist/*
```

Then push the tag:

```bash
git push origin main --tags
```

---

## 5. Verify the published artifact

```bash
python -m venv /tmp/verify && cd /tmp/verify
bin/pip install seatlayer # must resolve from PyPI, not a local path
bin/python -c "
import seatlayer, os
print(seatlayer.__version__)
print('typed:', os.path.exists(os.path.join(os.path.dirname(seatlayer.__file__), 'py.typed')))
c = seatlayer.SeatLayer('sk_test_x'); print(c.mode, c._http.base_url)
"
```

Expected: `0.1.0`, `typed: True`, `test https://api.seatlayer.io`.

Also eyeball https://pypi.org/project/seatlayer/ for:

- README rendering (Description-Content-Type is `text/markdown` — confirmed in the built metadata)
- License showing as **MIT** (PEP 639 `License-Expression`, not a wall of licence text)
- Author `SeatLayer <hello@seatlayer.io>`
- The five sidebar links: Homepage, Documentation, Changelog, Source, Issues
- The `Typing :: Typed` and Python 3.10–3.13 classifiers

---

## 6. If it goes wrong

**PyPI does not allow re-uploading a version.** Once `0.1.0` is up, that exact filename is spent
forever, even if you delete it. There is no `--force`.

| Situation | Action |
|---|---|
| Bad artifact, caught fast | `twine yank` is not a thing — yank in the web UI: project → *Manage* → *Releases* → *Yank*. Yanking hides it from new resolutions but keeps it installable for anyone who pinned it exactly. This is the correct, non-destructive fix. |
| Need a fixed build out | Bump to `0.1.1` and upload that. Never try to reuse `0.1.0`. |
| Leaked secret in the artifact | Yank **and** delete the release in the UI, then rotate the leaked credential. Assume it was already mirrored — deletion is not containment. |
| Wrong project entirely | Project → *Manage* → *Settings* → *Delete project*. Only possible while you are the sole owner; the **name is not released for reuse by others** immediately, but you also cannot re-upload the same version. |

Deleting is almost always the wrong reflex: it breaks anyone who already installed. **Prefer yank +
a patch release.**
Also check https://pypi.org/project/seatlayer/ for the rendered README, the MIT
license and the sidebar links.

---
## If a release is wrong

## 7. Post-publish
PyPI never accepts the same version twice, even after deletion.

- [ ] Revoke the account-scoped token; replace with a project-scoped one (Path A step 4).
- [ ] Add `Programming Language :: Python :: 3.14` to `pyproject.toml` **after** adding `3.14` to the
CI matrix. The suite passes on 3.14.5 locally today, but the classifier should follow tested
support, not precede it.
- [ ] Consider a `release.yml` + Trusted Publishing (Path B) before 0.2.0.
- [ ] Fleet nit, not a blocker: the `User-Agent` is the bare string `seatlayer-python` with no
version, matching node/go/php/ruby. Once these are in customers' hands, support cannot tell
which SDK version a request came from. Worth changing across all seven at once, never in one.
- **Bad build:** yank it in the PyPI web UI (project, Manage, Releases, Yank) and
publish a patch version. Yanking hides the release from new installs but keeps
exact pins working.
- **Leaked secret in an artifact:** yank and delete the release, then rotate the
credential. Assume it was already mirrored.
Loading