cosplay2py 0.3.0__py3-none-any.whl
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.
- cosplay2py/__init__.py +38 -0
- cosplay2py/_base_client.py +220 -0
- cosplay2py/_client.py +240 -0
- cosplay2py/_exceptions.py +125 -0
- cosplay2py/_types.py +48 -0
- cosplay2py/models/__init__.py +184 -0
- cosplay2py/models/_base.py +24 -0
- cosplay2py/models/common.py +49 -0
- cosplay2py/models/events.py +296 -0
- cosplay2py/models/misc.py +118 -0
- cosplay2py/models/requests.py +290 -0
- cosplay2py/models/topics.py +215 -0
- cosplay2py/models/users.py +114 -0
- cosplay2py/py.typed +0 -0
- cosplay2py/resources/__init__.py +44 -0
- cosplay2py/resources/_base.py +21 -0
- cosplay2py/resources/events.py +552 -0
- cosplay2py/resources/geo.py +33 -0
- cosplay2py/resources/misc.py +1017 -0
- cosplay2py/resources/requests.py +826 -0
- cosplay2py/resources/topics.py +579 -0
- cosplay2py/resources/users.py +211 -0
- cosplay2py-0.3.0.dist-info/METADATA +199 -0
- cosplay2py-0.3.0.dist-info/RECORD +26 -0
- cosplay2py-0.3.0.dist-info/WHEEL +4 -0
- cosplay2py-0.3.0.dist-info/licenses/LICENSE +21 -0
cosplay2py/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Sync and async client for the Cosplay2.ru event API."""
|
|
2
|
+
|
|
3
|
+
from cosplay2py import models
|
|
4
|
+
from cosplay2py._base_client import __version__
|
|
5
|
+
from cosplay2py._client import AsyncCosplay2, Cosplay2
|
|
6
|
+
from cosplay2py._exceptions import (
|
|
7
|
+
APIConnectionError,
|
|
8
|
+
APIError,
|
|
9
|
+
APIResponseValidationError,
|
|
10
|
+
APIStatusError,
|
|
11
|
+
APITimeoutError,
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
BadRequestError,
|
|
14
|
+
Cosplay2Error,
|
|
15
|
+
InternalServerError,
|
|
16
|
+
NotFoundError,
|
|
17
|
+
PermissionDeniedError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"APIConnectionError",
|
|
23
|
+
"APIError",
|
|
24
|
+
"APIResponseValidationError",
|
|
25
|
+
"APIStatusError",
|
|
26
|
+
"APITimeoutError",
|
|
27
|
+
"AsyncCosplay2",
|
|
28
|
+
"AuthenticationError",
|
|
29
|
+
"BadRequestError",
|
|
30
|
+
"Cosplay2",
|
|
31
|
+
"Cosplay2Error",
|
|
32
|
+
"InternalServerError",
|
|
33
|
+
"NotFoundError",
|
|
34
|
+
"PermissionDeniedError",
|
|
35
|
+
"RateLimitError",
|
|
36
|
+
"__version__",
|
|
37
|
+
"models",
|
|
38
|
+
]
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Transport-agnostic plumbing shared by the sync and async clients."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from email.utils import parsedate_to_datetime
|
|
10
|
+
from functools import cache
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
import httpx2
|
|
15
|
+
from pydantic import TypeAdapter, ValidationError
|
|
16
|
+
|
|
17
|
+
from cosplay2py._exceptions import (
|
|
18
|
+
APIResponseValidationError,
|
|
19
|
+
Cosplay2Error,
|
|
20
|
+
make_status_error,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger("cosplay2py")
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
__version__ = version("cosplay2py")
|
|
27
|
+
except PackageNotFoundError: # pragma: no cover - running from a source checkout
|
|
28
|
+
__version__ = "0.0.0"
|
|
29
|
+
|
|
30
|
+
DEFAULT_TIMEOUT = httpx2.Timeout(30.0, connect=10.0)
|
|
31
|
+
DEFAULT_MAX_RETRIES = 2
|
|
32
|
+
RETRYABLE_STATUSES = frozenset({429, 502, 503, 504})
|
|
33
|
+
_MAX_RETRY_DELAY = 30.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class Endpoint[T]:
|
|
38
|
+
"""Everything needed to perform one API call and parse its result.
|
|
39
|
+
|
|
40
|
+
Resources build these; the clients execute them. Keeping the description of an
|
|
41
|
+
endpoint free of any I/O is what lets one definition serve both sync and async.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
method: Literal["GET", "POST"]
|
|
45
|
+
path: str
|
|
46
|
+
response_type: Any
|
|
47
|
+
"""The type the (possibly unwrapped) JSON payload is validated against."""
|
|
48
|
+
key: str | None = None
|
|
49
|
+
"""Top-level key to unwrap, e.g. ``"state"`` for ``{"state": {...}}``."""
|
|
50
|
+
json: dict[str, Any] | None = None
|
|
51
|
+
params: dict[str, Any] | None = None
|
|
52
|
+
"""Query string parameters; a few endpoints take their arguments there."""
|
|
53
|
+
data: dict[str, Any] | None = None
|
|
54
|
+
files: dict[str, Any] | None = None
|
|
55
|
+
"""Multipart form fields and files, for uploads."""
|
|
56
|
+
idempotent: bool = field(kw_only=True)
|
|
57
|
+
"""Whether repeating the call is harmless. Writes are only retried when the server
|
|
58
|
+
cannot have acted on the request (a failed connection)."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@cache
|
|
62
|
+
def _adapter(tp: Any) -> TypeAdapter[Any]:
|
|
63
|
+
return TypeAdapter(tp)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _resolve_base_url(base_url: str | None, subdomain: str | None) -> httpx2.URL:
|
|
67
|
+
if base_url and subdomain:
|
|
68
|
+
raise Cosplay2Error("Pass either `base_url` or `subdomain`, not both.")
|
|
69
|
+
if subdomain and not re.fullmatch(
|
|
70
|
+
r"[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?", subdomain
|
|
71
|
+
):
|
|
72
|
+
raise Cosplay2Error("Invalid event subdomain.")
|
|
73
|
+
if subdomain:
|
|
74
|
+
base_url = f"https://{subdomain}.cosplay2.ru"
|
|
75
|
+
base_url = base_url or os.environ.get("COSPLAY2_BASE_URL")
|
|
76
|
+
if not base_url:
|
|
77
|
+
raise Cosplay2Error(
|
|
78
|
+
"No event to talk to: pass `subdomain=` (e.g. 'ff26summer'), `base_url=` "
|
|
79
|
+
"(e.g. 'https://ff26summer.cosplay2.ru') or set COSPLAY2_BASE_URL."
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
url = httpx2.URL(base_url)
|
|
83
|
+
except (httpx2.InvalidURL, ValueError):
|
|
84
|
+
raise Cosplay2Error(
|
|
85
|
+
"Invalid base_url: expected an HTTP(S) event URL."
|
|
86
|
+
) from None
|
|
87
|
+
if url.scheme not in {"http", "https"} or not url.host:
|
|
88
|
+
raise Cosplay2Error("Invalid base_url: expected an HTTP(S) event URL.")
|
|
89
|
+
if url.userinfo or url.query or url.fragment:
|
|
90
|
+
raise Cosplay2Error("base_url must not contain credentials, query or fragment.")
|
|
91
|
+
return url
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _resolve_credential(value: str | None, env: str, name: str) -> str:
|
|
95
|
+
value = value or os.environ.get(env)
|
|
96
|
+
if not value:
|
|
97
|
+
raise Cosplay2Error(f"Missing {name}: pass `{name}=` or set {env}.")
|
|
98
|
+
return value
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class BaseClient[HttpClientT: (httpx2.Client, httpx2.AsyncClient)]:
|
|
102
|
+
base_url: httpx2.URL
|
|
103
|
+
max_retries: int
|
|
104
|
+
_http: HttpClientT
|
|
105
|
+
_owns_http: bool
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
*,
|
|
110
|
+
api_key: str | None,
|
|
111
|
+
api_secret: str | None,
|
|
112
|
+
base_url: str | None,
|
|
113
|
+
subdomain: str | None,
|
|
114
|
+
max_retries: int,
|
|
115
|
+
headers: dict[str, str] | None,
|
|
116
|
+
) -> None:
|
|
117
|
+
if type(max_retries) is not int or max_retries < 0:
|
|
118
|
+
raise Cosplay2Error("max_retries must be a nonnegative integer.")
|
|
119
|
+
self.base_url = _resolve_base_url(base_url, subdomain)
|
|
120
|
+
self.max_retries = max_retries
|
|
121
|
+
self._headers = {
|
|
122
|
+
"Accept": "application/json",
|
|
123
|
+
"User-Agent": f"cosplay2py/{__version__}",
|
|
124
|
+
**(headers or {}),
|
|
125
|
+
"X-API-Key": _resolve_credential(api_key, "COSPLAY2_API_KEY", "api_key"),
|
|
126
|
+
"X-API-Secret": _resolve_credential(
|
|
127
|
+
api_secret, "COSPLAY2_API_SECRET", "api_secret"
|
|
128
|
+
),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
def __repr__(self) -> str:
|
|
132
|
+
# Never include the credentials: reprs end up in logs and tracebacks.
|
|
133
|
+
return f"{type(self).__name__}(base_url={str(self.base_url.copy_with(path='/'))!r})"
|
|
134
|
+
|
|
135
|
+
def _build_request(self, endpoint: Endpoint[Any]) -> httpx2.Request:
|
|
136
|
+
return self._http.build_request(
|
|
137
|
+
endpoint.method,
|
|
138
|
+
self.base_url.join(endpoint.path),
|
|
139
|
+
params=endpoint.params,
|
|
140
|
+
json=endpoint.json,
|
|
141
|
+
data=endpoint.data,
|
|
142
|
+
files=endpoint.files,
|
|
143
|
+
headers=self._headers,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def _process_response[T](
|
|
147
|
+
self, endpoint: Endpoint[T], response: httpx2.Response
|
|
148
|
+
) -> T:
|
|
149
|
+
log.debug("%s %s -> %s", endpoint.method, endpoint.path, response.status_code)
|
|
150
|
+
# Redirects are treated as errors too: an unauthenticated request may get
|
|
151
|
+
# bounced to an HTML page instead of a JSON error.
|
|
152
|
+
if not response.is_success:
|
|
153
|
+
raise make_status_error(response)
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
data = response.json()
|
|
157
|
+
except ValueError:
|
|
158
|
+
raise APIResponseValidationError(
|
|
159
|
+
"Response body is not valid JSON.", response=response
|
|
160
|
+
) from None
|
|
161
|
+
|
|
162
|
+
if endpoint.key is not None:
|
|
163
|
+
if not isinstance(data, dict) or endpoint.key not in data:
|
|
164
|
+
raise APIResponseValidationError(
|
|
165
|
+
f"Expected a JSON object with a {endpoint.key!r} key.",
|
|
166
|
+
response=response,
|
|
167
|
+
)
|
|
168
|
+
data = data[endpoint.key]
|
|
169
|
+
elif isinstance(data, dict) and data.get("message") == "ok":
|
|
170
|
+
# A success marker many endpoints put next to the payload, not part of it.
|
|
171
|
+
data = {k: v for k, v in data.items() if k != "message"}
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
return _adapter(endpoint.response_type).validate_python(data)
|
|
175
|
+
except ValidationError:
|
|
176
|
+
raise APIResponseValidationError(
|
|
177
|
+
f"Unexpected response shape for {endpoint.path}.",
|
|
178
|
+
response=response,
|
|
179
|
+
) from None
|
|
180
|
+
|
|
181
|
+
def _should_retry(
|
|
182
|
+
self, endpoint: Endpoint[Any], cause: httpx2.Response | Exception, attempt: int
|
|
183
|
+
) -> bool:
|
|
184
|
+
if attempt >= self.max_retries:
|
|
185
|
+
return False
|
|
186
|
+
if isinstance(cause, httpx2.Response):
|
|
187
|
+
if not endpoint.idempotent:
|
|
188
|
+
return False
|
|
189
|
+
delay = _retry_after(cause)
|
|
190
|
+
return cause.status_code in RETRYABLE_STATUSES and (
|
|
191
|
+
delay is None or delay <= _MAX_RETRY_DELAY
|
|
192
|
+
)
|
|
193
|
+
return endpoint.idempotent or isinstance(cause, httpx2.ConnectError)
|
|
194
|
+
|
|
195
|
+
def _retry_delay(self, attempt: int, cause: httpx2.Response | Exception) -> float:
|
|
196
|
+
delay = _retry_after(cause) if isinstance(cause, httpx2.Response) else None
|
|
197
|
+
if delay is None:
|
|
198
|
+
delay = min(0.5 * 2 ** min(attempt, 6), _MAX_RETRY_DELAY) * random.uniform(
|
|
199
|
+
0.75, 1.0
|
|
200
|
+
)
|
|
201
|
+
log.info(
|
|
202
|
+
"Retrying %s in %.2fs (%s)",
|
|
203
|
+
type(self).__name__,
|
|
204
|
+
delay,
|
|
205
|
+
type(cause).__name__,
|
|
206
|
+
)
|
|
207
|
+
return delay
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _retry_after(response: httpx2.Response) -> float | None:
|
|
211
|
+
value = response.headers.get("Retry-After", "").strip()
|
|
212
|
+
if re.fullmatch(r"[0-9]+", value):
|
|
213
|
+
return float(value)
|
|
214
|
+
try:
|
|
215
|
+
date = parsedate_to_datetime(value)
|
|
216
|
+
if date.tzinfo is None:
|
|
217
|
+
return None
|
|
218
|
+
return max(0.0, (date - datetime.now(UTC)).total_seconds())
|
|
219
|
+
except (ValueError, TypeError, OverflowError):
|
|
220
|
+
return None
|
cosplay2py/_client.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from itertools import count
|
|
3
|
+
from types import TracebackType
|
|
4
|
+
from typing import Self
|
|
5
|
+
|
|
6
|
+
import anyio
|
|
7
|
+
import httpx2
|
|
8
|
+
|
|
9
|
+
from cosplay2py._base_client import (
|
|
10
|
+
DEFAULT_MAX_RETRIES,
|
|
11
|
+
DEFAULT_TIMEOUT,
|
|
12
|
+
BaseClient,
|
|
13
|
+
Endpoint,
|
|
14
|
+
)
|
|
15
|
+
from cosplay2py._exceptions import APIConnectionError, APITimeoutError
|
|
16
|
+
from cosplay2py.resources import (
|
|
17
|
+
AsyncCards,
|
|
18
|
+
AsyncDesign,
|
|
19
|
+
AsyncETickets,
|
|
20
|
+
AsyncEvents,
|
|
21
|
+
AsyncGeo,
|
|
22
|
+
AsyncMedia,
|
|
23
|
+
AsyncPages,
|
|
24
|
+
AsyncRequests,
|
|
25
|
+
AsyncTickets,
|
|
26
|
+
AsyncTopics,
|
|
27
|
+
AsyncUsers,
|
|
28
|
+
Cards,
|
|
29
|
+
Design,
|
|
30
|
+
ETickets,
|
|
31
|
+
Events,
|
|
32
|
+
Geo,
|
|
33
|
+
Media,
|
|
34
|
+
Pages,
|
|
35
|
+
Requests,
|
|
36
|
+
Tickets,
|
|
37
|
+
Topics,
|
|
38
|
+
Users,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Cosplay2(BaseClient[httpx2.Client]):
|
|
43
|
+
"""Synchronous client for the API of one Cosplay2 event.
|
|
44
|
+
|
|
45
|
+
API tokens are issued per event (Orgs -> Settings -> API tokens), so a client is
|
|
46
|
+
bound to a single event site::
|
|
47
|
+
|
|
48
|
+
with Cosplay2(subdomain="ff26summer", api_key="...", api_secret="...") as c2:
|
|
49
|
+
for topic in c2.topics.get_list():
|
|
50
|
+
print(topic.title)
|
|
51
|
+
|
|
52
|
+
Credentials and the event fall back to the ``COSPLAY2_API_KEY``,
|
|
53
|
+
``COSPLAY2_API_SECRET`` and ``COSPLAY2_BASE_URL`` environment variables.
|
|
54
|
+
|
|
55
|
+
:param subdomain: Event subdomain on cosplay2.ru, e.g. ``"ff26summer"``.
|
|
56
|
+
:param base_url: Full event URL instead of ``subdomain``; use it for events
|
|
57
|
+
served from a custom domain.
|
|
58
|
+
:param max_retries: Retries for connection errors, timeouts and 429/502/503/504.
|
|
59
|
+
:param http_client: Bring your own ``httpx2.Client`` (proxies, transports, ...).
|
|
60
|
+
It is not closed by ``close()``; its ``base_url`` is ignored. Its timeout and default headers apply;
|
|
61
|
+
wrapper headers take precedence. Redirects are always disabled.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
events: Events
|
|
65
|
+
topics: Topics
|
|
66
|
+
requests: Requests
|
|
67
|
+
geo: Geo
|
|
68
|
+
users: Users
|
|
69
|
+
design: Design
|
|
70
|
+
pages: Pages
|
|
71
|
+
media: Media
|
|
72
|
+
cards: Cards
|
|
73
|
+
etickets: ETickets
|
|
74
|
+
tickets: Tickets
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
api_key: str | None = None,
|
|
80
|
+
api_secret: str | None = None,
|
|
81
|
+
subdomain: str | None = None,
|
|
82
|
+
base_url: str | None = None,
|
|
83
|
+
timeout: float | httpx2.Timeout = DEFAULT_TIMEOUT,
|
|
84
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
85
|
+
headers: dict[str, str] | None = None,
|
|
86
|
+
http_client: httpx2.Client | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
super().__init__(
|
|
89
|
+
api_key=api_key,
|
|
90
|
+
api_secret=api_secret,
|
|
91
|
+
base_url=base_url,
|
|
92
|
+
subdomain=subdomain,
|
|
93
|
+
max_retries=max_retries,
|
|
94
|
+
headers=headers,
|
|
95
|
+
)
|
|
96
|
+
self._owns_http = http_client is None
|
|
97
|
+
self._http = http_client or httpx2.Client(timeout=timeout)
|
|
98
|
+
|
|
99
|
+
self.events = Events(self)
|
|
100
|
+
self.topics = Topics(self)
|
|
101
|
+
self.requests = Requests(self)
|
|
102
|
+
self.geo = Geo(self)
|
|
103
|
+
self.users = Users(self)
|
|
104
|
+
self.design = Design(self)
|
|
105
|
+
self.pages = Pages(self)
|
|
106
|
+
self.media = Media(self)
|
|
107
|
+
self.cards = Cards(self)
|
|
108
|
+
self.etickets = ETickets(self)
|
|
109
|
+
self.tickets = Tickets(self)
|
|
110
|
+
|
|
111
|
+
def close(self) -> None:
|
|
112
|
+
"""Close the underlying HTTP client, unless it was passed in by the caller."""
|
|
113
|
+
if self._owns_http:
|
|
114
|
+
self._http.close()
|
|
115
|
+
|
|
116
|
+
def __enter__(self) -> Self:
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def __exit__(
|
|
120
|
+
self,
|
|
121
|
+
exc_type: type[BaseException] | None,
|
|
122
|
+
exc: BaseException | None,
|
|
123
|
+
tb: TracebackType | None,
|
|
124
|
+
) -> None:
|
|
125
|
+
self.close()
|
|
126
|
+
|
|
127
|
+
def _request[T](self, endpoint: Endpoint[T]) -> T:
|
|
128
|
+
request = self._build_request(endpoint)
|
|
129
|
+
for attempt in count():
|
|
130
|
+
try:
|
|
131
|
+
response = self._http.send(request, follow_redirects=False)
|
|
132
|
+
except httpx2.TransportError as exc:
|
|
133
|
+
if self._should_retry(endpoint, exc, attempt):
|
|
134
|
+
time.sleep(self._retry_delay(attempt, exc))
|
|
135
|
+
continue
|
|
136
|
+
if isinstance(exc, httpx2.TimeoutException):
|
|
137
|
+
raise APITimeoutError(request=request) from None
|
|
138
|
+
raise APIConnectionError(request=request) from None
|
|
139
|
+
|
|
140
|
+
if self._should_retry(endpoint, response, attempt):
|
|
141
|
+
response.close()
|
|
142
|
+
time.sleep(self._retry_delay(attempt, response))
|
|
143
|
+
continue
|
|
144
|
+
return self._process_response(endpoint, response)
|
|
145
|
+
raise AssertionError("unreachable")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class AsyncCosplay2(BaseClient[httpx2.AsyncClient]):
|
|
149
|
+
"""Asynchronous client for the API of one Cosplay2 event.
|
|
150
|
+
|
|
151
|
+
Same interface as ``Cosplay2``, with awaitable methods::
|
|
152
|
+
|
|
153
|
+
async with AsyncCosplay2(subdomain="ff26summer") as c2:
|
|
154
|
+
topics = await c2.topics.get_list()
|
|
155
|
+
|
|
156
|
+
Works with asyncio and trio.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
events: AsyncEvents
|
|
160
|
+
topics: AsyncTopics
|
|
161
|
+
requests: AsyncRequests
|
|
162
|
+
geo: AsyncGeo
|
|
163
|
+
users: AsyncUsers
|
|
164
|
+
design: AsyncDesign
|
|
165
|
+
pages: AsyncPages
|
|
166
|
+
media: AsyncMedia
|
|
167
|
+
cards: AsyncCards
|
|
168
|
+
etickets: AsyncETickets
|
|
169
|
+
tickets: AsyncTickets
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
*,
|
|
174
|
+
api_key: str | None = None,
|
|
175
|
+
api_secret: str | None = None,
|
|
176
|
+
subdomain: str | None = None,
|
|
177
|
+
base_url: str | None = None,
|
|
178
|
+
timeout: float | httpx2.Timeout = DEFAULT_TIMEOUT,
|
|
179
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
180
|
+
headers: dict[str, str] | None = None,
|
|
181
|
+
http_client: httpx2.AsyncClient | None = None,
|
|
182
|
+
) -> None:
|
|
183
|
+
super().__init__(
|
|
184
|
+
api_key=api_key,
|
|
185
|
+
api_secret=api_secret,
|
|
186
|
+
base_url=base_url,
|
|
187
|
+
subdomain=subdomain,
|
|
188
|
+
max_retries=max_retries,
|
|
189
|
+
headers=headers,
|
|
190
|
+
)
|
|
191
|
+
self._owns_http = http_client is None
|
|
192
|
+
self._http = http_client or httpx2.AsyncClient(timeout=timeout)
|
|
193
|
+
|
|
194
|
+
self.events = AsyncEvents(self)
|
|
195
|
+
self.topics = AsyncTopics(self)
|
|
196
|
+
self.requests = AsyncRequests(self)
|
|
197
|
+
self.geo = AsyncGeo(self)
|
|
198
|
+
self.users = AsyncUsers(self)
|
|
199
|
+
self.design = AsyncDesign(self)
|
|
200
|
+
self.pages = AsyncPages(self)
|
|
201
|
+
self.media = AsyncMedia(self)
|
|
202
|
+
self.cards = AsyncCards(self)
|
|
203
|
+
self.etickets = AsyncETickets(self)
|
|
204
|
+
self.tickets = AsyncTickets(self)
|
|
205
|
+
|
|
206
|
+
async def aclose(self) -> None:
|
|
207
|
+
"""Close the underlying HTTP client, unless it was passed in by the caller."""
|
|
208
|
+
if self._owns_http:
|
|
209
|
+
await self._http.aclose()
|
|
210
|
+
|
|
211
|
+
async def __aenter__(self) -> Self:
|
|
212
|
+
return self
|
|
213
|
+
|
|
214
|
+
async def __aexit__(
|
|
215
|
+
self,
|
|
216
|
+
exc_type: type[BaseException] | None,
|
|
217
|
+
exc: BaseException | None,
|
|
218
|
+
tb: TracebackType | None,
|
|
219
|
+
) -> None:
|
|
220
|
+
await self.aclose()
|
|
221
|
+
|
|
222
|
+
async def _request[T](self, endpoint: Endpoint[T]) -> T:
|
|
223
|
+
request = self._build_request(endpoint)
|
|
224
|
+
for attempt in count():
|
|
225
|
+
try:
|
|
226
|
+
response = await self._http.send(request, follow_redirects=False)
|
|
227
|
+
except httpx2.TransportError as exc:
|
|
228
|
+
if self._should_retry(endpoint, exc, attempt):
|
|
229
|
+
await anyio.sleep(self._retry_delay(attempt, exc))
|
|
230
|
+
continue
|
|
231
|
+
if isinstance(exc, httpx2.TimeoutException):
|
|
232
|
+
raise APITimeoutError(request=request) from None
|
|
233
|
+
raise APIConnectionError(request=request) from None
|
|
234
|
+
|
|
235
|
+
if self._should_retry(endpoint, response, attempt):
|
|
236
|
+
await response.aclose()
|
|
237
|
+
await anyio.sleep(self._retry_delay(attempt, response))
|
|
238
|
+
continue
|
|
239
|
+
return self._process_response(endpoint, response)
|
|
240
|
+
raise AssertionError("unreachable")
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import httpx2
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"APIConnectionError",
|
|
7
|
+
"APIError",
|
|
8
|
+
"APIResponseValidationError",
|
|
9
|
+
"APIStatusError",
|
|
10
|
+
"APITimeoutError",
|
|
11
|
+
"AuthenticationError",
|
|
12
|
+
"BadRequestError",
|
|
13
|
+
"Cosplay2Error",
|
|
14
|
+
"InternalServerError",
|
|
15
|
+
"NotFoundError",
|
|
16
|
+
"PermissionDeniedError",
|
|
17
|
+
"RateLimitError",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Cosplay2Error(Exception):
|
|
22
|
+
"""Base class for every error raised by cosplay2py."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class APIError(Cosplay2Error):
|
|
26
|
+
"""An error that happened while talking to the Cosplay2 API."""
|
|
27
|
+
|
|
28
|
+
message: str
|
|
29
|
+
request: httpx2.Request
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str, *, request: httpx2.Request) -> None:
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.message = message
|
|
34
|
+
self.request = request
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class APIConnectionError(APIError):
|
|
38
|
+
"""The request never got a response (DNS failure, refused connection, etc.)."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self, *, request: httpx2.Request, message: str = "Connection error."
|
|
42
|
+
) -> None:
|
|
43
|
+
super().__init__(message, request=request)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class APITimeoutError(APIConnectionError):
|
|
47
|
+
"""The request timed out."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, *, request: httpx2.Request) -> None:
|
|
50
|
+
super().__init__(request=request, message="Request timed out.")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class APIResponseValidationError(APIError):
|
|
54
|
+
"""The server answered successfully, but the payload did not match the expected shape."""
|
|
55
|
+
|
|
56
|
+
response: httpx2.Response
|
|
57
|
+
status_code: int
|
|
58
|
+
|
|
59
|
+
def __init__(self, message: str, *, response: httpx2.Response) -> None:
|
|
60
|
+
super().__init__(message, request=response.request)
|
|
61
|
+
self.response = response
|
|
62
|
+
self.status_code = response.status_code
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class APIStatusError(APIError):
|
|
66
|
+
"""The server answered with a non-2xx status code."""
|
|
67
|
+
|
|
68
|
+
response: httpx2.Response
|
|
69
|
+
status_code: int
|
|
70
|
+
body: Any
|
|
71
|
+
|
|
72
|
+
def __init__(self, message: str, *, response: httpx2.Response, body: Any) -> None:
|
|
73
|
+
super().__init__(message, request=response.request)
|
|
74
|
+
self.response = response
|
|
75
|
+
self.status_code = response.status_code
|
|
76
|
+
self.body = body
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class BadRequestError(APIStatusError):
|
|
80
|
+
"""HTTP 400."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AuthenticationError(APIStatusError):
|
|
84
|
+
"""HTTP 401: the API key or secret is missing, invalid or expired."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class PermissionDeniedError(APIStatusError):
|
|
88
|
+
"""HTTP 403: the token has no access to this event or action."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class NotFoundError(APIStatusError):
|
|
92
|
+
"""HTTP 404."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class RateLimitError(APIStatusError):
|
|
96
|
+
"""HTTP 429."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class InternalServerError(APIStatusError):
|
|
100
|
+
"""HTTP 5xx."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_STATUS_ERRORS: dict[int, type[APIStatusError]] = {
|
|
104
|
+
400: BadRequestError,
|
|
105
|
+
401: AuthenticationError,
|
|
106
|
+
403: PermissionDeniedError,
|
|
107
|
+
404: NotFoundError,
|
|
108
|
+
429: RateLimitError,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def make_status_error(response: httpx2.Response) -> APIStatusError:
|
|
113
|
+
"""Build the most specific ``APIStatusError`` subclass for ``response``."""
|
|
114
|
+
try:
|
|
115
|
+
body: Any = response.json()
|
|
116
|
+
except ValueError:
|
|
117
|
+
body = response.text
|
|
118
|
+
|
|
119
|
+
message = f"HTTP {response.status_code} API request failed."
|
|
120
|
+
|
|
121
|
+
if response.status_code >= 500:
|
|
122
|
+
cls: type[APIStatusError] = InternalServerError
|
|
123
|
+
else:
|
|
124
|
+
cls = _STATUS_ERRORS.get(response.status_code, APIStatusError)
|
|
125
|
+
return cls(message, response=response, body=body)
|
cosplay2py/_types.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Reusable annotated types that normalize Cosplay2's inconsistent wire formats."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import Annotated, Any
|
|
6
|
+
|
|
7
|
+
from pydantic import AfterValidator, BeforeValidator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _as_utc(value: datetime) -> datetime:
|
|
11
|
+
return value.replace(tzinfo=UTC) if value.tzinfo is None else value
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_local(value: Any) -> Any:
|
|
15
|
+
if isinstance(value, str):
|
|
16
|
+
return datetime.strptime(value, "%d.%m.%y %H:%M") # noqa: DTZ007 - naive by design
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse_json(value: Any) -> Any:
|
|
21
|
+
if isinstance(value, str):
|
|
22
|
+
return json.loads(value) if value.strip() else None
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _empty_as_none(value: Any) -> Any:
|
|
27
|
+
return None if value == "" else value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
UTCDateTime = Annotated[datetime, AfterValidator(_as_utc)]
|
|
31
|
+
"""A timezone-aware UTC datetime.
|
|
32
|
+
|
|
33
|
+
The API sends these as ``"YYYY-MM-DD HH:MM:SS"`` strings (in UTC), Unix seconds
|
|
34
|
+
or Unix milliseconds depending on the endpoint.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
LocalDateTime = Annotated[datetime, BeforeValidator(_parse_local)]
|
|
38
|
+
"""A naive datetime, exactly as displayed by the site.
|
|
39
|
+
|
|
40
|
+
Some endpoints return pre-formatted ``"DD.MM.YY HH:MM"`` strings meant for display;
|
|
41
|
+
these are usually in the event's timezone (see ``Event.timezone``).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
JsonEncoded = BeforeValidator(_parse_json)
|
|
45
|
+
"""Marker for fields that the API returns as a JSON document packed into a string."""
|
|
46
|
+
|
|
47
|
+
EmptyAsNone = BeforeValidator(_empty_as_none)
|
|
48
|
+
"""Marker for optional fields where the API sends ``""`` instead of ``null``."""
|