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.
@@ -0,0 +1,810 @@
1
+ """Async 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
+ AsyncHTTPXTransport,
14
+ AsyncTransport,
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 _AsyncNamespace:
36
+ """Base class providing shared async request logic."""
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ transport: AsyncTransport,
42
+ base_url: str,
43
+ headers: dict[str, str],
44
+ ) -> None:
45
+ """Create a new async namespace.
46
+
47
+ Args:
48
+ transport: The async 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
+ async 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 async 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 = await 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 AsyncPadsNamespace(_AsyncNamespace):
96
+ """Namespace for async pad operations."""
97
+
98
+ async 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 = await 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
+ async 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 = await self._request(
167
+ method="POST",
168
+ url="/api/pads/",
169
+ data=data,
170
+ )
171
+ return Pad.from_dict(data=response.json())
172
+
173
+ async 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 = await self._request(
183
+ method="GET",
184
+ url=f"/api/pads/{pad_id}",
185
+ )
186
+ return Pad.from_dict(data=response.json())
187
+
188
+ async 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
+ await self._request(
227
+ method="PUT",
228
+ url=f"/api/pads/{pad_id}",
229
+ data=data,
230
+ )
231
+
232
+ async 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
248
+ metadata.
249
+ """
250
+ params: dict[str, str | int] = {}
251
+ if sort is not None:
252
+ params["sort"] = sort.value
253
+ if page is not None:
254
+ params["page"] = page
255
+ response = await self._request(
256
+ method="GET",
257
+ url=f"/api/pads/{pad_id}/events",
258
+ params=params,
259
+ )
260
+ data = response.json()
261
+ return PaginatedList(
262
+ [PadEvent.from_dict(data=item) for item in data["events"]],
263
+ total=data["total"],
264
+ next_page=data.get("next_page"),
265
+ )
266
+
267
+ async def get_environment(
268
+ self,
269
+ *,
270
+ environment_id: str,
271
+ ) -> PadEnvironment:
272
+ """Retrieve pad environment information.
273
+
274
+ Args:
275
+ environment_id: The id of the pad environment.
276
+
277
+ Returns:
278
+ The pad environment.
279
+ """
280
+ response = await self._request(
281
+ method="GET",
282
+ url=f"/api/pad_environments/{environment_id}",
283
+ )
284
+ return PadEnvironment.from_dict(
285
+ data=response.json(),
286
+ )
287
+
288
+ async def get_history(self, *, history_url: str) -> PadHistory:
289
+ """Retrieve editor history from a Firebase history URL.
290
+
291
+ The URL is available as ``FileContent.history``. The CoderPad
292
+ API key is deliberately not sent to the Firebase host.
293
+
294
+ Args:
295
+ history_url: The history URL returned for a file in a pad
296
+ environment.
297
+
298
+ Returns:
299
+ The chronologically ordered editor history. An empty
300
+ history is returned when Firebase responds with ``null``.
301
+ """
302
+ response = await self.transport(
303
+ method="GET",
304
+ url=history_url,
305
+ headers={"Accept": "application/json"},
306
+ params=None,
307
+ data=None,
308
+ files=None,
309
+ )
310
+ if response.status_code >= HTTPStatus.BAD_REQUEST:
311
+ raise CoderPadError.from_response(response=response)
312
+ data = response.json()
313
+ if data is None:
314
+ return PadHistory()
315
+ return PadHistory.from_dict(data=data)
316
+
317
+
318
+ @beartype
319
+ class AsyncQuestionsNamespace(_AsyncNamespace):
320
+ """Namespace for async question operations."""
321
+
322
+ async def list(
323
+ self,
324
+ *,
325
+ sort: SortOrder | None = None,
326
+ page: int | None = None,
327
+ ) -> PaginatedList[Question]:
328
+ """Retrieve a list of questions.
329
+
330
+ Args:
331
+ sort: Sort order.
332
+ page: Page number for pagination.
333
+
334
+ Returns:
335
+ The list of questions with pagination
336
+ metadata.
337
+ """
338
+ params: dict[str, str | int] = {}
339
+ if sort is not None:
340
+ params["sort"] = sort.value
341
+ if page is not None:
342
+ params["page"] = page
343
+ response = await self._request(
344
+ method="GET",
345
+ url="/api/questions/",
346
+ params=params,
347
+ )
348
+ data = response.json()
349
+ return PaginatedList(
350
+ [Question.from_dict(data=item) for item in data["questions"]],
351
+ total=data["total"],
352
+ next_page=data.get("next_page"),
353
+ )
354
+
355
+ async def create(
356
+ self,
357
+ *,
358
+ title: str,
359
+ language: Language | str,
360
+ description: str | None = None,
361
+ contents: str | None = None,
362
+ solution: str | None = None,
363
+ ai_assist_custom_system_prompt: str | None = None,
364
+ candidate_instructions: (Sequence[CandidateInstruction] | None) = None,
365
+ file_contents: (Sequence[QuestionFileContent] | None) = None,
366
+ zip_file: Path | None = None,
367
+ ) -> Question:
368
+ """Create a new question.
369
+
370
+ Args:
371
+ title: Title for the question.
372
+ language: Programming language for the
373
+ question.
374
+ description: Notes about the question.
375
+ contents: Text inserted into the interview
376
+ session. Cannot be combined with
377
+ ``file_contents``.
378
+ solution: The solution to the question.
379
+ ai_assist_custom_system_prompt: Custom system prompt for AI Assist.
380
+ candidate_instructions: Progressively-revealed
381
+ instruction blocks shown to the candidate.
382
+ file_contents: Files for a multi-file question.
383
+ Cannot be combined with ``contents`` or
384
+ ``zip_file``.
385
+ zip_file: Path to a zip archive containing
386
+ files for a multi-file question. Cannot be
387
+ combined with ``file_contents``.
388
+
389
+ Returns:
390
+ The created question.
391
+ """
392
+ lang = language.value if isinstance(language, Language) else language
393
+ data: dict[str, str] = {
394
+ "question[title]": title,
395
+ "question[language]": lang,
396
+ }
397
+ if description is not None:
398
+ data["question[description]"] = description
399
+ if contents is not None:
400
+ data["question[contents]"] = contents
401
+ if solution is not None:
402
+ data["question[solution]"] = solution
403
+ if ai_assist_custom_system_prompt is not None:
404
+ data["question[ai_assist_custom_system_prompt]"] = (
405
+ ai_assist_custom_system_prompt
406
+ )
407
+ if candidate_instructions is not None:
408
+ data["question[candidate_instructions]"] = json.dumps(
409
+ obj=[
410
+ {
411
+ "instructions": ci.instructions,
412
+ "default_visible": ci.default_visible,
413
+ }
414
+ for ci in candidate_instructions
415
+ ],
416
+ )
417
+ if file_contents is not None:
418
+ data["question[file_contents]"] = json.dumps(
419
+ obj=[
420
+ {
421
+ "path": fc.path,
422
+ "contents": fc.contents,
423
+ }
424
+ for fc in file_contents
425
+ ],
426
+ )
427
+ files: dict[str, tuple[str, bytes, str]] | None = None
428
+ if zip_file is not None:
429
+ files = {
430
+ "question[zip_file]": (
431
+ zip_file.name,
432
+ zip_file.read_bytes(), # noqa: ASYNC240
433
+ "application/zip",
434
+ ),
435
+ }
436
+ response = await self._request(
437
+ method="POST",
438
+ url="/api/questions/",
439
+ data=data,
440
+ files=files,
441
+ )
442
+ return Question.from_dict(
443
+ data=response.json(),
444
+ )
445
+
446
+ async def get(
447
+ self,
448
+ *,
449
+ question_id: str,
450
+ ) -> Question:
451
+ """Retrieve a question by id.
452
+
453
+ Args:
454
+ question_id: The id of the question.
455
+
456
+ Returns:
457
+ The question.
458
+ """
459
+ response = await self._request(
460
+ method="GET",
461
+ url=f"/api/questions/{question_id}",
462
+ )
463
+ return Question.from_dict(
464
+ data=response.json(),
465
+ )
466
+
467
+ async def update(
468
+ self,
469
+ *,
470
+ question_id: str,
471
+ title: str | None = None,
472
+ language: Language | str | None = None,
473
+ description: str | None = None,
474
+ contents: str | None = None,
475
+ solution: str | None = None,
476
+ candidate_instructions: (Sequence[CandidateInstruction] | None) = None,
477
+ file_contents: (Sequence[QuestionFileContent] | None) = None,
478
+ zip_file: Path | None = None,
479
+ ) -> None:
480
+ """Modify an existing question.
481
+
482
+ Args:
483
+ question_id: The id of the question.
484
+ title: New title for the question.
485
+ language: New programming language.
486
+ description: New description.
487
+ contents: New contents. Cannot be combined with
488
+ ``file_contents``.
489
+ solution: New solution.
490
+ candidate_instructions: Progressively-revealed
491
+ instruction blocks shown to the candidate.
492
+ file_contents: Files for a multi-file question.
493
+ Cannot be combined with ``contents`` or
494
+ ``zip_file``.
495
+ zip_file: Path to a zip archive containing
496
+ files for a multi-file question. Cannot be
497
+ combined with ``file_contents``.
498
+ """
499
+ data: dict[str, str] = {}
500
+ if title is not None:
501
+ data["question[title]"] = title
502
+ if language is not None:
503
+ lang = (
504
+ language.value if isinstance(language, Language) else language
505
+ )
506
+ data["question[language]"] = lang
507
+ if description is not None:
508
+ data["question[description]"] = description
509
+ if contents is not None:
510
+ data["question[contents]"] = contents
511
+ if solution is not None:
512
+ data["question[solution]"] = solution
513
+ if candidate_instructions is not None:
514
+ data["question[candidate_instructions]"] = json.dumps(
515
+ obj=[
516
+ {
517
+ "instructions": ci.instructions,
518
+ "default_visible": ci.default_visible,
519
+ }
520
+ for ci in candidate_instructions
521
+ ],
522
+ )
523
+ if file_contents is not None:
524
+ data["question[file_contents]"] = json.dumps(
525
+ obj=[
526
+ {
527
+ "path": fc.path,
528
+ "contents": fc.contents,
529
+ }
530
+ for fc in file_contents
531
+ ],
532
+ )
533
+ files: dict[str, tuple[str, bytes, str]] | None = None
534
+ if zip_file is not None:
535
+ files = {
536
+ "question[zip_file]": (
537
+ zip_file.name,
538
+ zip_file.read_bytes(), # noqa: ASYNC240
539
+ "application/zip",
540
+ ),
541
+ }
542
+ await self._request(
543
+ method="PUT",
544
+ url=f"/api/questions/{question_id}",
545
+ data=data,
546
+ files=files,
547
+ )
548
+
549
+ async def delete(
550
+ self,
551
+ *,
552
+ question_id: str,
553
+ ) -> None:
554
+ """Delete a question.
555
+
556
+ Args:
557
+ question_id: The id of the question.
558
+ """
559
+ await self._request(
560
+ method="DELETE",
561
+ url=f"/api/questions/{question_id}",
562
+ )
563
+
564
+
565
+ @beartype
566
+ class AsyncOrganizationPadsNamespace(_AsyncNamespace):
567
+ """Namespace for async organization pad operations."""
568
+
569
+ async def list(
570
+ self,
571
+ *,
572
+ sort: SortOrder | None = None,
573
+ page: int | None = None,
574
+ ) -> PaginatedList[Pad]:
575
+ """Retrieve pads for the entire organization.
576
+
577
+ Args:
578
+ sort: Sort order.
579
+ page: Page number for pagination.
580
+
581
+ Returns:
582
+ The list of pads with pagination metadata.
583
+ """
584
+ params: dict[str, str | int] = {}
585
+ if sort is not None:
586
+ params["sort"] = sort.value
587
+ if page is not None:
588
+ params["page"] = page
589
+ response = await self._request(
590
+ method="GET",
591
+ url="/api/organization/pads",
592
+ params=params,
593
+ )
594
+ data = response.json()
595
+ return PaginatedList(
596
+ [Pad.from_dict(data=item) for item in data["pads"]],
597
+ total=data["total"],
598
+ next_page=data.get("next_page"),
599
+ )
600
+
601
+
602
+ @beartype
603
+ class AsyncOrganizationQuestionsNamespace(
604
+ _AsyncNamespace,
605
+ ):
606
+ """Namespace for async organization question
607
+ operations.
608
+ """
609
+
610
+ async def list(
611
+ self,
612
+ *,
613
+ sort: SortOrder | None = None,
614
+ page: int | None = None,
615
+ ) -> PaginatedList[Question]:
616
+ """Retrieve questions for the entire organization.
617
+
618
+ Args:
619
+ sort: Sort order.
620
+ page: Page number for pagination.
621
+
622
+ Returns:
623
+ The list of questions with pagination
624
+ metadata.
625
+ """
626
+ params: dict[str, str | int] = {}
627
+ if sort is not None:
628
+ params["sort"] = sort.value
629
+ if page is not None:
630
+ params["page"] = page
631
+ response = await self._request(
632
+ method="GET",
633
+ url="/api/organization/questions",
634
+ params=params,
635
+ )
636
+ data = response.json()
637
+ return PaginatedList(
638
+ [Question.from_dict(data=item) for item in data["questions"]],
639
+ total=data["total"],
640
+ next_page=data.get("next_page"),
641
+ )
642
+
643
+
644
+ @beartype
645
+ class AsyncOrganizationNamespace(_AsyncNamespace):
646
+ """Namespace for async organization operations."""
647
+
648
+ def __init__(
649
+ self,
650
+ *,
651
+ transport: AsyncTransport,
652
+ base_url: str,
653
+ headers: dict[str, str],
654
+ ) -> None:
655
+ """Create a new async organization namespace.
656
+
657
+ Args:
658
+ transport: The async HTTP transport.
659
+ base_url: The base URL for the API.
660
+ headers: Headers to send with every request.
661
+ """
662
+ super().__init__(
663
+ transport=transport,
664
+ base_url=base_url,
665
+ headers=headers,
666
+ )
667
+ self.pads: AsyncOrganizationPadsNamespace = (
668
+ AsyncOrganizationPadsNamespace(
669
+ transport=transport,
670
+ base_url=base_url,
671
+ headers=headers,
672
+ )
673
+ )
674
+ self.questions: AsyncOrganizationQuestionsNamespace = (
675
+ AsyncOrganizationQuestionsNamespace(
676
+ transport=transport,
677
+ base_url=base_url,
678
+ headers=headers,
679
+ )
680
+ )
681
+
682
+ async def get(self) -> Organization:
683
+ """Retrieve organization information.
684
+
685
+ Returns:
686
+ The organization details.
687
+ """
688
+ response = await self._request(
689
+ method="GET",
690
+ url="/api/organization",
691
+ )
692
+ return Organization.from_dict(
693
+ data=response.json(),
694
+ )
695
+
696
+ async def get_stats(
697
+ self,
698
+ *,
699
+ start_time: str | None = None,
700
+ end_time: str | None = None,
701
+ ) -> OrganizationStats:
702
+ """Retrieve pad usage stats for the organization.
703
+
704
+ Args:
705
+ start_time: ISO 8601 start of the search
706
+ window.
707
+ end_time: ISO 8601 end of the search window.
708
+
709
+ Returns:
710
+ The usage statistics.
711
+ """
712
+ params: dict[str, str | int] = {}
713
+ if start_time is not None:
714
+ params["start_time"] = start_time
715
+ if end_time is not None:
716
+ params["end_time"] = end_time
717
+ response = await self._request(
718
+ method="GET",
719
+ url="/api/organization/stats",
720
+ params=params,
721
+ )
722
+ return OrganizationStats.from_dict(
723
+ data=response.json(),
724
+ )
725
+
726
+ async def get_quota(self) -> Quota:
727
+ """Retrieve quota information.
728
+
729
+ Returns:
730
+ The quota details.
731
+ """
732
+ response = await self._request(
733
+ method="GET",
734
+ url="/api/quota",
735
+ )
736
+ return Quota.from_dict(data=response.json())
737
+
738
+
739
+ @beartype
740
+ class AsyncCoderPad:
741
+ """An async client for the CoderPad Interview API."""
742
+
743
+ def __init__(
744
+ self,
745
+ *,
746
+ api_key: str,
747
+ base_url: str = ("https://app.coderpad.io"),
748
+ transport: AsyncTransport | None = None,
749
+ ) -> None:
750
+ """Create a new async CoderPad client.
751
+
752
+ Args:
753
+ api_key: The API key for authentication.
754
+ base_url: The base URL for the API.
755
+ transport: The async HTTP transport. Defaults
756
+ to ``AsyncHTTPXTransport()``.
757
+ """
758
+ self.base_url = base_url
759
+ resolved_transport = transport or AsyncHTTPXTransport()
760
+ headers = {"Authorization": f'Token token="{api_key}"'}
761
+ self.pads: AsyncPadsNamespace = AsyncPadsNamespace(
762
+ transport=resolved_transport,
763
+ base_url=base_url,
764
+ headers=headers,
765
+ )
766
+ self.questions: AsyncQuestionsNamespace = AsyncQuestionsNamespace(
767
+ transport=resolved_transport,
768
+ base_url=base_url,
769
+ headers=headers,
770
+ )
771
+ if isinstance(
772
+ resolved_transport,
773
+ AsyncHTTPXTransport,
774
+ ):
775
+ self._aclose = resolved_transport.aclose
776
+ else:
777
+
778
+ async def _noop() -> None:
779
+ """No-op close for transports without aclose."""
780
+
781
+ self._aclose = _noop
782
+ self.organization: AsyncOrganizationNamespace = (
783
+ AsyncOrganizationNamespace(
784
+ transport=resolved_transport,
785
+ base_url=base_url,
786
+ headers=headers,
787
+ )
788
+ )
789
+
790
+ async def aclose(self) -> None:
791
+ """Close the underlying transport."""
792
+ await self._aclose()
793
+
794
+ async def __aenter__(self) -> Self:
795
+ """Enter the async context manager.
796
+
797
+ Returns:
798
+ This client instance.
799
+ """
800
+ return self
801
+
802
+ async def __aexit__(
803
+ self,
804
+ _exc_type: type[BaseException] | None,
805
+ _exc_val: BaseException | None,
806
+ _exc_tb: object,
807
+ /,
808
+ ) -> None:
809
+ """Exit the async context manager and close."""
810
+ await self.aclose()