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/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()