coderpad-py 2026.7.22__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.
coderpad/exceptions.py ADDED
@@ -0,0 +1,115 @@
1
+ """Custom exception hierarchy for the CoderPad API."""
2
+
3
+ from http import HTTPStatus
4
+ from typing import ClassVar
5
+
6
+ from coderpad.transports import TransportResponse
7
+
8
+
9
+ class CoderPadError(Exception):
10
+ """Base exception for all CoderPad API errors.
11
+
12
+ Attributes:
13
+ response: The full transport response for debugging.
14
+ status_code: The HTTP status code.
15
+ content: The response body.
16
+ """
17
+
18
+ _registry: ClassVar[dict[int, type["CoderPadError"]]] = {}
19
+
20
+ def __init_subclass__(
21
+ cls,
22
+ *,
23
+ status_code: HTTPStatus | None = None,
24
+ ) -> None:
25
+ """Register subclass for a specific HTTP status code.
26
+
27
+ Args:
28
+ status_code: The HTTP status code to map.
29
+ """
30
+ super().__init_subclass__()
31
+ if status_code is not None:
32
+ CoderPadError._registry[status_code.value] = cls
33
+
34
+ def __init__(
35
+ self,
36
+ *,
37
+ response: TransportResponse,
38
+ ) -> None:
39
+ """Create a new CoderPad error.
40
+
41
+ Args:
42
+ response: The transport response that caused
43
+ the error.
44
+ """
45
+ message = f"HTTP {response.status_code}"
46
+ super().__init__(message)
47
+ self.response: TransportResponse = response
48
+ self.status_code: int = response.status_code
49
+ self.content: bytes = response.content
50
+
51
+ @classmethod
52
+ def from_response(
53
+ cls,
54
+ *,
55
+ response: TransportResponse,
56
+ ) -> "CoderPadError":
57
+ """Create the appropriate exception for a response.
58
+
59
+ Uses the registry to find a specific exception class
60
+ for the response's status code, falling back to
61
+ ``CoderPadError``.
62
+
63
+ Args:
64
+ response: The transport response.
65
+
66
+ Returns:
67
+ The appropriate exception instance.
68
+ """
69
+ exc_cls = cls._registry.get(
70
+ response.status_code,
71
+ CoderPadError,
72
+ )
73
+ return exc_cls(response=response)
74
+
75
+
76
+ class BadRequestError(
77
+ CoderPadError,
78
+ status_code=HTTPStatus.BAD_REQUEST,
79
+ ):
80
+ """Raised for 400 Bad Request responses."""
81
+
82
+
83
+ class AuthenticationError(
84
+ CoderPadError,
85
+ status_code=HTTPStatus.UNAUTHORIZED,
86
+ ):
87
+ """Raised for 401 Unauthorized responses."""
88
+
89
+
90
+ class ForbiddenError(
91
+ CoderPadError,
92
+ status_code=HTTPStatus.FORBIDDEN,
93
+ ):
94
+ """Raised for 403 Forbidden responses."""
95
+
96
+
97
+ class NotFoundError(
98
+ CoderPadError,
99
+ status_code=HTTPStatus.NOT_FOUND,
100
+ ):
101
+ """Raised for 404 Not Found responses."""
102
+
103
+
104
+ class RateLimitError(
105
+ CoderPadError,
106
+ status_code=HTTPStatus.TOO_MANY_REQUESTS,
107
+ ):
108
+ """Raised for 429 Too Many Requests responses."""
109
+
110
+
111
+ class ServerError(
112
+ CoderPadError,
113
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
114
+ ):
115
+ """Raised for 500 Internal Server Error responses."""
coderpad/py.typed ADDED
File without changes
coderpad/transports.py ADDED
@@ -0,0 +1,279 @@
1
+ """Transport abstractions for the CoderPad Interview API."""
2
+
3
+ import json as json_module
4
+ from dataclasses import dataclass
5
+ from http import HTTPStatus
6
+ from typing import Any, Protocol, Self, runtime_checkable
7
+
8
+ import httpx
9
+ from beartype import beartype
10
+
11
+
12
+ class HTTPStatusError(Exception):
13
+ """Raised when an HTTP response has an error status code."""
14
+
15
+ def __init__(
16
+ self,
17
+ *,
18
+ status_code: int,
19
+ content: bytes,
20
+ ) -> None:
21
+ """Create a new HTTP status error.
22
+
23
+ Args:
24
+ status_code: The HTTP status code.
25
+ content: The response body.
26
+ """
27
+ message = f"HTTP {status_code}"
28
+ super().__init__(message)
29
+ self.status_code = status_code
30
+ self.content = content
31
+
32
+
33
+ @beartype
34
+ @dataclass(frozen=True, kw_only=True)
35
+ class TransportResponse:
36
+ """A response from a transport."""
37
+
38
+ status_code: int
39
+ headers: dict[str, str]
40
+ content: bytes
41
+
42
+ def json(self) -> Any: # noqa: ANN401
43
+ """Parse the response body as JSON.
44
+
45
+ Returns:
46
+ The parsed JSON data.
47
+ """
48
+ return json_module.loads(s=self.content)
49
+
50
+ def raise_for_status(self) -> None:
51
+ """Raise an error if the response has an error status.
52
+
53
+ Raises:
54
+ HTTPStatusError: If the status code is 400 or above.
55
+ """
56
+ if self.status_code >= HTTPStatus.BAD_REQUEST:
57
+ raise HTTPStatusError(
58
+ status_code=self.status_code,
59
+ content=self.content,
60
+ )
61
+
62
+
63
+ @runtime_checkable
64
+ class Transport(Protocol):
65
+ """Protocol for HTTP transports.
66
+
67
+ A transport is a callable that makes an HTTP request and
68
+ returns a ``TransportResponse``.
69
+ """
70
+
71
+ def __call__(
72
+ self,
73
+ *,
74
+ method: str,
75
+ url: str,
76
+ headers: dict[str, str],
77
+ params: dict[str, str | int] | None = None,
78
+ data: dict[str, str] | None = None,
79
+ files: (dict[str, tuple[str, bytes, str]] | None) = None,
80
+ ) -> TransportResponse:
81
+ """Make an HTTP request.
82
+
83
+ Args:
84
+ method: The HTTP method (e.g. ``GET``, ``POST``).
85
+ url: The full URL to request.
86
+ headers: Headers to send with the request.
87
+ params: Query parameters.
88
+ data: Form data to send in the request body.
89
+ files: Files to upload as multipart form data.
90
+ Each key is the field name, and the value is
91
+ a ``(filename, content, content_type)`` tuple.
92
+
93
+ Returns:
94
+ A ``TransportResponse`` populated from the HTTP
95
+ response.
96
+ """
97
+ ... # pylint: disable=unnecessary-ellipsis
98
+
99
+
100
+ @beartype
101
+ class HTTPXTransport:
102
+ """HTTP transport using the ``httpx`` library.
103
+
104
+ This is the default transport. It uses a shared
105
+ ``httpx.Client`` for connection pooling.
106
+ """
107
+
108
+ def __init__(self) -> None:
109
+ """Create a new HTTPX transport."""
110
+ self._client = httpx.Client()
111
+
112
+ def close(self) -> None:
113
+ """Close the underlying HTTP client."""
114
+ self._client.close()
115
+
116
+ def __enter__(self) -> Self:
117
+ """Enter the context manager.
118
+
119
+ Returns:
120
+ This transport instance.
121
+ """
122
+ return self
123
+
124
+ def __exit__(
125
+ self,
126
+ _exc_type: type[BaseException] | None,
127
+ _exc_val: BaseException | None,
128
+ _exc_tb: object,
129
+ ) -> None:
130
+ """Exit the context manager and close the client."""
131
+ self.close()
132
+
133
+ def __call__(
134
+ self,
135
+ *,
136
+ method: str,
137
+ url: str,
138
+ headers: dict[str, str],
139
+ params: dict[str, str | int] | None = None,
140
+ data: dict[str, str] | None = None,
141
+ files: (dict[str, tuple[str, bytes, str]] | None) = None,
142
+ ) -> TransportResponse:
143
+ """Make an HTTP request using ``httpx``.
144
+
145
+ Args:
146
+ method: The HTTP method.
147
+ url: The full URL.
148
+ headers: Request headers.
149
+ params: Query parameters.
150
+ data: Form data to send in the request body.
151
+ files: Files to upload as multipart form data.
152
+
153
+ Returns:
154
+ A ``TransportResponse`` populated from the httpx
155
+ response.
156
+ """
157
+ response = self._client.request(
158
+ method=method,
159
+ url=url,
160
+ headers=headers,
161
+ params=params,
162
+ data=data,
163
+ files=files,
164
+ )
165
+ return TransportResponse(
166
+ status_code=response.status_code,
167
+ headers=dict(response.headers),
168
+ content=response.content,
169
+ )
170
+
171
+
172
+ @runtime_checkable
173
+ class AsyncTransport(Protocol):
174
+ """Protocol for async HTTP transports.
175
+
176
+ An async transport is a callable that makes an async HTTP
177
+ request and returns a ``TransportResponse``.
178
+ """
179
+
180
+ async def __call__(
181
+ self,
182
+ *,
183
+ method: str,
184
+ url: str,
185
+ headers: dict[str, str],
186
+ params: dict[str, str | int] | None = None,
187
+ data: dict[str, str] | None = None,
188
+ files: (dict[str, tuple[str, bytes, str]] | None) = None,
189
+ ) -> TransportResponse:
190
+ """Make an async HTTP request.
191
+
192
+ Args:
193
+ method: The HTTP method (e.g. ``GET``, ``POST``).
194
+ url: The full URL to request.
195
+ headers: Headers to send with the request.
196
+ params: Query parameters.
197
+ data: Form data to send in the request body.
198
+ files: Files to upload as multipart form data.
199
+ Each key is the field name, and the value is
200
+ a ``(filename, content, content_type)`` tuple.
201
+
202
+ Returns:
203
+ A ``TransportResponse`` populated from the HTTP
204
+ response.
205
+ """
206
+ ... # pylint: disable=unnecessary-ellipsis
207
+
208
+
209
+ @beartype
210
+ class AsyncHTTPXTransport:
211
+ """Async HTTP transport using the ``httpx`` library.
212
+
213
+ This is the default async transport. It uses a shared
214
+ ``httpx.AsyncClient`` for connection pooling.
215
+ """
216
+
217
+ def __init__(self) -> None:
218
+ """Create a new async HTTPX transport."""
219
+ self._client = httpx.AsyncClient()
220
+
221
+ async def aclose(self) -> None:
222
+ """Close the underlying async HTTP client."""
223
+ await self._client.aclose()
224
+
225
+ async def __aenter__(self) -> Self:
226
+ """Enter the async context manager.
227
+
228
+ Returns:
229
+ This transport instance.
230
+ """
231
+ return self
232
+
233
+ async def __aexit__(
234
+ self,
235
+ _exc_type: type[BaseException] | None,
236
+ _exc_val: BaseException | None,
237
+ _exc_tb: object,
238
+ /,
239
+ ) -> None:
240
+ """Exit the async context manager and close."""
241
+ await self.aclose()
242
+
243
+ async def __call__(
244
+ self,
245
+ *,
246
+ method: str,
247
+ url: str,
248
+ headers: dict[str, str],
249
+ params: dict[str, str | int] | None = None,
250
+ data: dict[str, str] | None = None,
251
+ files: (dict[str, tuple[str, bytes, str]] | None) = None,
252
+ ) -> TransportResponse:
253
+ """Make an async HTTP request using ``httpx``.
254
+
255
+ Args:
256
+ method: The HTTP method.
257
+ url: The full URL.
258
+ headers: Request headers.
259
+ params: Query parameters.
260
+ data: Form data to send in the request body.
261
+ files: Files to upload as multipart form data.
262
+
263
+ Returns:
264
+ A ``TransportResponse`` populated from the httpx
265
+ response.
266
+ """
267
+ response = await self._client.request(
268
+ method=method,
269
+ url=url,
270
+ headers=headers,
271
+ params=params,
272
+ data=data,
273
+ files=files,
274
+ )
275
+ return TransportResponse(
276
+ status_code=response.status_code,
277
+ headers=dict(response.headers),
278
+ content=response.content,
279
+ )