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.
@@ -0,0 +1,3 @@
1
+ dist/
2
+ __pycache__/
3
+ *.egg-info/
@@ -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