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/__init__.py +1 -0
- coderpad/_dict_types.py +240 -0
- coderpad/async_client.py +810 -0
- coderpad/client.py +789 -0
- coderpad/exceptions.py +115 -0
- coderpad/py.typed +0 -0
- coderpad/transports.py +279 -0
- coderpad/types.py +1045 -0
- coderpad_py-2026.7.22.dist-info/METADATA +117 -0
- coderpad_py-2026.7.22.dist-info/RECORD +13 -0
- coderpad_py-2026.7.22.dist-info/WHEEL +5 -0
- coderpad_py-2026.7.22.dist-info/licenses/LICENSE +21 -0
- coderpad_py-2026.7.22.dist-info/top_level.txt +1 -0
coderpad/client.py
ADDED
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
"""CoderPad Interview API client."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from http import HTTPStatus
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Self
|
|
8
|
+
|
|
9
|
+
from beartype import beartype
|
|
10
|
+
|
|
11
|
+
from coderpad.exceptions import CoderPadError
|
|
12
|
+
from coderpad.transports import (
|
|
13
|
+
HTTPXTransport,
|
|
14
|
+
Transport,
|
|
15
|
+
TransportResponse,
|
|
16
|
+
)
|
|
17
|
+
from coderpad.types import (
|
|
18
|
+
CandidateInstruction,
|
|
19
|
+
Language,
|
|
20
|
+
Organization,
|
|
21
|
+
OrganizationStats,
|
|
22
|
+
Pad,
|
|
23
|
+
PadEnvironment,
|
|
24
|
+
PadEvent,
|
|
25
|
+
PadHistory,
|
|
26
|
+
PaginatedList,
|
|
27
|
+
Question,
|
|
28
|
+
QuestionFileContent,
|
|
29
|
+
Quota,
|
|
30
|
+
SortOrder,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@beartype
|
|
35
|
+
class _Namespace:
|
|
36
|
+
"""Base class providing shared request logic."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
transport: Transport,
|
|
42
|
+
base_url: str,
|
|
43
|
+
headers: dict[str, str],
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Create a new namespace.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
transport: The HTTP transport.
|
|
49
|
+
base_url: The base URL for the API.
|
|
50
|
+
headers: Headers to send with every request.
|
|
51
|
+
"""
|
|
52
|
+
self.transport = transport
|
|
53
|
+
self.base_url = base_url
|
|
54
|
+
self.headers = headers
|
|
55
|
+
|
|
56
|
+
def _request(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
method: str,
|
|
60
|
+
url: str,
|
|
61
|
+
params: dict[str, str | int] | None = None,
|
|
62
|
+
data: dict[str, str] | None = None,
|
|
63
|
+
files: (dict[str, tuple[str, bytes, str]] | None) = None,
|
|
64
|
+
) -> TransportResponse:
|
|
65
|
+
"""Make an HTTP request.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
method: The HTTP method.
|
|
69
|
+
url: The URL path.
|
|
70
|
+
params: Query parameters.
|
|
71
|
+
data: Form data.
|
|
72
|
+
files: Files to upload as multipart form data.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
The transport response.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
CoderPadError: If the response has an error
|
|
79
|
+
status code.
|
|
80
|
+
"""
|
|
81
|
+
response = self.transport(
|
|
82
|
+
method=method,
|
|
83
|
+
url=self.base_url + url,
|
|
84
|
+
headers=self.headers,
|
|
85
|
+
params=params,
|
|
86
|
+
data=data,
|
|
87
|
+
files=files,
|
|
88
|
+
)
|
|
89
|
+
if response.status_code >= HTTPStatus.BAD_REQUEST:
|
|
90
|
+
raise CoderPadError.from_response(response=response)
|
|
91
|
+
return response
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@beartype
|
|
95
|
+
class PadsNamespace(_Namespace):
|
|
96
|
+
"""Namespace for pad operations."""
|
|
97
|
+
|
|
98
|
+
def list(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
sort: SortOrder | None = None,
|
|
102
|
+
page: int | None = None,
|
|
103
|
+
) -> PaginatedList[Pad]:
|
|
104
|
+
"""Retrieve a list of pads.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
sort: Sort order.
|
|
108
|
+
page: Page number for pagination.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
The list of pads with pagination metadata.
|
|
112
|
+
"""
|
|
113
|
+
params: dict[str, str | int] = {}
|
|
114
|
+
if sort is not None:
|
|
115
|
+
params["sort"] = sort.value
|
|
116
|
+
if page is not None:
|
|
117
|
+
params["page"] = page
|
|
118
|
+
response = self._request(
|
|
119
|
+
method="GET",
|
|
120
|
+
url="/api/pads/",
|
|
121
|
+
params=params,
|
|
122
|
+
)
|
|
123
|
+
data = response.json()
|
|
124
|
+
return PaginatedList(
|
|
125
|
+
[Pad.from_dict(data=item) for item in data["pads"]],
|
|
126
|
+
total=data["total"],
|
|
127
|
+
next_page=data.get("next_page"),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def create(
|
|
131
|
+
self,
|
|
132
|
+
*,
|
|
133
|
+
title: str | None = None,
|
|
134
|
+
language: Language | str | None = None,
|
|
135
|
+
contents: str | None = None,
|
|
136
|
+
notes: str | None = None,
|
|
137
|
+
question_id: str | int | None = None,
|
|
138
|
+
) -> Pad:
|
|
139
|
+
"""Create a new pad.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
title: Title for the pad.
|
|
143
|
+
language: Programming language for the pad.
|
|
144
|
+
contents: Initial contents of the pad editor.
|
|
145
|
+
notes: Private notes for the interviewer.
|
|
146
|
+
question_id: Id of an existing question to seed
|
|
147
|
+
the pad from.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The created pad.
|
|
151
|
+
"""
|
|
152
|
+
data: dict[str, str] = {}
|
|
153
|
+
if title is not None:
|
|
154
|
+
data["title"] = title
|
|
155
|
+
if language is not None:
|
|
156
|
+
lang = (
|
|
157
|
+
language.value if isinstance(language, Language) else language
|
|
158
|
+
)
|
|
159
|
+
data["language"] = lang
|
|
160
|
+
if contents is not None:
|
|
161
|
+
data["contents"] = contents
|
|
162
|
+
if notes is not None:
|
|
163
|
+
data["notes"] = notes
|
|
164
|
+
if question_id is not None:
|
|
165
|
+
data["question_id"] = str(object=question_id)
|
|
166
|
+
response = self._request(
|
|
167
|
+
method="POST",
|
|
168
|
+
url="/api/pads/",
|
|
169
|
+
data=data,
|
|
170
|
+
)
|
|
171
|
+
return Pad.from_dict(data=response.json())
|
|
172
|
+
|
|
173
|
+
def get(self, *, pad_id: str) -> Pad:
|
|
174
|
+
"""Retrieve a pad by id.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
pad_id: The id of the pad.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
The pad.
|
|
181
|
+
"""
|
|
182
|
+
response = self._request(
|
|
183
|
+
method="GET",
|
|
184
|
+
url=f"/api/pads/{pad_id}",
|
|
185
|
+
)
|
|
186
|
+
return Pad.from_dict(data=response.json())
|
|
187
|
+
|
|
188
|
+
def update(
|
|
189
|
+
self,
|
|
190
|
+
*,
|
|
191
|
+
pad_id: str,
|
|
192
|
+
title: str | None = None,
|
|
193
|
+
language: Language | str | None = None,
|
|
194
|
+
contents: str | None = None,
|
|
195
|
+
notes: str | None = None,
|
|
196
|
+
ended: bool | None = None,
|
|
197
|
+
deleted: bool | None = None,
|
|
198
|
+
) -> None:
|
|
199
|
+
"""Modify an existing pad.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
pad_id: The id of the pad.
|
|
203
|
+
title: New title for the pad.
|
|
204
|
+
language: New programming language.
|
|
205
|
+
contents: New contents of the pad editor.
|
|
206
|
+
notes: New private notes.
|
|
207
|
+
ended: Set to ``True`` to end the interview.
|
|
208
|
+
deleted: Set to ``True`` to delete the pad.
|
|
209
|
+
"""
|
|
210
|
+
data: dict[str, str] = {}
|
|
211
|
+
if title is not None:
|
|
212
|
+
data["title"] = title
|
|
213
|
+
if language is not None:
|
|
214
|
+
lang = (
|
|
215
|
+
language.value if isinstance(language, Language) else language
|
|
216
|
+
)
|
|
217
|
+
data["language"] = lang
|
|
218
|
+
if contents is not None:
|
|
219
|
+
data["contents"] = contents
|
|
220
|
+
if notes is not None:
|
|
221
|
+
data["notes"] = notes
|
|
222
|
+
if ended is not None:
|
|
223
|
+
data["ended"] = "true" if ended else "false"
|
|
224
|
+
if deleted is not None:
|
|
225
|
+
data["deleted"] = "true" if deleted else "false"
|
|
226
|
+
self._request(
|
|
227
|
+
method="PUT",
|
|
228
|
+
url=f"/api/pads/{pad_id}",
|
|
229
|
+
data=data,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def get_events(
|
|
233
|
+
self,
|
|
234
|
+
*,
|
|
235
|
+
pad_id: str,
|
|
236
|
+
sort: SortOrder | None = None,
|
|
237
|
+
page: int | None = None,
|
|
238
|
+
) -> PaginatedList[PadEvent]:
|
|
239
|
+
"""Retrieve a list of pad events.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
pad_id: The id of the pad.
|
|
243
|
+
sort: Sort order.
|
|
244
|
+
page: Page number for pagination.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
The list of pad events with pagination metadata.
|
|
248
|
+
"""
|
|
249
|
+
params: dict[str, str | int] = {}
|
|
250
|
+
if sort is not None:
|
|
251
|
+
params["sort"] = sort.value
|
|
252
|
+
if page is not None:
|
|
253
|
+
params["page"] = page
|
|
254
|
+
response = self._request(
|
|
255
|
+
method="GET",
|
|
256
|
+
url=f"/api/pads/{pad_id}/events",
|
|
257
|
+
params=params,
|
|
258
|
+
)
|
|
259
|
+
data = response.json()
|
|
260
|
+
return PaginatedList(
|
|
261
|
+
[PadEvent.from_dict(data=item) for item in data["events"]],
|
|
262
|
+
total=data["total"],
|
|
263
|
+
next_page=data.get("next_page"),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def get_environment(
|
|
267
|
+
self,
|
|
268
|
+
*,
|
|
269
|
+
environment_id: str,
|
|
270
|
+
) -> PadEnvironment:
|
|
271
|
+
"""Retrieve pad environment information.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
environment_id: The id of the pad environment.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
The pad environment.
|
|
278
|
+
"""
|
|
279
|
+
response = self._request(
|
|
280
|
+
method="GET",
|
|
281
|
+
url=f"/api/pad_environments/{environment_id}",
|
|
282
|
+
)
|
|
283
|
+
return PadEnvironment.from_dict(
|
|
284
|
+
data=response.json(),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def get_history(self, *, history_url: str) -> PadHistory:
|
|
288
|
+
"""Retrieve editor history from a Firebase history URL.
|
|
289
|
+
|
|
290
|
+
The URL is available as ``FileContent.history``. The CoderPad
|
|
291
|
+
API key is deliberately not sent to the Firebase host.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
history_url: The history URL returned for a file in a pad
|
|
295
|
+
environment.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
The chronologically ordered editor history. An empty
|
|
299
|
+
history is returned when Firebase responds with ``null``.
|
|
300
|
+
"""
|
|
301
|
+
response = self.transport(
|
|
302
|
+
method="GET",
|
|
303
|
+
url=history_url,
|
|
304
|
+
headers={"Accept": "application/json"},
|
|
305
|
+
params=None,
|
|
306
|
+
data=None,
|
|
307
|
+
files=None,
|
|
308
|
+
)
|
|
309
|
+
if response.status_code >= HTTPStatus.BAD_REQUEST:
|
|
310
|
+
raise CoderPadError.from_response(response=response)
|
|
311
|
+
data = response.json()
|
|
312
|
+
if data is None:
|
|
313
|
+
return PadHistory()
|
|
314
|
+
return PadHistory.from_dict(data=data)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@beartype
|
|
318
|
+
class QuestionsNamespace(_Namespace):
|
|
319
|
+
"""Namespace for question operations."""
|
|
320
|
+
|
|
321
|
+
def list(
|
|
322
|
+
self,
|
|
323
|
+
*,
|
|
324
|
+
sort: SortOrder | None = None,
|
|
325
|
+
page: int | None = None,
|
|
326
|
+
) -> PaginatedList[Question]:
|
|
327
|
+
"""Retrieve a list of questions.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
sort: Sort order.
|
|
331
|
+
page: Page number for pagination.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
The list of questions with pagination metadata.
|
|
335
|
+
"""
|
|
336
|
+
params: dict[str, str | int] = {}
|
|
337
|
+
if sort is not None:
|
|
338
|
+
params["sort"] = sort.value
|
|
339
|
+
if page is not None:
|
|
340
|
+
params["page"] = page
|
|
341
|
+
response = self._request(
|
|
342
|
+
method="GET",
|
|
343
|
+
url="/api/questions/",
|
|
344
|
+
params=params,
|
|
345
|
+
)
|
|
346
|
+
data = response.json()
|
|
347
|
+
return PaginatedList(
|
|
348
|
+
[Question.from_dict(data=item) for item in data["questions"]],
|
|
349
|
+
total=data["total"],
|
|
350
|
+
next_page=data.get("next_page"),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def create(
|
|
354
|
+
self,
|
|
355
|
+
*,
|
|
356
|
+
title: str,
|
|
357
|
+
language: Language | str,
|
|
358
|
+
description: str | None = None,
|
|
359
|
+
contents: str | None = None,
|
|
360
|
+
solution: str | None = None,
|
|
361
|
+
ai_assist_custom_system_prompt: str | None = None,
|
|
362
|
+
candidate_instructions: (Sequence[CandidateInstruction] | None) = None,
|
|
363
|
+
file_contents: (Sequence[QuestionFileContent] | None) = None,
|
|
364
|
+
zip_file: Path | None = None,
|
|
365
|
+
) -> Question:
|
|
366
|
+
"""Create a new question.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
title: Title for the question.
|
|
370
|
+
language: Programming language for the question.
|
|
371
|
+
description: Notes about the question.
|
|
372
|
+
contents: Text inserted into the interview
|
|
373
|
+
session. Cannot be combined with
|
|
374
|
+
``file_contents``.
|
|
375
|
+
solution: The solution to the question.
|
|
376
|
+
ai_assist_custom_system_prompt: Custom system prompt for AI Assist.
|
|
377
|
+
candidate_instructions: Progressively-revealed
|
|
378
|
+
instruction blocks shown to the candidate.
|
|
379
|
+
file_contents: Files for a multi-file question.
|
|
380
|
+
Cannot be combined with ``contents`` or
|
|
381
|
+
``zip_file``.
|
|
382
|
+
zip_file: Path to a zip archive containing
|
|
383
|
+
files for a multi-file question. Cannot be
|
|
384
|
+
combined with ``file_contents``.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
The created question.
|
|
388
|
+
"""
|
|
389
|
+
lang = language.value if isinstance(language, Language) else language
|
|
390
|
+
data: dict[str, str] = {
|
|
391
|
+
"question[title]": title,
|
|
392
|
+
"question[language]": lang,
|
|
393
|
+
}
|
|
394
|
+
if description is not None:
|
|
395
|
+
data["question[description]"] = description
|
|
396
|
+
if contents is not None:
|
|
397
|
+
data["question[contents]"] = contents
|
|
398
|
+
if solution is not None:
|
|
399
|
+
data["question[solution]"] = solution
|
|
400
|
+
if ai_assist_custom_system_prompt is not None:
|
|
401
|
+
data["question[ai_assist_custom_system_prompt]"] = (
|
|
402
|
+
ai_assist_custom_system_prompt
|
|
403
|
+
)
|
|
404
|
+
if candidate_instructions is not None:
|
|
405
|
+
data["question[candidate_instructions]"] = json.dumps(
|
|
406
|
+
obj=[
|
|
407
|
+
{
|
|
408
|
+
"instructions": ci.instructions,
|
|
409
|
+
"default_visible": ci.default_visible,
|
|
410
|
+
}
|
|
411
|
+
for ci in candidate_instructions
|
|
412
|
+
],
|
|
413
|
+
)
|
|
414
|
+
if file_contents is not None:
|
|
415
|
+
data["question[file_contents]"] = json.dumps(
|
|
416
|
+
obj=[
|
|
417
|
+
{
|
|
418
|
+
"path": fc.path,
|
|
419
|
+
"contents": fc.contents,
|
|
420
|
+
}
|
|
421
|
+
for fc in file_contents
|
|
422
|
+
],
|
|
423
|
+
)
|
|
424
|
+
files: dict[str, tuple[str, bytes, str]] | None = None
|
|
425
|
+
if zip_file is not None:
|
|
426
|
+
files = {
|
|
427
|
+
"question[zip_file]": (
|
|
428
|
+
zip_file.name,
|
|
429
|
+
zip_file.read_bytes(),
|
|
430
|
+
"application/zip",
|
|
431
|
+
),
|
|
432
|
+
}
|
|
433
|
+
response = self._request(
|
|
434
|
+
method="POST",
|
|
435
|
+
url="/api/questions/",
|
|
436
|
+
data=data,
|
|
437
|
+
files=files,
|
|
438
|
+
)
|
|
439
|
+
return Question.from_dict(
|
|
440
|
+
data=response.json(),
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
def get(
|
|
444
|
+
self,
|
|
445
|
+
*,
|
|
446
|
+
question_id: str,
|
|
447
|
+
) -> Question:
|
|
448
|
+
"""Retrieve a question by id.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
question_id: The id of the question.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
The question.
|
|
455
|
+
"""
|
|
456
|
+
response = self._request(
|
|
457
|
+
method="GET",
|
|
458
|
+
url=f"/api/questions/{question_id}",
|
|
459
|
+
)
|
|
460
|
+
return Question.from_dict(
|
|
461
|
+
data=response.json(),
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def update(
|
|
465
|
+
self,
|
|
466
|
+
*,
|
|
467
|
+
question_id: str,
|
|
468
|
+
title: str | None = None,
|
|
469
|
+
language: Language | str | None = None,
|
|
470
|
+
description: str | None = None,
|
|
471
|
+
contents: str | None = None,
|
|
472
|
+
solution: str | None = None,
|
|
473
|
+
candidate_instructions: (Sequence[CandidateInstruction] | None) = None,
|
|
474
|
+
file_contents: (Sequence[QuestionFileContent] | None) = None,
|
|
475
|
+
zip_file: Path | None = None,
|
|
476
|
+
) -> None:
|
|
477
|
+
"""Modify an existing question.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
question_id: The id of the question.
|
|
481
|
+
title: New title for the question.
|
|
482
|
+
language: New programming language.
|
|
483
|
+
description: New description.
|
|
484
|
+
contents: New contents. Cannot be combined with
|
|
485
|
+
``file_contents``.
|
|
486
|
+
solution: New solution.
|
|
487
|
+
candidate_instructions: Progressively-revealed
|
|
488
|
+
instruction blocks shown to the candidate.
|
|
489
|
+
file_contents: Files for a multi-file question.
|
|
490
|
+
Cannot be combined with ``contents`` or
|
|
491
|
+
``zip_file``.
|
|
492
|
+
zip_file: Path to a zip archive containing
|
|
493
|
+
files for a multi-file question. Cannot be
|
|
494
|
+
combined with ``file_contents``.
|
|
495
|
+
"""
|
|
496
|
+
data: dict[str, str] = {}
|
|
497
|
+
if title is not None:
|
|
498
|
+
data["question[title]"] = title
|
|
499
|
+
if language is not None:
|
|
500
|
+
lang = (
|
|
501
|
+
language.value if isinstance(language, Language) else language
|
|
502
|
+
)
|
|
503
|
+
data["question[language]"] = lang
|
|
504
|
+
if description is not None:
|
|
505
|
+
data["question[description]"] = description
|
|
506
|
+
if contents is not None:
|
|
507
|
+
data["question[contents]"] = contents
|
|
508
|
+
if solution is not None:
|
|
509
|
+
data["question[solution]"] = solution
|
|
510
|
+
if candidate_instructions is not None:
|
|
511
|
+
data["question[candidate_instructions]"] = json.dumps(
|
|
512
|
+
obj=[
|
|
513
|
+
{
|
|
514
|
+
"instructions": ci.instructions,
|
|
515
|
+
"default_visible": ci.default_visible,
|
|
516
|
+
}
|
|
517
|
+
for ci in candidate_instructions
|
|
518
|
+
],
|
|
519
|
+
)
|
|
520
|
+
if file_contents is not None:
|
|
521
|
+
data["question[file_contents]"] = json.dumps(
|
|
522
|
+
obj=[
|
|
523
|
+
{
|
|
524
|
+
"path": fc.path,
|
|
525
|
+
"contents": fc.contents,
|
|
526
|
+
}
|
|
527
|
+
for fc in file_contents
|
|
528
|
+
],
|
|
529
|
+
)
|
|
530
|
+
files: dict[str, tuple[str, bytes, str]] | None = None
|
|
531
|
+
if zip_file is not None:
|
|
532
|
+
files = {
|
|
533
|
+
"question[zip_file]": (
|
|
534
|
+
zip_file.name,
|
|
535
|
+
zip_file.read_bytes(),
|
|
536
|
+
"application/zip",
|
|
537
|
+
),
|
|
538
|
+
}
|
|
539
|
+
self._request(
|
|
540
|
+
method="PUT",
|
|
541
|
+
url=f"/api/questions/{question_id}",
|
|
542
|
+
data=data,
|
|
543
|
+
files=files,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
def delete(
|
|
547
|
+
self,
|
|
548
|
+
*,
|
|
549
|
+
question_id: str,
|
|
550
|
+
) -> None:
|
|
551
|
+
"""Delete a question.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
question_id: The id of the question.
|
|
555
|
+
"""
|
|
556
|
+
self._request(
|
|
557
|
+
method="DELETE",
|
|
558
|
+
url=f"/api/questions/{question_id}",
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@beartype
|
|
563
|
+
class OrganizationPadsNamespace(_Namespace):
|
|
564
|
+
"""Namespace for organization pad operations."""
|
|
565
|
+
|
|
566
|
+
def list(
|
|
567
|
+
self,
|
|
568
|
+
*,
|
|
569
|
+
sort: SortOrder | None = None,
|
|
570
|
+
page: int | None = None,
|
|
571
|
+
) -> PaginatedList[Pad]:
|
|
572
|
+
"""Retrieve pads for the entire organization.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
sort: Sort order.
|
|
576
|
+
page: Page number for pagination.
|
|
577
|
+
|
|
578
|
+
Returns:
|
|
579
|
+
The list of pads with pagination metadata.
|
|
580
|
+
"""
|
|
581
|
+
params: dict[str, str | int] = {}
|
|
582
|
+
if sort is not None:
|
|
583
|
+
params["sort"] = sort.value
|
|
584
|
+
if page is not None:
|
|
585
|
+
params["page"] = page
|
|
586
|
+
response = self._request(
|
|
587
|
+
method="GET",
|
|
588
|
+
url="/api/organization/pads",
|
|
589
|
+
params=params,
|
|
590
|
+
)
|
|
591
|
+
data = response.json()
|
|
592
|
+
return PaginatedList(
|
|
593
|
+
[Pad.from_dict(data=item) for item in data["pads"]],
|
|
594
|
+
total=data["total"],
|
|
595
|
+
next_page=data.get("next_page"),
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
@beartype
|
|
600
|
+
class OrganizationQuestionsNamespace(_Namespace):
|
|
601
|
+
"""Namespace for organization question operations."""
|
|
602
|
+
|
|
603
|
+
def list(
|
|
604
|
+
self,
|
|
605
|
+
*,
|
|
606
|
+
sort: SortOrder | None = None,
|
|
607
|
+
page: int | None = None,
|
|
608
|
+
) -> PaginatedList[Question]:
|
|
609
|
+
"""Retrieve questions for the entire organization.
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
sort: Sort order.
|
|
613
|
+
page: Page number for pagination.
|
|
614
|
+
|
|
615
|
+
Returns:
|
|
616
|
+
The list of questions with pagination metadata.
|
|
617
|
+
"""
|
|
618
|
+
params: dict[str, str | int] = {}
|
|
619
|
+
if sort is not None:
|
|
620
|
+
params["sort"] = sort.value
|
|
621
|
+
if page is not None:
|
|
622
|
+
params["page"] = page
|
|
623
|
+
response = self._request(
|
|
624
|
+
method="GET",
|
|
625
|
+
url="/api/organization/questions",
|
|
626
|
+
params=params,
|
|
627
|
+
)
|
|
628
|
+
data = response.json()
|
|
629
|
+
return PaginatedList(
|
|
630
|
+
[Question.from_dict(data=item) for item in data["questions"]],
|
|
631
|
+
total=data["total"],
|
|
632
|
+
next_page=data.get("next_page"),
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
@beartype
|
|
637
|
+
class OrganizationNamespace(_Namespace):
|
|
638
|
+
"""Namespace for organization operations."""
|
|
639
|
+
|
|
640
|
+
def __init__(
|
|
641
|
+
self,
|
|
642
|
+
*,
|
|
643
|
+
transport: Transport,
|
|
644
|
+
base_url: str,
|
|
645
|
+
headers: dict[str, str],
|
|
646
|
+
) -> None:
|
|
647
|
+
"""Create a new organization namespace.
|
|
648
|
+
|
|
649
|
+
Args:
|
|
650
|
+
transport: The HTTP transport.
|
|
651
|
+
base_url: The base URL for the API.
|
|
652
|
+
headers: Headers to send with every request.
|
|
653
|
+
"""
|
|
654
|
+
super().__init__(
|
|
655
|
+
transport=transport,
|
|
656
|
+
base_url=base_url,
|
|
657
|
+
headers=headers,
|
|
658
|
+
)
|
|
659
|
+
self.pads: OrganizationPadsNamespace = OrganizationPadsNamespace(
|
|
660
|
+
transport=transport,
|
|
661
|
+
base_url=base_url,
|
|
662
|
+
headers=headers,
|
|
663
|
+
)
|
|
664
|
+
self.questions: OrganizationQuestionsNamespace = (
|
|
665
|
+
OrganizationQuestionsNamespace(
|
|
666
|
+
transport=transport,
|
|
667
|
+
base_url=base_url,
|
|
668
|
+
headers=headers,
|
|
669
|
+
)
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
def get(self) -> Organization:
|
|
673
|
+
"""Retrieve organization information.
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
The organization details.
|
|
677
|
+
"""
|
|
678
|
+
response = self._request(
|
|
679
|
+
method="GET",
|
|
680
|
+
url="/api/organization",
|
|
681
|
+
)
|
|
682
|
+
return Organization.from_dict(
|
|
683
|
+
data=response.json(),
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
def get_stats(
|
|
687
|
+
self,
|
|
688
|
+
*,
|
|
689
|
+
start_time: str | None = None,
|
|
690
|
+
end_time: str | None = None,
|
|
691
|
+
) -> OrganizationStats:
|
|
692
|
+
"""Retrieve pad usage stats for the organization.
|
|
693
|
+
|
|
694
|
+
Args:
|
|
695
|
+
start_time: ISO 8601 start of the search window.
|
|
696
|
+
end_time: ISO 8601 end of the search window.
|
|
697
|
+
|
|
698
|
+
Returns:
|
|
699
|
+
The usage statistics.
|
|
700
|
+
"""
|
|
701
|
+
params: dict[str, str | int] = {}
|
|
702
|
+
if start_time is not None:
|
|
703
|
+
params["start_time"] = start_time
|
|
704
|
+
if end_time is not None:
|
|
705
|
+
params["end_time"] = end_time
|
|
706
|
+
response = self._request(
|
|
707
|
+
method="GET",
|
|
708
|
+
url="/api/organization/stats",
|
|
709
|
+
params=params,
|
|
710
|
+
)
|
|
711
|
+
return OrganizationStats.from_dict(
|
|
712
|
+
data=response.json(),
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
def get_quota(self) -> Quota:
|
|
716
|
+
"""Retrieve quota information.
|
|
717
|
+
|
|
718
|
+
Returns:
|
|
719
|
+
The quota details.
|
|
720
|
+
"""
|
|
721
|
+
response = self._request(
|
|
722
|
+
method="GET",
|
|
723
|
+
url="/api/quota",
|
|
724
|
+
)
|
|
725
|
+
return Quota.from_dict(data=response.json())
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
@beartype
|
|
729
|
+
class CoderPad:
|
|
730
|
+
"""A client for the CoderPad Interview API."""
|
|
731
|
+
|
|
732
|
+
def __init__(
|
|
733
|
+
self,
|
|
734
|
+
*,
|
|
735
|
+
api_key: str,
|
|
736
|
+
base_url: str = "https://app.coderpad.io",
|
|
737
|
+
transport: Transport | None = None,
|
|
738
|
+
) -> None:
|
|
739
|
+
"""Create a new CoderPad client.
|
|
740
|
+
|
|
741
|
+
Args:
|
|
742
|
+
api_key: The API key for authentication.
|
|
743
|
+
base_url: The base URL for the API.
|
|
744
|
+
transport: The HTTP transport. Defaults to
|
|
745
|
+
``HTTPXTransport()``.
|
|
746
|
+
"""
|
|
747
|
+
self.base_url = base_url
|
|
748
|
+
resolved_transport = transport or HTTPXTransport()
|
|
749
|
+
headers = {"Authorization": f'Token token="{api_key}"'}
|
|
750
|
+
self.pads: PadsNamespace = PadsNamespace(
|
|
751
|
+
transport=resolved_transport,
|
|
752
|
+
base_url=base_url,
|
|
753
|
+
headers=headers,
|
|
754
|
+
)
|
|
755
|
+
self.questions: QuestionsNamespace = QuestionsNamespace(
|
|
756
|
+
transport=resolved_transport,
|
|
757
|
+
base_url=base_url,
|
|
758
|
+
headers=headers,
|
|
759
|
+
)
|
|
760
|
+
if isinstance(resolved_transport, HTTPXTransport):
|
|
761
|
+
self._close = resolved_transport.close
|
|
762
|
+
else:
|
|
763
|
+
self._close = lambda: None
|
|
764
|
+
self.organization: OrganizationNamespace = OrganizationNamespace(
|
|
765
|
+
transport=resolved_transport,
|
|
766
|
+
base_url=base_url,
|
|
767
|
+
headers=headers,
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
def close(self) -> None:
|
|
771
|
+
"""Close the underlying transport if it supports closing."""
|
|
772
|
+
self._close()
|
|
773
|
+
|
|
774
|
+
def __enter__(self) -> Self:
|
|
775
|
+
"""Enter the context manager.
|
|
776
|
+
|
|
777
|
+
Returns:
|
|
778
|
+
This client instance.
|
|
779
|
+
"""
|
|
780
|
+
return self
|
|
781
|
+
|
|
782
|
+
def __exit__(
|
|
783
|
+
self,
|
|
784
|
+
_exc_type: type[BaseException] | None,
|
|
785
|
+
_exc_val: BaseException | None,
|
|
786
|
+
_exc_tb: object,
|
|
787
|
+
) -> None:
|
|
788
|
+
"""Exit the context manager and close the transport."""
|
|
789
|
+
self.close()
|