ticketfairy 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ticketfairy-0.1.0/.gitignore +3 -0
- ticketfairy-0.1.0/CHANGELOG.md +9 -0
- ticketfairy-0.1.0/LICENSE +21 -0
- ticketfairy-0.1.0/PKG-INFO +163 -0
- ticketfairy-0.1.0/README.md +140 -0
- ticketfairy-0.1.0/pyproject.toml +40 -0
- ticketfairy-0.1.0/src/ticketfairy/__init__.py +58 -0
- ticketfairy-0.1.0/src/ticketfairy/_http.py +207 -0
- ticketfairy-0.1.0/src/ticketfairy/client.py +316 -0
- ticketfairy-0.1.0/src/ticketfairy/errors.py +111 -0
- ticketfairy-0.1.0/src/ticketfairy/py.typed +0 -0
- ticketfairy-0.1.0/src/ticketfairy/version.py +1 -0
- ticketfairy-0.1.0/tests/test_client.py +429 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0]
|
|
4
|
+
|
|
5
|
+
- First release.
|
|
6
|
+
- Public event listings: `events.list` and `events.iter`.
|
|
7
|
+
- Organiser API: `events.create` (with an `Idempotency-Key`) and `events.sales`.
|
|
8
|
+
- Setup copies: `setup_copy.start`, `setup_copy.get` and `setup_copy.wait`.
|
|
9
|
+
- Typed errors and a retry policy that never repeats a write the API cannot tell apart from a new one.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Ticket Fairy, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ticketfairy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Ticket Fairy API: public event listings, organiser events, sales and setup copies
|
|
5
|
+
Project-URL: Homepage, https://www.ticketfairy.com/developers
|
|
6
|
+
Project-URL: Documentation, https://www.ticketfairy.com/developers
|
|
7
|
+
Project-URL: Source, https://github.com/theticketfairy/ticketfairy-cli/tree/main/python
|
|
8
|
+
Project-URL: Issues, https://github.com/theticketfairy/ticketfairy-cli/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/theticketfairy/ticketfairy-cli/blob/main/python/CHANGELOG.md
|
|
10
|
+
Author-email: Ticket Fairy <support@theticketfairy.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: api,events,sdk,ticketfairy,ticketing,tickets
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# ticketfairy (Python)
|
|
25
|
+
|
|
26
|
+
The Python client for the [Ticket Fairy](https://www.ticketfairy.com) API. Use it to:
|
|
27
|
+
|
|
28
|
+
- read public event listings;
|
|
29
|
+
- create events for a brand you manage;
|
|
30
|
+
- read an event's sales;
|
|
31
|
+
- copy another event's setup into an event, as a background job you can follow.
|
|
32
|
+
|
|
33
|
+
It has no dependencies outside the Python standard library and needs Python 3.9 or later.
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pip install ticketfairy
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The same API is available from Node.js and the command line in the [`ticketfairy` npm package](https://www.npmjs.com/package/ticketfairy).
|
|
40
|
+
|
|
41
|
+
## Public event listings
|
|
42
|
+
|
|
43
|
+
The public listing needs no account.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from ticketfairy import TicketFairy
|
|
47
|
+
|
|
48
|
+
tf = TicketFairy()
|
|
49
|
+
|
|
50
|
+
page = tf.events.list(country="GB", date_from="2026-11-01", size=50)
|
|
51
|
+
for event in page["events"]:
|
|
52
|
+
print(event["displayName"], event["startDate"], event["url"])
|
|
53
|
+
|
|
54
|
+
# Every matching event, page after page:
|
|
55
|
+
for event in tf.events.iter(search="jazz", limit=200):
|
|
56
|
+
print(event["displayName"])
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`list` takes these filters:
|
|
60
|
+
|
|
61
|
+
| Filter | Meaning |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `search` | Words to match against event names and descriptions |
|
|
64
|
+
| `country` | An ISO 3166-1 alpha-2 country code |
|
|
65
|
+
| `state` | A region, state or province |
|
|
66
|
+
| `date_from`, `date_to` | Dates as `YYYY-MM-DD` |
|
|
67
|
+
| `section_type` | The listing section to read |
|
|
68
|
+
| `timezone` | An IANA timezone for the date window |
|
|
69
|
+
| `sort` | `created_at`, `updated_at` or `start_date` |
|
|
70
|
+
| `order` | `asc` or `desc` |
|
|
71
|
+
| `brand_id` | Only events from this brand |
|
|
72
|
+
| `include` | The kinds of event to include |
|
|
73
|
+
| `size` | Events per page, up to 200 |
|
|
74
|
+
|
|
75
|
+
`iter` takes the same filters and follows `pagination.nextCursor` for you.
|
|
76
|
+
|
|
77
|
+
## Organiser API
|
|
78
|
+
|
|
79
|
+
The organiser API acts as you. To get a token:
|
|
80
|
+
|
|
81
|
+
1. Create a personal access token in your Ticket Fairy account settings.
|
|
82
|
+
2. Pass it to the client, or set it in the `TICKETFAIRY_TOKEN` environment variable.
|
|
83
|
+
|
|
84
|
+
The token can do what your roles allow, and nothing more.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from ticketfairy import TicketFairy
|
|
88
|
+
|
|
89
|
+
org = TicketFairy(token="...") # or set TICKETFAIRY_TOKEN
|
|
90
|
+
|
|
91
|
+
# Create a draft event for a brand where you are admin or owner.
|
|
92
|
+
event = org.events.create(
|
|
93
|
+
brand_id=1234,
|
|
94
|
+
attributes={"displayName": "Summer Festival 2027", "slug": "summer-festival-2027", "flagDraft": True},
|
|
95
|
+
)
|
|
96
|
+
print(event["id"])
|
|
97
|
+
|
|
98
|
+
# Tickets sold and revenue, by day and by ticket type and release.
|
|
99
|
+
sales = org.events.sales(event["id"])
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`create` sends an `Idempotency-Key` with each request. If a network error happens, a retry cannot create a second event.
|
|
103
|
+
|
|
104
|
+
### Copy another event's setup
|
|
105
|
+
|
|
106
|
+
The copy runs in the background. `start` returns at once with the run. `wait` follows the run until it stops.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
run = org.setup_copy.start(new_event_id, source_event_id=last_year_event_id)
|
|
110
|
+
run = org.setup_copy.wait(new_event_id, run, timeout=600)
|
|
111
|
+
|
|
112
|
+
print(run["status"]) # done, partly_done or failed
|
|
113
|
+
for part in run["parts"]:
|
|
114
|
+
print(part)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
How a copy works:
|
|
118
|
+
|
|
119
|
+
- Both events must belong to the same brand.
|
|
120
|
+
- You need the owner, admin or producer role on both events.
|
|
121
|
+
- Forms need the owner or admin role.
|
|
122
|
+
- Leave out `parts` to copy everything, or name the parts you want.
|
|
123
|
+
|
|
124
|
+
`start` sends a `request_key`. Sending the same key and the same choice of parts again returns the same run, so a retried start does not copy twice.
|
|
125
|
+
|
|
126
|
+
## Errors
|
|
127
|
+
|
|
128
|
+
Every error is a `TicketFairyError`. Each error has these attributes:
|
|
129
|
+
|
|
130
|
+
- `status`: the HTTP status.
|
|
131
|
+
- `code`: a stable code, when the API sends one.
|
|
132
|
+
- `message`: the API's own explanation.
|
|
133
|
+
- `hint`: what to do next, when the API says.
|
|
134
|
+
- `body`: the response.
|
|
135
|
+
|
|
136
|
+
| Error | When |
|
|
137
|
+
| --- | --- |
|
|
138
|
+
| `AuthenticationError` | The token is missing, expired or revoked |
|
|
139
|
+
| `PermissionDeniedError` | Your role does not allow this |
|
|
140
|
+
| `NotFoundError` | No such event or run |
|
|
141
|
+
| `ValidationError` | A parameter or field is not valid; the message names it |
|
|
142
|
+
| `ConflictError` | A key was already used for something else, or a copy is already running |
|
|
143
|
+
| `RateLimitedError` | Too many requests; wait `retry_after` seconds |
|
|
144
|
+
| `ServerError` | Ticket Fairy could not complete the request |
|
|
145
|
+
| `NetworkError` | No response arrived |
|
|
146
|
+
| `SetupCopyTimeoutError` | `wait` gave up while the copy was still running; the copy carries on |
|
|
147
|
+
|
|
148
|
+
### When the client retries
|
|
149
|
+
|
|
150
|
+
Reads are retried after a rate limit, a server error or a network error. For a rate limit, the client first waits for the `Retry-After` time.
|
|
151
|
+
|
|
152
|
+
Writes are retried in the same cases only when the API can tell a repeat from a new request. That is the case for `create`, which sends an `Idempotency-Key`, and for `setup_copy.start`, which sends a `request_key`. A repeat sends the same key, so it returns the first attempt's result rather than doing the work twice.
|
|
153
|
+
|
|
154
|
+
When the retries run out, the error is raised. A `RateLimitedError` carries `retry_after`.
|
|
155
|
+
|
|
156
|
+
## API reference
|
|
157
|
+
|
|
158
|
+
The client follows these OpenAPI documents:
|
|
159
|
+
|
|
160
|
+
- [Public listing](https://www.ticketfairy.com/api/v1/openapi.json)
|
|
161
|
+
- [Organiser API](https://www.theticketfairy.com/api/openapi.json)
|
|
162
|
+
|
|
163
|
+
The developer guide is at [ticketfairy.com/developers](https://www.ticketfairy.com/developers).
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# ticketfairy (Python)
|
|
2
|
+
|
|
3
|
+
The Python client for the [Ticket Fairy](https://www.ticketfairy.com) API. Use it to:
|
|
4
|
+
|
|
5
|
+
- read public event listings;
|
|
6
|
+
- create events for a brand you manage;
|
|
7
|
+
- read an event's sales;
|
|
8
|
+
- copy another event's setup into an event, as a background job you can follow.
|
|
9
|
+
|
|
10
|
+
It has no dependencies outside the Python standard library and needs Python 3.9 or later.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
pip install ticketfairy
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The same API is available from Node.js and the command line in the [`ticketfairy` npm package](https://www.npmjs.com/package/ticketfairy).
|
|
17
|
+
|
|
18
|
+
## Public event listings
|
|
19
|
+
|
|
20
|
+
The public listing needs no account.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from ticketfairy import TicketFairy
|
|
24
|
+
|
|
25
|
+
tf = TicketFairy()
|
|
26
|
+
|
|
27
|
+
page = tf.events.list(country="GB", date_from="2026-11-01", size=50)
|
|
28
|
+
for event in page["events"]:
|
|
29
|
+
print(event["displayName"], event["startDate"], event["url"])
|
|
30
|
+
|
|
31
|
+
# Every matching event, page after page:
|
|
32
|
+
for event in tf.events.iter(search="jazz", limit=200):
|
|
33
|
+
print(event["displayName"])
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`list` takes these filters:
|
|
37
|
+
|
|
38
|
+
| Filter | Meaning |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `search` | Words to match against event names and descriptions |
|
|
41
|
+
| `country` | An ISO 3166-1 alpha-2 country code |
|
|
42
|
+
| `state` | A region, state or province |
|
|
43
|
+
| `date_from`, `date_to` | Dates as `YYYY-MM-DD` |
|
|
44
|
+
| `section_type` | The listing section to read |
|
|
45
|
+
| `timezone` | An IANA timezone for the date window |
|
|
46
|
+
| `sort` | `created_at`, `updated_at` or `start_date` |
|
|
47
|
+
| `order` | `asc` or `desc` |
|
|
48
|
+
| `brand_id` | Only events from this brand |
|
|
49
|
+
| `include` | The kinds of event to include |
|
|
50
|
+
| `size` | Events per page, up to 200 |
|
|
51
|
+
|
|
52
|
+
`iter` takes the same filters and follows `pagination.nextCursor` for you.
|
|
53
|
+
|
|
54
|
+
## Organiser API
|
|
55
|
+
|
|
56
|
+
The organiser API acts as you. To get a token:
|
|
57
|
+
|
|
58
|
+
1. Create a personal access token in your Ticket Fairy account settings.
|
|
59
|
+
2. Pass it to the client, or set it in the `TICKETFAIRY_TOKEN` environment variable.
|
|
60
|
+
|
|
61
|
+
The token can do what your roles allow, and nothing more.
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from ticketfairy import TicketFairy
|
|
65
|
+
|
|
66
|
+
org = TicketFairy(token="...") # or set TICKETFAIRY_TOKEN
|
|
67
|
+
|
|
68
|
+
# Create a draft event for a brand where you are admin or owner.
|
|
69
|
+
event = org.events.create(
|
|
70
|
+
brand_id=1234,
|
|
71
|
+
attributes={"displayName": "Summer Festival 2027", "slug": "summer-festival-2027", "flagDraft": True},
|
|
72
|
+
)
|
|
73
|
+
print(event["id"])
|
|
74
|
+
|
|
75
|
+
# Tickets sold and revenue, by day and by ticket type and release.
|
|
76
|
+
sales = org.events.sales(event["id"])
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`create` sends an `Idempotency-Key` with each request. If a network error happens, a retry cannot create a second event.
|
|
80
|
+
|
|
81
|
+
### Copy another event's setup
|
|
82
|
+
|
|
83
|
+
The copy runs in the background. `start` returns at once with the run. `wait` follows the run until it stops.
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
run = org.setup_copy.start(new_event_id, source_event_id=last_year_event_id)
|
|
87
|
+
run = org.setup_copy.wait(new_event_id, run, timeout=600)
|
|
88
|
+
|
|
89
|
+
print(run["status"]) # done, partly_done or failed
|
|
90
|
+
for part in run["parts"]:
|
|
91
|
+
print(part)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
How a copy works:
|
|
95
|
+
|
|
96
|
+
- Both events must belong to the same brand.
|
|
97
|
+
- You need the owner, admin or producer role on both events.
|
|
98
|
+
- Forms need the owner or admin role.
|
|
99
|
+
- Leave out `parts` to copy everything, or name the parts you want.
|
|
100
|
+
|
|
101
|
+
`start` sends a `request_key`. Sending the same key and the same choice of parts again returns the same run, so a retried start does not copy twice.
|
|
102
|
+
|
|
103
|
+
## Errors
|
|
104
|
+
|
|
105
|
+
Every error is a `TicketFairyError`. Each error has these attributes:
|
|
106
|
+
|
|
107
|
+
- `status`: the HTTP status.
|
|
108
|
+
- `code`: a stable code, when the API sends one.
|
|
109
|
+
- `message`: the API's own explanation.
|
|
110
|
+
- `hint`: what to do next, when the API says.
|
|
111
|
+
- `body`: the response.
|
|
112
|
+
|
|
113
|
+
| Error | When |
|
|
114
|
+
| --- | --- |
|
|
115
|
+
| `AuthenticationError` | The token is missing, expired or revoked |
|
|
116
|
+
| `PermissionDeniedError` | Your role does not allow this |
|
|
117
|
+
| `NotFoundError` | No such event or run |
|
|
118
|
+
| `ValidationError` | A parameter or field is not valid; the message names it |
|
|
119
|
+
| `ConflictError` | A key was already used for something else, or a copy is already running |
|
|
120
|
+
| `RateLimitedError` | Too many requests; wait `retry_after` seconds |
|
|
121
|
+
| `ServerError` | Ticket Fairy could not complete the request |
|
|
122
|
+
| `NetworkError` | No response arrived |
|
|
123
|
+
| `SetupCopyTimeoutError` | `wait` gave up while the copy was still running; the copy carries on |
|
|
124
|
+
|
|
125
|
+
### When the client retries
|
|
126
|
+
|
|
127
|
+
Reads are retried after a rate limit, a server error or a network error. For a rate limit, the client first waits for the `Retry-After` time.
|
|
128
|
+
|
|
129
|
+
Writes are retried in the same cases only when the API can tell a repeat from a new request. That is the case for `create`, which sends an `Idempotency-Key`, and for `setup_copy.start`, which sends a `request_key`. A repeat sends the same key, so it returns the first attempt's result rather than doing the work twice.
|
|
130
|
+
|
|
131
|
+
When the retries run out, the error is raised. A `RateLimitedError` carries `retry_after`.
|
|
132
|
+
|
|
133
|
+
## API reference
|
|
134
|
+
|
|
135
|
+
The client follows these OpenAPI documents:
|
|
136
|
+
|
|
137
|
+
- [Public listing](https://www.ticketfairy.com/api/v1/openapi.json)
|
|
138
|
+
- [Organiser API](https://www.theticketfairy.com/api/openapi.json)
|
|
139
|
+
|
|
140
|
+
The developer guide is at [ticketfairy.com/developers](https://www.ticketfairy.com/developers).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling==1.27.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ticketfairy"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Python client for the Ticket Fairy API: public event listings, organiser events, sales and setup copies"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "Ticket Fairy", email = "support@theticketfairy.com" }]
|
|
14
|
+
keywords = ["ticketfairy", "events", "tickets", "ticketing", "api", "sdk"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://www.ticketfairy.com/developers"
|
|
28
|
+
Documentation = "https://www.ticketfairy.com/developers"
|
|
29
|
+
Source = "https://github.com/theticketfairy/ticketfairy-cli/tree/main/python"
|
|
30
|
+
Issues = "https://github.com/theticketfairy/ticketfairy-cli/issues"
|
|
31
|
+
Changelog = "https://github.com/theticketfairy/ticketfairy-cli/blob/main/python/CHANGELOG.md"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.version]
|
|
34
|
+
path = "src/ticketfairy/version.py"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/ticketfairy"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
include = ["src/ticketfairy", "tests", "README.md", "CHANGELOG.md", "LICENSE"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Python client for the Ticket Fairy API.
|
|
2
|
+
|
|
3
|
+
from ticketfairy import TicketFairy
|
|
4
|
+
|
|
5
|
+
tf = TicketFairy() # public listings need no token
|
|
6
|
+
for event in tf.events.iter(country="GB", limit=20):
|
|
7
|
+
print(event["displayName"])
|
|
8
|
+
|
|
9
|
+
org = TicketFairy(token="...") # organiser API
|
|
10
|
+
run = org.setup_copy.start(123, source_event_id=100)
|
|
11
|
+
run = org.setup_copy.wait(123, run)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .client import (
|
|
15
|
+
ORGANISER_BASE_URL,
|
|
16
|
+
PUBLIC_BASE_URL,
|
|
17
|
+
SALES_SECTIONS,
|
|
18
|
+
SETUP_COPY_FINAL_STATUSES,
|
|
19
|
+
Events,
|
|
20
|
+
SetupCopy,
|
|
21
|
+
TicketFairy,
|
|
22
|
+
flatten,
|
|
23
|
+
)
|
|
24
|
+
from .errors import (
|
|
25
|
+
AuthenticationError,
|
|
26
|
+
ConflictError,
|
|
27
|
+
NetworkError,
|
|
28
|
+
NotFoundError,
|
|
29
|
+
PermissionDeniedError,
|
|
30
|
+
RateLimitedError,
|
|
31
|
+
ServerError,
|
|
32
|
+
SetupCopyTimeoutError,
|
|
33
|
+
TicketFairyError,
|
|
34
|
+
ValidationError,
|
|
35
|
+
)
|
|
36
|
+
from .version import __version__
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"AuthenticationError",
|
|
40
|
+
"ConflictError",
|
|
41
|
+
"Events",
|
|
42
|
+
"NetworkError",
|
|
43
|
+
"NotFoundError",
|
|
44
|
+
"ORGANISER_BASE_URL",
|
|
45
|
+
"PUBLIC_BASE_URL",
|
|
46
|
+
"PermissionDeniedError",
|
|
47
|
+
"RateLimitedError",
|
|
48
|
+
"SALES_SECTIONS",
|
|
49
|
+
"SETUP_COPY_FINAL_STATUSES",
|
|
50
|
+
"ServerError",
|
|
51
|
+
"SetupCopy",
|
|
52
|
+
"SetupCopyTimeoutError",
|
|
53
|
+
"TicketFairy",
|
|
54
|
+
"TicketFairyError",
|
|
55
|
+
"ValidationError",
|
|
56
|
+
"__version__",
|
|
57
|
+
"flatten",
|
|
58
|
+
]
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""HTTP transport: one request, the retry policy and error mapping.
|
|
2
|
+
|
|
3
|
+
Retries follow the same rule as the ``ticketfairy`` npm package, so a retry
|
|
4
|
+
never runs a write twice:
|
|
5
|
+
|
|
6
|
+
- A network error or a 5xx is retried only for a request that is safe to
|
|
7
|
+
repeat: GET, HEAD, OPTIONS, PUT and DELETE, a write that carries an
|
|
8
|
+
``Idempotency-Key``, or a write the caller marks as safe (one the server
|
|
9
|
+
deduplicates by a key in its body).
|
|
10
|
+
- A 429 gets the same rule, after the ``Retry-After`` seconds. A keyed write
|
|
11
|
+
sent again while its first attempt is still running is answered 429, and
|
|
12
|
+
waiting then sending the same key returns the first attempt's result.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import datetime
|
|
18
|
+
import email.utils
|
|
19
|
+
import http.client
|
|
20
|
+
import json
|
|
21
|
+
import socket
|
|
22
|
+
import time
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.parse
|
|
25
|
+
import urllib.request
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any, Callable, Dict, Mapping, Optional
|
|
28
|
+
|
|
29
|
+
from .errors import NetworkError, error_for_status
|
|
30
|
+
|
|
31
|
+
class _NoRedirects(urllib.request.HTTPRedirectHandler):
|
|
32
|
+
"""The API never redirects. Following one would send the token to another address."""
|
|
33
|
+
|
|
34
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_OPENER = urllib.request.build_opener(_NoRedirects)
|
|
39
|
+
|
|
40
|
+
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Response:
|
|
45
|
+
status: int
|
|
46
|
+
headers: Dict[str, str]
|
|
47
|
+
body: Any = field(default=None)
|
|
48
|
+
|
|
49
|
+
def header(self, name: str) -> Optional[str]:
|
|
50
|
+
return self.headers.get(name.lower())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Transport:
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
user_agent: str,
|
|
58
|
+
timeout: float,
|
|
59
|
+
max_retries: int,
|
|
60
|
+
max_retry_wait: float,
|
|
61
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
62
|
+
) -> None:
|
|
63
|
+
self.user_agent = user_agent
|
|
64
|
+
self.timeout = timeout
|
|
65
|
+
self.max_retries = max_retries
|
|
66
|
+
self.max_retry_wait = max_retry_wait
|
|
67
|
+
self.sleep = sleep
|
|
68
|
+
|
|
69
|
+
def request(
|
|
70
|
+
self,
|
|
71
|
+
method: str,
|
|
72
|
+
url: str,
|
|
73
|
+
*,
|
|
74
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
75
|
+
json_body: Any = None,
|
|
76
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
77
|
+
safe_to_repeat: bool = False,
|
|
78
|
+
) -> Response:
|
|
79
|
+
method = method.upper()
|
|
80
|
+
all_headers = {"User-Agent": self.user_agent, "Accept": "application/json"}
|
|
81
|
+
all_headers.update(headers or {})
|
|
82
|
+
data = None
|
|
83
|
+
if json_body is not None:
|
|
84
|
+
data = json.dumps(json_body).encode("utf-8")
|
|
85
|
+
all_headers.setdefault("Content-Type", "application/json")
|
|
86
|
+
if params:
|
|
87
|
+
query = urllib.parse.urlencode({k: _query_value(v) for k, v in params.items() if v is not None})
|
|
88
|
+
if query:
|
|
89
|
+
url = f"{url}{'&' if '?' in url else '?'}{query}"
|
|
90
|
+
|
|
91
|
+
has_key = any(name.lower() == "idempotency-key" for name in all_headers)
|
|
92
|
+
repeatable = safe_to_repeat or has_key or method in _SAFE_METHODS
|
|
93
|
+
attempt = 0
|
|
94
|
+
while True:
|
|
95
|
+
try:
|
|
96
|
+
response = self._send(method, url, data, all_headers)
|
|
97
|
+
except NetworkError:
|
|
98
|
+
if repeatable and attempt < self.max_retries:
|
|
99
|
+
self.sleep(self._backoff(attempt))
|
|
100
|
+
attempt += 1
|
|
101
|
+
continue
|
|
102
|
+
raise
|
|
103
|
+
|
|
104
|
+
# A redirect is not followed (see _NoRedirects), so only 2xx succeeds.
|
|
105
|
+
if 200 <= response.status < 300:
|
|
106
|
+
return response
|
|
107
|
+
|
|
108
|
+
retry_after = _retry_after(response.header("retry-after"))
|
|
109
|
+
if (response.status == 429 or response.status >= 500) and repeatable and attempt < self.max_retries:
|
|
110
|
+
# A Retry-After on a 429 or a load-shedding 503 replaces the backoff.
|
|
111
|
+
wait = retry_after if retry_after is not None else self._backoff(attempt)
|
|
112
|
+
self.sleep(min(max(wait, 0.5), self.max_retry_wait))
|
|
113
|
+
attempt += 1
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
message, code = _describe(response)
|
|
117
|
+
raise error_for_status(
|
|
118
|
+
response.status,
|
|
119
|
+
message,
|
|
120
|
+
code=code,
|
|
121
|
+
body=response.body,
|
|
122
|
+
request_id=response.header("x-request-id"),
|
|
123
|
+
retry_after=retry_after,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def _send(self, method: str, url: str, data: Optional[bytes], headers: Mapping[str, str]) -> Response:
|
|
127
|
+
request = urllib.request.Request(url, data=data, method=method, headers=dict(headers))
|
|
128
|
+
try:
|
|
129
|
+
with _OPENER.open(request, timeout=self.timeout) as raw:
|
|
130
|
+
return _response(raw.status, raw.headers, raw.read())
|
|
131
|
+
except urllib.error.HTTPError as failure:
|
|
132
|
+
try:
|
|
133
|
+
with failure:
|
|
134
|
+
return _response(failure.code, failure.headers, failure.read())
|
|
135
|
+
except _TRANSPORT_FAILURES as cut_off:
|
|
136
|
+
raise _network_error(cut_off) from cut_off
|
|
137
|
+
except _TRANSPORT_FAILURES as failure:
|
|
138
|
+
raise _network_error(failure) from failure
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _backoff(attempt: int) -> float:
|
|
142
|
+
return float(min(0.5 * (2**attempt), 8.0))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# HTTPException covers a body cut off after the headers (IncompleteRead).
|
|
146
|
+
_TRANSPORT_FAILURES = (urllib.error.URLError, http.client.HTTPException, socket.timeout, TimeoutError, ConnectionError)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _network_error(failure: BaseException) -> NetworkError:
|
|
150
|
+
return NetworkError(f"Could not reach Ticket Fairy: {getattr(failure, 'reason', failure)}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _response(status: int, headers: Any, raw: bytes) -> Response:
|
|
154
|
+
lowered = {name.lower(): value for name, value in headers.items()} if headers else {}
|
|
155
|
+
body: Any = None
|
|
156
|
+
if raw:
|
|
157
|
+
text = raw.decode("utf-8", errors="replace")
|
|
158
|
+
try:
|
|
159
|
+
body = json.loads(text)
|
|
160
|
+
except ValueError:
|
|
161
|
+
body = text
|
|
162
|
+
return Response(status=status, headers=lowered, body=body)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _query_value(value: Any) -> str:
|
|
166
|
+
if isinstance(value, bool):
|
|
167
|
+
return "true" if value else "false"
|
|
168
|
+
return str(value)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _retry_after(value: Optional[str]) -> Optional[float]:
|
|
172
|
+
"""Seconds to wait, from either form of Retry-After: seconds or an HTTP date."""
|
|
173
|
+
if value is None:
|
|
174
|
+
return None
|
|
175
|
+
try:
|
|
176
|
+
return max(0.0, float(value))
|
|
177
|
+
except ValueError:
|
|
178
|
+
pass
|
|
179
|
+
try:
|
|
180
|
+
when = email.utils.parsedate_to_datetime(value)
|
|
181
|
+
except (TypeError, ValueError, IndexError):
|
|
182
|
+
return None
|
|
183
|
+
if when.tzinfo is None:
|
|
184
|
+
when = when.replace(tzinfo=datetime.timezone.utc)
|
|
185
|
+
return max(0.0, (when - datetime.datetime.now(datetime.timezone.utc)).total_seconds())
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _describe(response: Response) -> "tuple[str, Optional[str]]":
|
|
189
|
+
"""The API's own message and code from any of its error shapes."""
|
|
190
|
+
body = response.body
|
|
191
|
+
message: Optional[str] = None
|
|
192
|
+
code: Optional[str] = None
|
|
193
|
+
if isinstance(body, dict):
|
|
194
|
+
if isinstance(body.get("message"), str):
|
|
195
|
+
message = body["message"]
|
|
196
|
+
elif isinstance(body.get("error"), str):
|
|
197
|
+
message = body["error"]
|
|
198
|
+
errors = body.get("errors")
|
|
199
|
+
if message is None and isinstance(errors, list) and errors and isinstance(errors[0], dict):
|
|
200
|
+
message = errors[0].get("detail") or errors[0].get("title")
|
|
201
|
+
if isinstance(errors[0].get("code"), str):
|
|
202
|
+
code = errors[0]["code"]
|
|
203
|
+
if isinstance(body.get("code"), str):
|
|
204
|
+
code = body["code"]
|
|
205
|
+
elif isinstance(body, str) and body.strip() and len(body) <= 500:
|
|
206
|
+
message = body.strip()
|
|
207
|
+
return message or f"Ticket Fairy answered HTTP {response.status}.", code
|