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 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``."""