hackerrank 2026.7.19__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.
hackerrank/client.py ADDED
@@ -0,0 +1,2623 @@
1
+ """HackerRank for Work API client."""
2
+
3
+ import builtins
4
+ from collections.abc import Mapping, Sequence
5
+ from http import HTTPStatus
6
+ from types import TracebackType
7
+ from typing import Any, BinaryIO, Self
8
+
9
+ from beartype import beartype
10
+
11
+ from hackerrank.exceptions import HackerRankError
12
+ from hackerrank.transports import (
13
+ HTTPXTransport,
14
+ Transport,
15
+ TransportResponse,
16
+ )
17
+ from hackerrank.types import (
18
+ ATSCodePair,
19
+ ATSCodeScreen,
20
+ AuditLog,
21
+ Environment,
22
+ Interview,
23
+ InterviewTemplate,
24
+ InterviewTranscript,
25
+ Inviter,
26
+ JSONValue,
27
+ Page,
28
+ Question,
29
+ SCIMPage,
30
+ SCIMTeam,
31
+ SCIMUser,
32
+ Team,
33
+ Template,
34
+ Test,
35
+ TestCandidate,
36
+ User,
37
+ UserTeamMembership,
38
+ )
39
+
40
+ _API_V3 = "/x/api/v3"
41
+ _SCIM_BASE_URL = "https://services.hackerrank.com/scim/v2"
42
+
43
+
44
+ def _drop_none(
45
+ data: Mapping[str, JSONValue],
46
+ /,
47
+ ) -> dict[str, JSONValue]:
48
+ """Return a copy of ``data`` with ``None`` values removed.
49
+
50
+ Args:
51
+ data: The dictionary to filter.
52
+
53
+ Returns:
54
+ A new dictionary without ``None`` values.
55
+ """
56
+ return {k: v for k, v in data.items() if v is not None}
57
+
58
+
59
+ @beartype
60
+ class _Namespace:
61
+ """Base class providing shared request logic."""
62
+
63
+ def __init__(
64
+ self,
65
+ *,
66
+ transport: Transport,
67
+ base_url: str,
68
+ headers: dict[str, str],
69
+ ) -> None:
70
+ """Create a new namespace.
71
+
72
+ Args:
73
+ transport: The HTTP transport.
74
+ base_url: The base URL for the API.
75
+ headers: Headers to send with every request.
76
+ """
77
+ self.transport = transport
78
+ self.base_url = base_url
79
+ self.headers = headers
80
+
81
+ def _request(
82
+ self,
83
+ *,
84
+ method: str,
85
+ url: str,
86
+ params: dict[str, str | int] | None = None,
87
+ json: Mapping[str, JSONValue] | None = None,
88
+ files: Mapping[str, Any] | None = None,
89
+ ) -> TransportResponse:
90
+ """Make an HTTP request.
91
+
92
+ Args:
93
+ method: The HTTP method.
94
+ url: The URL path.
95
+ params: Query parameters.
96
+ json: JSON-serialisable body.
97
+ files: Files to send as multipart form-data.
98
+
99
+ Returns:
100
+ The transport response.
101
+
102
+ Raises:
103
+ HackerRankError: If the response has an error
104
+ status code.
105
+ """
106
+ response = self.transport(
107
+ method=method,
108
+ url=self.base_url + url,
109
+ headers=self.headers,
110
+ params=params,
111
+ json=json,
112
+ files=files,
113
+ )
114
+ if response.status_code >= HTTPStatus.BAD_REQUEST:
115
+ raise HackerRankError.from_response(response=response)
116
+ return response
117
+
118
+
119
+ def _coerce_int(value: object, /) -> int:
120
+ """Coerce ``value`` to an ``int``, defaulting to ``0``.
121
+
122
+ Args:
123
+ value: A value that may be ``int``, ``str`` or
124
+ ``None``.
125
+
126
+ Returns:
127
+ The integer value, or ``0`` if conversion is not
128
+ possible.
129
+ """
130
+ if isinstance(value, bool):
131
+ return int(value)
132
+ if isinstance(value, int):
133
+ return value
134
+ if isinstance(value, str) and value:
135
+ try:
136
+ return int(value)
137
+ except ValueError:
138
+ return 0
139
+ return 0
140
+
141
+
142
+ def _coerce_str(value: object, /) -> str:
143
+ """Coerce ``value`` to a ``str``, defaulting to ``""``.
144
+
145
+ Args:
146
+ value: A value that may be a ``str`` or ``None``.
147
+
148
+ Returns:
149
+ The string value, or ``""`` if ``None``.
150
+ """
151
+ if isinstance(value, str):
152
+ return value
153
+ return ""
154
+
155
+
156
+ def _make_page[T](
157
+ items: list[T],
158
+ metadata: Mapping[str, JSONValue],
159
+ /,
160
+ ) -> Page[T]:
161
+ """Wrap ``items`` and ``metadata`` into a ``Page``.
162
+
163
+ Args:
164
+ items: The items in the page.
165
+ metadata: The raw response metadata.
166
+
167
+ Returns:
168
+ A populated ``Page`` instance.
169
+ """
170
+ return Page(
171
+ items,
172
+ page_total=_coerce_int(metadata.get("page_total")),
173
+ offset=_coerce_int(metadata.get("offset")),
174
+ previous=_coerce_str(metadata.get("previous")),
175
+ next_=_coerce_str(metadata.get("next")),
176
+ first=_coerce_str(metadata.get("first")),
177
+ last=_coerce_str(metadata.get("last")),
178
+ total=_coerce_int(metadata.get("total")),
179
+ )
180
+
181
+
182
+ def _list_params(
183
+ *,
184
+ limit: int | None,
185
+ offset: int | None,
186
+ extra: dict[str, str | int] | None = None,
187
+ ) -> dict[str, str | int]:
188
+ """Build query parameters for a list call.
189
+
190
+ Args:
191
+ limit: Maximum number of items to return.
192
+ offset: Offset to start from.
193
+ extra: Any extra parameters to include.
194
+
195
+ Returns:
196
+ The query parameter mapping.
197
+ """
198
+ params: dict[str, str | int] = {}
199
+ if limit is not None:
200
+ params["limit"] = limit
201
+ if offset is not None:
202
+ params["offset"] = offset
203
+ if extra:
204
+ params.update(extra)
205
+ return params
206
+
207
+
208
+ def _question_body(
209
+ *,
210
+ name: str | None = None,
211
+ type: str | None = None, # noqa: A002 # pylint: disable=redefined-builtin
212
+ internal_notes: str | None = None,
213
+ languages: Sequence[str] | None = None,
214
+ problem_statement: str | None = None,
215
+ recommended_duration: int | None = None,
216
+ tags: Sequence[str] | None = None,
217
+ options: Sequence[str] | None = None,
218
+ answer: int | Sequence[int] | None = None,
219
+ score: float | None = None,
220
+ environment_id: int | None = None,
221
+ role_type: str | None = None,
222
+ scoring_command: str | None = None,
223
+ scoring_files: Sequence[str] | None = None,
224
+ readonly_paths: Sequence[str] | None = None,
225
+ default_files: Sequence[str] | None = None,
226
+ configuration: Mapping[str, JSONValue] | None = None,
227
+ testcases: Sequence[Mapping[str, JSONValue]] | None = None,
228
+ ) -> dict[str, JSONValue]:
229
+ """Build a JSON body for question create/update calls."""
230
+ return _drop_none(
231
+ {
232
+ "name": name,
233
+ "type": type,
234
+ "internal_notes": internal_notes,
235
+ "languages": (list(languages) if languages is not None else None),
236
+ "problem_statement": problem_statement,
237
+ "recommended_duration": recommended_duration,
238
+ "tags": list(tags) if tags is not None else None,
239
+ "options": (list(options) if options is not None else None),
240
+ "answer": (
241
+ list(answer)
242
+ if isinstance(answer, Sequence) and not isinstance(answer, str)
243
+ else answer
244
+ ),
245
+ "score": score,
246
+ "environment_id": environment_id,
247
+ "role_type": role_type,
248
+ "scoring_command": scoring_command,
249
+ "scoring_files": (
250
+ list(scoring_files) if scoring_files is not None else None
251
+ ),
252
+ "readonly_paths": (
253
+ list(readonly_paths) if readonly_paths is not None else None
254
+ ),
255
+ "default_files": (
256
+ list(default_files) if default_files is not None else None
257
+ ),
258
+ "configuration": configuration,
259
+ "testcases": (list(testcases) if testcases is not None else None),
260
+ },
261
+ )
262
+
263
+
264
+ @beartype
265
+ class InterviewsNamespace(_Namespace):
266
+ """Namespace for interview operations."""
267
+
268
+ def list(
269
+ self,
270
+ *,
271
+ limit: int | None = None,
272
+ offset: int | None = None,
273
+ created_at: str | None = None,
274
+ updated_at: str | None = None,
275
+ ended_at: str | None = None,
276
+ ) -> Page[Interview]:
277
+ """List interviews.
278
+
279
+ Args:
280
+ limit: Number of records to fetch.
281
+ offset: Offset of records.
282
+ created_at: ``from..to`` filter for creation time.
283
+ updated_at: ``from..to`` filter for update time.
284
+ ended_at: ``from..to`` filter for ending time.
285
+
286
+ Returns:
287
+ A page of interviews.
288
+ """
289
+ extra: dict[str, str | int] = {}
290
+ if created_at is not None:
291
+ extra["created_at"] = created_at
292
+ if updated_at is not None:
293
+ extra["updated_at"] = updated_at
294
+ if ended_at is not None:
295
+ extra["ended_at"] = ended_at
296
+ response = self._request(
297
+ method="GET",
298
+ url=f"{_API_V3}/interviews",
299
+ params=_list_params(
300
+ limit=limit,
301
+ offset=offset,
302
+ extra=extra,
303
+ ),
304
+ )
305
+ payload = response.json()
306
+ raw_items = list(payload.get("data", []))
307
+ items: list[Interview] = [
308
+ Interview.from_dict(data=item) for item in raw_items
309
+ ]
310
+ return _make_page(items, payload)
311
+
312
+ def create(
313
+ self,
314
+ *,
315
+ title: str | None = None,
316
+ from_: str | None = None,
317
+ to: str | None = None,
318
+ notes: str | None = None,
319
+ resume_url: str | None = None,
320
+ interviewers: Sequence[str] | None = None,
321
+ result_url: str | None = None,
322
+ candidate: Mapping[str, JSONValue] | None = None,
323
+ send_email: bool | None = None,
324
+ metadata: Mapping[str, JSONValue] | None = None,
325
+ interview_template_id: int | None = None,
326
+ ) -> Interview:
327
+ """Create an interview.
328
+
329
+ Args:
330
+ title: Title of the interview.
331
+ from_: Scheduled start time.
332
+ to: Scheduled end time.
333
+ notes: Private notes.
334
+ resume_url: URL to the candidate resume.
335
+ interviewers: Emails of interviewers.
336
+ result_url: URL invoked when the interview ends.
337
+ candidate: Candidate details.
338
+ send_email: Whether to send an email invite.
339
+ metadata: Arbitrary metadata.
340
+ interview_template_id: Template to apply.
341
+
342
+ Returns:
343
+ The created interview.
344
+ """
345
+ body = _drop_none(
346
+ {
347
+ "title": title,
348
+ "from": from_,
349
+ "to": to,
350
+ "notes": notes,
351
+ "resume_url": resume_url,
352
+ "interviewers": (
353
+ list(interviewers) if interviewers is not None else None
354
+ ),
355
+ "result_url": result_url,
356
+ "candidate": candidate,
357
+ "send_email": send_email,
358
+ "metadata": metadata,
359
+ "interview_template_id": interview_template_id,
360
+ },
361
+ )
362
+ response = self._request(
363
+ method="POST",
364
+ url=f"{_API_V3}/interviews",
365
+ json=body,
366
+ )
367
+ return Interview.from_dict(data=response.json())
368
+
369
+ def get(self, *, interview_id: str) -> Interview:
370
+ """Retrieve an interview.
371
+
372
+ Args:
373
+ interview_id: The id of the interview.
374
+
375
+ Returns:
376
+ The interview.
377
+ """
378
+ response = self._request(
379
+ method="GET",
380
+ url=f"{_API_V3}/interviews/{interview_id}",
381
+ )
382
+ return Interview.from_dict(data=response.json())
383
+
384
+ def update(
385
+ self,
386
+ *,
387
+ interview_id: str,
388
+ title: str | None = None,
389
+ from_: str | None = None,
390
+ to: str | None = None,
391
+ notes: str | None = None,
392
+ resume_url: str | None = None,
393
+ result_url: str | None = None,
394
+ candidate: Mapping[str, JSONValue] | None = None,
395
+ send_email: bool | None = None,
396
+ metadata: Mapping[str, JSONValue] | None = None,
397
+ interview_template_id: int | None = None,
398
+ ) -> None:
399
+ """Update an interview.
400
+
401
+ Args:
402
+ interview_id: The id of the interview.
403
+ title: New title.
404
+ from_: New start time.
405
+ to: New end time.
406
+ notes: New private notes.
407
+ resume_url: New resume URL.
408
+ result_url: New result URL.
409
+ candidate: New candidate details.
410
+ send_email: Whether to send an email.
411
+ metadata: New metadata.
412
+ interview_template_id: New template id.
413
+ """
414
+ body = _drop_none(
415
+ {
416
+ "title": title,
417
+ "from": from_,
418
+ "to": to,
419
+ "notes": notes,
420
+ "resume_url": resume_url,
421
+ "result_url": result_url,
422
+ "candidate": candidate,
423
+ "send_email": send_email,
424
+ "metadata": metadata,
425
+ "interview_template_id": interview_template_id,
426
+ },
427
+ )
428
+ self._request(
429
+ method="PUT",
430
+ url=f"{_API_V3}/interviews/{interview_id}",
431
+ json=body,
432
+ )
433
+
434
+ def delete(self, *, interview_id: str) -> None:
435
+ """Delete an interview.
436
+
437
+ Args:
438
+ interview_id: The id of the interview.
439
+ """
440
+ self._request(
441
+ method="DELETE",
442
+ url=f"{_API_V3}/interviews/{interview_id}",
443
+ )
444
+
445
+ def get_transcript(
446
+ self,
447
+ *,
448
+ interview_id: str,
449
+ ) -> InterviewTranscript:
450
+ """Retrieve the transcript of an interview.
451
+
452
+ Args:
453
+ interview_id: The id of the interview.
454
+
455
+ Returns:
456
+ The transcript.
457
+ """
458
+ response = self._request(
459
+ method="GET",
460
+ url=(f"{_API_V3}/interviews/{interview_id}/transcript"),
461
+ )
462
+ return InterviewTranscript.from_dict(data=response.json())
463
+
464
+
465
+ @beartype
466
+ class InterviewTemplatesNamespace(_Namespace):
467
+ """Namespace for interview-template operations."""
468
+
469
+ def list(
470
+ self,
471
+ *,
472
+ limit: int | None = None,
473
+ offset: int | None = None,
474
+ ) -> Page[InterviewTemplate]:
475
+ """List interview templates.
476
+
477
+ Args:
478
+ limit: Number of records to fetch.
479
+ offset: Offset of records.
480
+
481
+ Returns:
482
+ A page of interview templates.
483
+ """
484
+ response = self._request(
485
+ method="GET",
486
+ url=f"{_API_V3}/interview_templates",
487
+ params=_list_params(limit=limit, offset=offset),
488
+ )
489
+ payload = response.json()
490
+ raw_items = list(payload.get("data", []))
491
+ items: list[InterviewTemplate] = [
492
+ InterviewTemplate.from_dict(data=item) for item in raw_items
493
+ ]
494
+ return _make_page(items, payload)
495
+
496
+ def create(
497
+ self,
498
+ *,
499
+ name: str,
500
+ roles: Sequence[str] | None = None,
501
+ team_share: int | None = None,
502
+ questions: Sequence[str] | None = None,
503
+ scorecard: int | None = None,
504
+ ) -> InterviewTemplate:
505
+ """Create an interview template.
506
+
507
+ Args:
508
+ name: The template name.
509
+ roles: Roles for the template.
510
+ team_share: Team-share flag.
511
+ questions: Associated question ids.
512
+ scorecard: Scorecard id.
513
+
514
+ Returns:
515
+ The created interview template.
516
+ """
517
+ body = _drop_none(
518
+ {
519
+ "name": name,
520
+ "roles": list(roles) if roles is not None else None,
521
+ "team_share": team_share,
522
+ "questions": (
523
+ list(questions) if questions is not None else None
524
+ ),
525
+ "scorecard": scorecard,
526
+ },
527
+ )
528
+ response = self._request(
529
+ method="POST",
530
+ url=f"{_API_V3}/interview_templates",
531
+ json=body,
532
+ )
533
+ return InterviewTemplate.from_dict(data=response.json())
534
+
535
+ def get(self, *, template_id: int | str) -> InterviewTemplate:
536
+ """Retrieve an interview template.
537
+
538
+ Args:
539
+ template_id: The id of the template.
540
+
541
+ Returns:
542
+ The template.
543
+ """
544
+ response = self._request(
545
+ method="GET",
546
+ url=(f"{_API_V3}/interview_templates/{template_id}"),
547
+ )
548
+ return InterviewTemplate.from_dict(data=response.json())
549
+
550
+ def update(
551
+ self,
552
+ *,
553
+ template_id: int | str,
554
+ name: str | None = None,
555
+ roles: Sequence[str] | None = None,
556
+ team_share: int | None = None,
557
+ questions: Sequence[str] | None = None,
558
+ scorecard: int | None = None,
559
+ ) -> None:
560
+ """Update an interview template.
561
+
562
+ Args:
563
+ template_id: The id of the template.
564
+ name: New name.
565
+ roles: New roles.
566
+ team_share: New team-share flag.
567
+ questions: New question ids.
568
+ scorecard: New scorecard id.
569
+ """
570
+ body = _drop_none(
571
+ {
572
+ "name": name,
573
+ "roles": list(roles) if roles is not None else None,
574
+ "team_share": team_share,
575
+ "questions": (
576
+ list(questions) if questions is not None else None
577
+ ),
578
+ "scorecard": scorecard,
579
+ },
580
+ )
581
+ self._request(
582
+ method="PUT",
583
+ url=(f"{_API_V3}/interview_templates/{template_id}"),
584
+ json=body,
585
+ )
586
+
587
+ def delete(self, *, template_id: int | str) -> None:
588
+ """Delete an interview template.
589
+
590
+ Args:
591
+ template_id: The id of the template.
592
+ """
593
+ self._request(
594
+ method="DELETE",
595
+ url=(f"{_API_V3}/interview_templates/{template_id}"),
596
+ )
597
+
598
+
599
+ @beartype
600
+ class EnvironmentsNamespace(_Namespace):
601
+ """Namespace for project-question environment operations."""
602
+
603
+ def list(self) -> builtins.list[Environment]:
604
+ """List project-question environments.
605
+
606
+ Returns:
607
+ The available environments.
608
+ """
609
+ response = self._request(
610
+ method="GET",
611
+ url=f"{_API_V3}/environments",
612
+ )
613
+ raw_items = list(response.json().get("environments", []))
614
+ return [Environment.from_dict(data=item) for item in raw_items]
615
+
616
+ def get(self, *, environment_id: int) -> Environment:
617
+ """Retrieve a project-question environment.
618
+
619
+ Args:
620
+ environment_id: The id of the environment.
621
+
622
+ Returns:
623
+ The environment.
624
+ """
625
+ response = self._request(
626
+ method="GET",
627
+ url=f"{_API_V3}/environments/{environment_id}",
628
+ )
629
+ return Environment.from_dict(data=response.json()["environment"])
630
+
631
+
632
+ @beartype
633
+ class QuestionsNamespace(_Namespace):
634
+ """Namespace for question operations."""
635
+
636
+ def list(
637
+ self,
638
+ *,
639
+ limit: int | None = None,
640
+ offset: int | None = None,
641
+ ) -> Page[Question]:
642
+ """List questions.
643
+
644
+ Args:
645
+ limit: Number of records to fetch.
646
+ offset: Offset of records.
647
+
648
+ Returns:
649
+ A page of questions.
650
+ """
651
+ response = self._request(
652
+ method="GET",
653
+ url=f"{_API_V3}/questions",
654
+ params=_list_params(limit=limit, offset=offset),
655
+ )
656
+ payload = response.json()
657
+ raw_items = list(payload.get("data", []))
658
+ items: list[Question] = [
659
+ Question.from_dict(data=item) for item in raw_items
660
+ ]
661
+ return _make_page(items, payload)
662
+
663
+ def create(
664
+ self,
665
+ *,
666
+ name: str,
667
+ type: str, # noqa: A002 # pylint: disable=redefined-builtin
668
+ internal_notes: str | None = None,
669
+ languages: Sequence[str] | None = None,
670
+ problem_statement: str | None = None,
671
+ recommended_duration: int | None = None,
672
+ tags: Sequence[str] | None = None,
673
+ options: Sequence[str] | None = None,
674
+ answer: int | Sequence[int] | None = None,
675
+ score: float | None = None,
676
+ environment_id: int | None = None,
677
+ role_type: str | None = None,
678
+ scoring_command: str | None = None,
679
+ scoring_files: Sequence[str] | None = None,
680
+ readonly_paths: Sequence[str] | None = None,
681
+ default_files: Sequence[str] | None = None,
682
+ configuration: Mapping[str, JSONValue] | None = None,
683
+ testcases: Sequence[Mapping[str, JSONValue]] | None = None,
684
+ ) -> Question:
685
+ """Create a question.
686
+
687
+ Args:
688
+ name: Question name.
689
+ type: Question type (``code``, ``mcq``, ...).
690
+ internal_notes: Private notes.
691
+ languages: Supported languages.
692
+ problem_statement: Problem statement.
693
+ recommended_duration: Recommended duration.
694
+ tags: Tags.
695
+ options: MCQ options.
696
+ answer: Correct MCQ answer.
697
+ score: Maximum score for project questions.
698
+ environment_id: Environment id for a fullstack question.
699
+ role_type: Role type for a fullstack question.
700
+ scoring_command: Command used to score the submission.
701
+ scoring_files: Files that hold scoring tests.
702
+ readonly_paths: Paths the candidate cannot edit.
703
+ default_files: File paths opened by default.
704
+ configuration: Project configuration.
705
+ testcases: Project scoring test cases.
706
+
707
+ Returns:
708
+ The created question.
709
+ """
710
+ body = _question_body(
711
+ name=name,
712
+ type=type,
713
+ internal_notes=internal_notes,
714
+ languages=languages,
715
+ problem_statement=problem_statement,
716
+ recommended_duration=recommended_duration,
717
+ tags=tags,
718
+ options=options,
719
+ answer=answer,
720
+ score=score,
721
+ environment_id=environment_id,
722
+ role_type=role_type,
723
+ scoring_command=scoring_command,
724
+ scoring_files=scoring_files,
725
+ readonly_paths=readonly_paths,
726
+ default_files=default_files,
727
+ configuration=configuration,
728
+ testcases=testcases,
729
+ )
730
+ response = self._request(
731
+ method="POST",
732
+ url=f"{_API_V3}/questions",
733
+ json=body,
734
+ )
735
+ return Question.from_dict(data=response.json())
736
+
737
+ def get(self, *, question_id: str) -> Question:
738
+ """Retrieve a question.
739
+
740
+ Args:
741
+ question_id: The id of the question.
742
+
743
+ Returns:
744
+ The question.
745
+ """
746
+ response = self._request(
747
+ method="GET",
748
+ url=f"{_API_V3}/questions/{question_id}",
749
+ )
750
+ return Question.from_dict(data=response.json())
751
+
752
+ def update(
753
+ self,
754
+ *,
755
+ question_id: str,
756
+ name: str | None = None,
757
+ # pylint: disable-next=redefined-builtin
758
+ type: str | None = None, # noqa: A002
759
+ internal_notes: str | None = None,
760
+ languages: Sequence[str] | None = None,
761
+ problem_statement: str | None = None,
762
+ recommended_duration: int | None = None,
763
+ tags: Sequence[str] | None = None,
764
+ options: Sequence[str] | None = None,
765
+ answer: int | Sequence[int] | None = None,
766
+ score: float | None = None,
767
+ environment_id: int | None = None,
768
+ role_type: str | None = None,
769
+ scoring_command: str | None = None,
770
+ scoring_files: Sequence[str] | None = None,
771
+ readonly_paths: Sequence[str] | None = None,
772
+ default_files: Sequence[str] | None = None,
773
+ configuration: Mapping[str, JSONValue] | None = None,
774
+ testcases: Sequence[Mapping[str, JSONValue]] | None = None,
775
+ ) -> Question | None:
776
+ """Update a question.
777
+
778
+ Args:
779
+ question_id: The id of the question.
780
+ name: New name.
781
+ type: New question type.
782
+ internal_notes: New internal notes.
783
+ languages: New supported languages.
784
+ problem_statement: New problem statement.
785
+ recommended_duration: New recommended duration.
786
+ tags: New tags.
787
+ options: New MCQ options.
788
+ answer: New MCQ answer.
789
+ score: New maximum score.
790
+ environment_id: New environment id.
791
+ role_type: New role type.
792
+ scoring_command: New scoring command.
793
+ scoring_files: New scoring files.
794
+ readonly_paths: New read-only paths.
795
+ default_files: New default file paths.
796
+ configuration: Project configuration to merge.
797
+ testcases: New project scoring test cases.
798
+
799
+ Returns:
800
+ The updated question when returned by the API, otherwise
801
+ ``None``.
802
+ """
803
+ body = _question_body(
804
+ name=name,
805
+ type=type,
806
+ internal_notes=internal_notes,
807
+ languages=languages,
808
+ problem_statement=problem_statement,
809
+ recommended_duration=recommended_duration,
810
+ tags=tags,
811
+ options=options,
812
+ answer=answer,
813
+ score=score,
814
+ environment_id=environment_id,
815
+ role_type=role_type,
816
+ scoring_command=scoring_command,
817
+ scoring_files=scoring_files,
818
+ readonly_paths=readonly_paths,
819
+ default_files=default_files,
820
+ configuration=configuration,
821
+ testcases=testcases,
822
+ )
823
+ response = self._request(
824
+ method="PUT",
825
+ url=f"{_API_V3}/questions/{question_id}",
826
+ json=body,
827
+ )
828
+ if response.content:
829
+ return Question.from_dict(data=response.json())
830
+ return None
831
+
832
+ def upload_project_zip(
833
+ self,
834
+ *,
835
+ question_id: str,
836
+ file: bytes | BinaryIO,
837
+ filename: str = "project.zip",
838
+ content_type: str = "application/zip",
839
+ ) -> dict[str, JSONValue]:
840
+ """Upload a project zip for a fullstack question.
841
+
842
+ Args:
843
+ question_id: The id of the question.
844
+ file: The zip file content or a binary file object.
845
+ filename: The multipart filename.
846
+ content_type: The multipart content type.
847
+
848
+ Returns:
849
+ The raw API response, including ``file_url`` and
850
+ ``file_path``.
851
+ """
852
+ response = self._request(
853
+ method="POST",
854
+ url=f"{_API_V3}/questions/{question_id}/upload_project_zip",
855
+ files={"file": (filename, file, content_type)},
856
+ )
857
+ result: dict[str, JSONValue] = dict(response.json())
858
+ return result
859
+
860
+ def update_codestubs(
861
+ self,
862
+ *,
863
+ question_id: str,
864
+ codestubs: Mapping[str, JSONValue],
865
+ ) -> None:
866
+ """Update custom code-stubs for a question.
867
+
868
+ Args:
869
+ question_id: The id of the question.
870
+ codestubs: A mapping describing the code-stubs.
871
+ """
872
+ self._request(
873
+ method="PUT",
874
+ url=(f"{_API_V3}/questions/{question_id}/custom_codestubs"),
875
+ json=codestubs,
876
+ )
877
+
878
+ def generate_codestubs(
879
+ self,
880
+ *,
881
+ question_id: str,
882
+ body: Mapping[str, JSONValue] | None = None,
883
+ ) -> dict[str, JSONValue]:
884
+ """Generate code-stubs for a question.
885
+
886
+ Args:
887
+ question_id: The id of the question.
888
+ body: An optional request body.
889
+
890
+ Returns:
891
+ The raw API response.
892
+ """
893
+ response = self._request(
894
+ method="PUT",
895
+ url=(f"{_API_V3}/questions/{question_id}/generate"),
896
+ json=body if body is not None else {},
897
+ )
898
+ result: dict[str, JSONValue] = dict(response.json())
899
+ return result
900
+
901
+ def add_testcase(
902
+ self,
903
+ *,
904
+ question_id: str,
905
+ body: Mapping[str, JSONValue],
906
+ ) -> dict[str, JSONValue]:
907
+ """Add a test case to a question.
908
+
909
+ Args:
910
+ question_id: The id of the question.
911
+ body: The test-case payload.
912
+
913
+ Returns:
914
+ The raw API response.
915
+ """
916
+ response = self._request(
917
+ method="POST",
918
+ url=(f"{_API_V3}/questions/{question_id}/testcases"),
919
+ json=body,
920
+ )
921
+ result: dict[str, JSONValue] = dict(response.json())
922
+ return result
923
+
924
+ def update_testcase(
925
+ self,
926
+ *,
927
+ question_id: str,
928
+ testcase_id: str,
929
+ body: Mapping[str, JSONValue],
930
+ ) -> None:
931
+ """Update an existing test case.
932
+
933
+ Args:
934
+ question_id: The id of the question.
935
+ testcase_id: The id of the test case.
936
+ body: The test-case payload.
937
+ """
938
+ self._request(
939
+ method="PUT",
940
+ url=(f"{_API_V3}/questions/{question_id}/testcases/{testcase_id}"),
941
+ json=body,
942
+ )
943
+
944
+ def delete_testcase(
945
+ self,
946
+ *,
947
+ question_id: str,
948
+ testcase_id: str,
949
+ ) -> None:
950
+ """Delete a single test case.
951
+
952
+ Args:
953
+ question_id: The id of the question.
954
+ testcase_id: The id of the test case.
955
+ """
956
+ self._request(
957
+ method="DELETE",
958
+ url=(f"{_API_V3}/questions/{question_id}/testcases/{testcase_id}"),
959
+ )
960
+
961
+ def delete_all_testcases(self, *, question_id: str) -> None:
962
+ """Delete every test case on a question.
963
+
964
+ Args:
965
+ question_id: The id of the question.
966
+ """
967
+ self._request(
968
+ method="DELETE",
969
+ url=(f"{_API_V3}/questions/{question_id}/testcases/delete_all"),
970
+ )
971
+
972
+
973
+ @beartype
974
+ class TestCandidatesNamespace(_Namespace):
975
+ """Namespace for the candidates of a test."""
976
+
977
+ def list(
978
+ self,
979
+ *,
980
+ test_id: str,
981
+ limit: int | None = None,
982
+ offset: int | None = None,
983
+ ) -> Page[TestCandidate]:
984
+ """List candidates for a test.
985
+
986
+ Args:
987
+ test_id: The id of the test.
988
+ limit: Number of records to fetch.
989
+ offset: Offset of records.
990
+
991
+ Returns:
992
+ A page of candidates.
993
+ """
994
+ response = self._request(
995
+ method="GET",
996
+ url=f"{_API_V3}/tests/{test_id}/candidates",
997
+ params=_list_params(limit=limit, offset=offset),
998
+ )
999
+ payload = response.json()
1000
+ raw_items = list(payload.get("data", []))
1001
+ items: list[TestCandidate] = [
1002
+ TestCandidate.from_dict(data=item) for item in raw_items
1003
+ ]
1004
+ return _make_page(items, payload)
1005
+
1006
+ def search(
1007
+ self,
1008
+ *,
1009
+ test_id: str,
1010
+ search: str,
1011
+ limit: int | None = None,
1012
+ offset: int | None = None,
1013
+ ) -> Page[TestCandidate]:
1014
+ """Search candidates within a test.
1015
+
1016
+ Args:
1017
+ test_id: The id of the test.
1018
+ search: Search query.
1019
+ limit: Number of records to fetch.
1020
+ offset: Offset of records.
1021
+
1022
+ Returns:
1023
+ A page of candidates.
1024
+ """
1025
+ params = _list_params(
1026
+ limit=limit,
1027
+ offset=offset,
1028
+ extra={"search": search},
1029
+ )
1030
+ response = self._request(
1031
+ method="GET",
1032
+ url=f"{_API_V3}/tests/{test_id}/candidates/search",
1033
+ params=params,
1034
+ )
1035
+ payload = response.json()
1036
+ raw_items = list(payload.get("data", []))
1037
+ items: list[TestCandidate] = [
1038
+ TestCandidate.from_dict(data=item) for item in raw_items
1039
+ ]
1040
+ return _make_page(items, payload)
1041
+
1042
+ def invite(
1043
+ self,
1044
+ *,
1045
+ test_id: str,
1046
+ email: str,
1047
+ full_name: str | None = None,
1048
+ send_email: bool | None = None,
1049
+ evaluator_email: str | None = None,
1050
+ test_result_url: str | None = None,
1051
+ test_finish_url: str | None = None,
1052
+ tags: Sequence[str] | None = None,
1053
+ invite_valid_from: str | None = None,
1054
+ invite_valid_to: str | None = None,
1055
+ force: bool | None = None,
1056
+ force_reattempt: bool | None = None,
1057
+ accommodations: Mapping[str, JSONValue] | None = None,
1058
+ invite_metadata: Mapping[str, JSONValue] | None = None,
1059
+ webhook_authentication: Mapping[str, JSONValue] | None = None,
1060
+ accept_result_updates: bool | None = None,
1061
+ subject: str | None = None,
1062
+ message: str | None = None,
1063
+ template: str | None = None,
1064
+ ) -> TestCandidate:
1065
+ """Invite a candidate to a test.
1066
+
1067
+ Args:
1068
+ test_id: The id of the test.
1069
+ email: Candidate email address.
1070
+ full_name: Candidate full name.
1071
+ send_email: Whether to send the invitation email.
1072
+ evaluator_email: Evaluator email.
1073
+ test_result_url: URL to which results are posted.
1074
+ test_finish_url: URL the candidate sees on finish.
1075
+ tags: Tags to set on the candidate.
1076
+ invite_valid_from: Invitation start time.
1077
+ invite_valid_to: Invitation end time.
1078
+ force: Force inviting even if previously invited.
1079
+ force_reattempt: Allow re-attempt.
1080
+ accommodations: Accommodation settings.
1081
+ invite_metadata: Arbitrary metadata.
1082
+ webhook_authentication: Webhook auth config.
1083
+ accept_result_updates: Accept result updates flag.
1084
+ subject: Custom email subject.
1085
+ message: Custom email message.
1086
+ template: Email template id.
1087
+
1088
+ Returns:
1089
+ The created candidate.
1090
+ """
1091
+ body = _drop_none(
1092
+ {
1093
+ "email": email,
1094
+ "full_name": full_name,
1095
+ "send_email": send_email,
1096
+ "evaluator_email": evaluator_email,
1097
+ "test_result_url": test_result_url,
1098
+ "test_finish_url": test_finish_url,
1099
+ "tags": list(tags) if tags is not None else None,
1100
+ "invite_valid_from": invite_valid_from,
1101
+ "invite_valid_to": invite_valid_to,
1102
+ "force": force,
1103
+ "force_reattempt": force_reattempt,
1104
+ "accommodations": accommodations,
1105
+ "invite_metadata": invite_metadata,
1106
+ "webhook_authentication": webhook_authentication,
1107
+ "accept_result_updates": accept_result_updates,
1108
+ "subject": subject,
1109
+ "message": message,
1110
+ "template": template,
1111
+ },
1112
+ )
1113
+ response = self._request(
1114
+ method="POST",
1115
+ url=f"{_API_V3}/tests/{test_id}/candidates",
1116
+ json=body,
1117
+ )
1118
+ return TestCandidate.from_dict(data=response.json())
1119
+
1120
+ def get(
1121
+ self,
1122
+ *,
1123
+ test_id: str,
1124
+ candidate_id: str,
1125
+ additional_fields: str | None = None,
1126
+ ) -> TestCandidate:
1127
+ """Retrieve a single candidate.
1128
+
1129
+ Args:
1130
+ test_id: The id of the test.
1131
+ candidate_id: The id of the candidate.
1132
+ additional_fields: Comma-separated extra fields.
1133
+
1134
+ Returns:
1135
+ The candidate.
1136
+ """
1137
+ params: dict[str, str | int] = {}
1138
+ if additional_fields is not None:
1139
+ params["additional_fields"] = additional_fields
1140
+ response = self._request(
1141
+ method="GET",
1142
+ url=(f"{_API_V3}/tests/{test_id}/candidates/{candidate_id}"),
1143
+ params=params or None,
1144
+ )
1145
+ return TestCandidate.from_dict(data=response.json())
1146
+
1147
+ def update(
1148
+ self,
1149
+ *,
1150
+ test_id: str,
1151
+ candidate_id: str,
1152
+ full_name: str | None = None,
1153
+ ats_state: int | None = None,
1154
+ invite_valid_from: str | None = None,
1155
+ invite_valid_to: str | None = None,
1156
+ invite_metadata: Mapping[str, JSONValue] | None = None,
1157
+ evaluator_email: str | None = None,
1158
+ test_finish_url: str | None = None,
1159
+ test_result_url: str | None = None,
1160
+ webhook_authentication: Mapping[str, JSONValue] | None = None,
1161
+ accept_result_updates: bool | None = None,
1162
+ tags: Sequence[str] | None = None,
1163
+ accommodations: Mapping[str, JSONValue] | None = None,
1164
+ ) -> None:
1165
+ """Update a candidate.
1166
+
1167
+ Args:
1168
+ test_id: The id of the test.
1169
+ candidate_id: The id of the candidate.
1170
+ full_name: New full name.
1171
+ ats_state: New ATS state.
1172
+ invite_valid_from: New invitation start time.
1173
+ invite_valid_to: New invitation end time.
1174
+ invite_metadata: New invite metadata.
1175
+ evaluator_email: New evaluator email.
1176
+ test_finish_url: New finish URL.
1177
+ test_result_url: New result URL.
1178
+ webhook_authentication: New webhook auth.
1179
+ accept_result_updates: New flag value.
1180
+ tags: New tags.
1181
+ accommodations: New accommodations.
1182
+ """
1183
+ body = _drop_none(
1184
+ {
1185
+ "full_name": full_name,
1186
+ "ats_state": ats_state,
1187
+ "invite_valid_from": invite_valid_from,
1188
+ "invite_valid_to": invite_valid_to,
1189
+ "invite_metadata": invite_metadata,
1190
+ "evaluator_email": evaluator_email,
1191
+ "test_finish_url": test_finish_url,
1192
+ "test_result_url": test_result_url,
1193
+ "webhook_authentication": webhook_authentication,
1194
+ "accept_result_updates": accept_result_updates,
1195
+ "tags": list(tags) if tags is not None else None,
1196
+ "accommodations": accommodations,
1197
+ },
1198
+ )
1199
+ self._request(
1200
+ method="PUT",
1201
+ url=(f"{_API_V3}/tests/{test_id}/candidates/{candidate_id}"),
1202
+ json=body,
1203
+ )
1204
+
1205
+ def cancel_invite(
1206
+ self,
1207
+ *,
1208
+ test_id: str,
1209
+ candidate_id: str,
1210
+ ) -> None:
1211
+ """Cancel a candidate's invitation.
1212
+
1213
+ Args:
1214
+ test_id: The id of the test.
1215
+ candidate_id: The id of the candidate.
1216
+ """
1217
+ self._request(
1218
+ method="DELETE",
1219
+ url=(
1220
+ f"{_API_V3}/tests/{test_id}/candidates/{candidate_id}/invite"
1221
+ ),
1222
+ )
1223
+
1224
+ def delete_report(
1225
+ self,
1226
+ *,
1227
+ test_id: str,
1228
+ candidate_id: str,
1229
+ ) -> None:
1230
+ """Delete the report for a candidate.
1231
+
1232
+ Args:
1233
+ test_id: The id of the test.
1234
+ candidate_id: The id of the candidate.
1235
+ """
1236
+ self._request(
1237
+ method="DELETE",
1238
+ url=(
1239
+ f"{_API_V3}/tests/{test_id}/candidates/{candidate_id}/report"
1240
+ ),
1241
+ )
1242
+
1243
+ def get_report_pdf(
1244
+ self,
1245
+ *,
1246
+ test_id: str,
1247
+ candidate_id: str,
1248
+ format_: str = "url",
1249
+ ) -> dict[str, JSONValue]:
1250
+ """Retrieve the PDF report for a candidate.
1251
+
1252
+ Args:
1253
+ test_id: The id of the test.
1254
+ candidate_id: The id of the candidate.
1255
+ format_: Format of the PDF (e.g. ``"url"``).
1256
+
1257
+ Returns:
1258
+ The raw API response. When ``format_`` is
1259
+ ``"url"`` the response typically contains a URL
1260
+ for downloading the PDF.
1261
+ """
1262
+ response = self._request(
1263
+ method="GET",
1264
+ url=(f"{_API_V3}/tests/{test_id}/candidates/{candidate_id}/pdf"),
1265
+ params={"format": format_},
1266
+ )
1267
+ result: dict[str, JSONValue] = dict(response.json())
1268
+ return result
1269
+
1270
+
1271
+ @beartype
1272
+ class TestsNamespace(_Namespace):
1273
+ """Namespace for test operations."""
1274
+
1275
+ def __init__(
1276
+ self,
1277
+ *,
1278
+ transport: Transport,
1279
+ base_url: str,
1280
+ headers: dict[str, str],
1281
+ ) -> None:
1282
+ """Create the namespace.
1283
+
1284
+ Args:
1285
+ transport: The HTTP transport.
1286
+ base_url: The base URL for the API.
1287
+ headers: Headers to send with every request.
1288
+ """
1289
+ super().__init__(
1290
+ transport=transport,
1291
+ base_url=base_url,
1292
+ headers=headers,
1293
+ )
1294
+ self.candidates: TestCandidatesNamespace = TestCandidatesNamespace(
1295
+ transport=transport,
1296
+ base_url=base_url,
1297
+ headers=headers,
1298
+ )
1299
+
1300
+ def list(
1301
+ self,
1302
+ *,
1303
+ limit: int | None = None,
1304
+ offset: int | None = None,
1305
+ ) -> Page[Test]:
1306
+ """List tests.
1307
+
1308
+ Args:
1309
+ limit: Number of records to fetch.
1310
+ offset: Offset of records.
1311
+
1312
+ Returns:
1313
+ A page of tests.
1314
+ """
1315
+ response = self._request(
1316
+ method="GET",
1317
+ url=f"{_API_V3}/tests",
1318
+ params=_list_params(limit=limit, offset=offset),
1319
+ )
1320
+ payload = response.json()
1321
+ raw_items = list(payload.get("data", []))
1322
+ items: list[Test] = [Test.from_dict(data=item) for item in raw_items]
1323
+ return _make_page(items, payload)
1324
+
1325
+ def create(
1326
+ self,
1327
+ *,
1328
+ name: str,
1329
+ starttime: str | None = None,
1330
+ endtime: str | None = None,
1331
+ duration: int | None = None,
1332
+ instructions: str | None = None,
1333
+ locked: bool | None = None,
1334
+ draft: bool | None = None,
1335
+ languages: Sequence[str] | None = None,
1336
+ candidate_details: Sequence[str] | None = None,
1337
+ custom_acknowledge_text: str | None = None,
1338
+ cutoff_score: int | None = None,
1339
+ master_password: str | None = None,
1340
+ hide_compile_test: bool | None = None,
1341
+ tags: Sequence[str] | None = None,
1342
+ role_ids: Sequence[str] | None = None,
1343
+ experience: Sequence[str] | None = None,
1344
+ questions: Sequence[str] | None = None,
1345
+ mcq_incorrect_score: int | None = None,
1346
+ mcq_correct_score: int | None = None,
1347
+ shuffle_questions: bool | None = None,
1348
+ test_admins: Sequence[str] | None = None,
1349
+ hide_template: bool | None = None,
1350
+ enable_acknowledgement: bool | None = None,
1351
+ enable_proctoring: bool | None = None,
1352
+ enable_advanced_proctoring: bool | None = None,
1353
+ enable_secure_assessment_mode: bool | None = None,
1354
+ enable_ml_plagiarism_analysis: bool | None = None,
1355
+ enable_photo_identification: bool | None = None,
1356
+ ide_config: str | None = None,
1357
+ ) -> Test:
1358
+ """Create a test.
1359
+
1360
+ Args:
1361
+ name: The name of the test.
1362
+ starttime: Test start time.
1363
+ endtime: Test end time.
1364
+ duration: Test duration in minutes.
1365
+ instructions: Test instructions.
1366
+ locked: Whether the test is locked.
1367
+ draft: Whether the test is a draft.
1368
+ languages: Allowed programming languages.
1369
+ candidate_details: Candidate detail fields.
1370
+ custom_acknowledge_text: Custom acknowledge text.
1371
+ cutoff_score: The cut-off score.
1372
+ master_password: Master password for the test.
1373
+ hide_compile_test: Hide compile-test option.
1374
+ tags: Tags applied to the test.
1375
+ role_ids: Role ids associated with the test.
1376
+ experience: Experience-level metadata.
1377
+ questions: Question ids to include.
1378
+ mcq_incorrect_score: Score for incorrect MCQ.
1379
+ mcq_correct_score: Score for correct MCQ.
1380
+ shuffle_questions: Whether to shuffle.
1381
+ test_admins: Test admin user ids.
1382
+ hide_template: Hide the template flag.
1383
+ enable_acknowledgement: Enable acknowledgement.
1384
+ enable_proctoring: Enable proctoring.
1385
+ enable_advanced_proctoring: Advanced proctoring.
1386
+ enable_secure_assessment_mode: Secure mode flag.
1387
+ enable_ml_plagiarism_analysis: ML plagiarism.
1388
+ enable_photo_identification: Photo ID flag.
1389
+ ide_config: IDE configuration.
1390
+
1391
+ Returns:
1392
+ The created test.
1393
+ """
1394
+ body = _drop_none(
1395
+ {
1396
+ "name": name,
1397
+ "starttime": starttime,
1398
+ "endtime": endtime,
1399
+ "duration": duration,
1400
+ "instructions": instructions,
1401
+ "locked": locked,
1402
+ "draft": draft,
1403
+ "languages": (
1404
+ list(languages) if languages is not None else None
1405
+ ),
1406
+ "candidate_details": (
1407
+ list(candidate_details)
1408
+ if candidate_details is not None
1409
+ else None
1410
+ ),
1411
+ "custom_acknowledge_text": custom_acknowledge_text,
1412
+ "cutoff_score": cutoff_score,
1413
+ "master_password": master_password,
1414
+ "hide_compile_test": hide_compile_test,
1415
+ "tags": list(tags) if tags is not None else None,
1416
+ "role_ids": (list(role_ids) if role_ids is not None else None),
1417
+ "experience": (
1418
+ list(experience) if experience is not None else None
1419
+ ),
1420
+ "questions": (
1421
+ list(questions) if questions is not None else None
1422
+ ),
1423
+ "mcq_incorrect_score": mcq_incorrect_score,
1424
+ "mcq_correct_score": mcq_correct_score,
1425
+ "shuffle_questions": shuffle_questions,
1426
+ "test_admins": (
1427
+ list(test_admins) if test_admins is not None else None
1428
+ ),
1429
+ "hide_template": hide_template,
1430
+ "enable_acknowledgement": enable_acknowledgement,
1431
+ "enable_proctoring": enable_proctoring,
1432
+ "enable_advanced_proctoring": (enable_advanced_proctoring),
1433
+ "enable_secure_assessment_mode": (
1434
+ enable_secure_assessment_mode
1435
+ ),
1436
+ "enable_ml_plagiarism_analysis": (
1437
+ enable_ml_plagiarism_analysis
1438
+ ),
1439
+ "enable_photo_identification": (enable_photo_identification),
1440
+ "ide_config": ide_config,
1441
+ },
1442
+ )
1443
+ response = self._request(
1444
+ method="POST",
1445
+ url=f"{_API_V3}/tests",
1446
+ json=body,
1447
+ )
1448
+ return Test.from_dict(data=response.json())
1449
+
1450
+ def get(
1451
+ self,
1452
+ *,
1453
+ test_id: str,
1454
+ additional_fields: str | None = None,
1455
+ ) -> Test:
1456
+ """Retrieve a test.
1457
+
1458
+ Args:
1459
+ test_id: The id of the test.
1460
+ additional_fields: Comma-separated extra fields.
1461
+
1462
+ Returns:
1463
+ The test.
1464
+ """
1465
+ params: dict[str, str | int] = {}
1466
+ if additional_fields is not None:
1467
+ params["additional_fields"] = additional_fields
1468
+ response = self._request(
1469
+ method="GET",
1470
+ url=f"{_API_V3}/tests/{test_id}",
1471
+ params=params or None,
1472
+ )
1473
+ return Test.from_dict(data=response.json())
1474
+
1475
+ def update(
1476
+ self,
1477
+ *,
1478
+ test_id: str,
1479
+ body: Mapping[str, JSONValue],
1480
+ ) -> None:
1481
+ """Update a test using a raw payload.
1482
+
1483
+ Args:
1484
+ test_id: The id of the test.
1485
+ body: A mapping of fields to update. See the
1486
+ HackerRank API documentation for the
1487
+ supported fields.
1488
+ """
1489
+ self._request(
1490
+ method="PUT",
1491
+ url=f"{_API_V3}/tests/{test_id}",
1492
+ json=body,
1493
+ )
1494
+
1495
+ def delete(self, *, test_id: str) -> None:
1496
+ """Delete a test.
1497
+
1498
+ Args:
1499
+ test_id: The id of the test.
1500
+ """
1501
+ self._request(
1502
+ method="DELETE",
1503
+ url=f"{_API_V3}/tests/{test_id}",
1504
+ )
1505
+
1506
+ def archive(self, *, test_id: str) -> None:
1507
+ """Archive a test.
1508
+
1509
+ Args:
1510
+ test_id: The id of the test.
1511
+ """
1512
+ self._request(
1513
+ method="POST",
1514
+ url=f"{_API_V3}/tests/{test_id}/archive",
1515
+ )
1516
+
1517
+ def list_inviters(
1518
+ self,
1519
+ *,
1520
+ test_id: str,
1521
+ limit: int | None = None,
1522
+ offset: int | None = None,
1523
+ ) -> Page[Inviter]:
1524
+ """List inviters for a test.
1525
+
1526
+ Args:
1527
+ test_id: The id of the test.
1528
+ limit: Number of records to fetch.
1529
+ offset: Offset of records.
1530
+
1531
+ Returns:
1532
+ A page of inviters.
1533
+ """
1534
+ response = self._request(
1535
+ method="GET",
1536
+ url=f"{_API_V3}/tests/{test_id}/inviters",
1537
+ params=_list_params(limit=limit, offset=offset),
1538
+ )
1539
+ payload = response.json()
1540
+ raw_items = list(payload.get("data", []))
1541
+ items: list[Inviter] = [
1542
+ Inviter.from_dict(data=item) for item in raw_items
1543
+ ]
1544
+ return _make_page(items, payload)
1545
+
1546
+
1547
+ @beartype
1548
+ class TemplatesNamespace(_Namespace):
1549
+ """Namespace for invite-email templates."""
1550
+
1551
+ def list(
1552
+ self,
1553
+ *,
1554
+ limit: int | None = None,
1555
+ offset: int | None = None,
1556
+ ) -> Page[Template]:
1557
+ """List invite templates.
1558
+
1559
+ Args:
1560
+ limit: Number of records to fetch.
1561
+ offset: Offset of records.
1562
+
1563
+ Returns:
1564
+ A page of templates.
1565
+ """
1566
+ response = self._request(
1567
+ method="GET",
1568
+ url=f"{_API_V3}/templates",
1569
+ params=_list_params(limit=limit, offset=offset),
1570
+ )
1571
+ payload = response.json()
1572
+ raw_items = list(payload.get("data", []))
1573
+ items: list[Template] = [
1574
+ Template.from_dict(data=item) for item in raw_items
1575
+ ]
1576
+ return _make_page(items, payload)
1577
+
1578
+ def get(self, *, template_id: str) -> Template:
1579
+ """Retrieve an invite template.
1580
+
1581
+ Args:
1582
+ template_id: The id of the template.
1583
+
1584
+ Returns:
1585
+ The template.
1586
+ """
1587
+ response = self._request(
1588
+ method="GET",
1589
+ url=f"{_API_V3}/templates/{template_id}",
1590
+ )
1591
+ return Template.from_dict(data=response.json())
1592
+
1593
+
1594
+ @beartype
1595
+ class UsersNamespace(_Namespace):
1596
+ """Namespace for user operations."""
1597
+
1598
+ def list(
1599
+ self,
1600
+ *,
1601
+ limit: int | None = None,
1602
+ offset: int | None = None,
1603
+ ) -> Page[User]:
1604
+ """List users.
1605
+
1606
+ Args:
1607
+ limit: Number of records to fetch.
1608
+ offset: Offset of records.
1609
+
1610
+ Returns:
1611
+ A page of users.
1612
+ """
1613
+ response = self._request(
1614
+ method="GET",
1615
+ url=f"{_API_V3}/users",
1616
+ params=_list_params(limit=limit, offset=offset),
1617
+ )
1618
+ payload = response.json()
1619
+ raw_items = list(payload.get("data", []))
1620
+ items: list[User] = [User.from_dict(data=item) for item in raw_items]
1621
+ return _make_page(items, payload)
1622
+
1623
+ def search(
1624
+ self,
1625
+ *,
1626
+ search: str,
1627
+ limit: int | None = None,
1628
+ offset: int | None = None,
1629
+ ) -> Page[User]:
1630
+ """Search users.
1631
+
1632
+ Args:
1633
+ search: Search query.
1634
+ limit: Number of records to fetch.
1635
+ offset: Offset of records.
1636
+
1637
+ Returns:
1638
+ A page of users.
1639
+ """
1640
+ params = _list_params(
1641
+ limit=limit,
1642
+ offset=offset,
1643
+ extra={"search": search},
1644
+ )
1645
+ response = self._request(
1646
+ method="GET",
1647
+ url=f"{_API_V3}/users/search",
1648
+ params=params,
1649
+ )
1650
+ payload = response.json()
1651
+ raw_items = list(payload.get("data", []))
1652
+ items: list[User] = [User.from_dict(data=item) for item in raw_items]
1653
+ return _make_page(items, payload)
1654
+
1655
+ def create(
1656
+ self,
1657
+ *,
1658
+ email: str,
1659
+ firstname: str | None = None,
1660
+ lastname: str | None = None,
1661
+ country: str | None = None,
1662
+ role: str | None = None,
1663
+ send_email: bool | None = None,
1664
+ phone: str | None = None,
1665
+ questions_permission: int | None = None,
1666
+ tests_permission: int | None = None,
1667
+ interviews_permission: int | None = None,
1668
+ candidates_permission: int | None = None,
1669
+ shared_questions_permission: int | None = None,
1670
+ shared_tests_permission: int | None = None,
1671
+ shared_interviews_permission: int | None = None,
1672
+ shared_candidates_permission: int | None = None,
1673
+ company_admin: bool | None = None,
1674
+ team_admin: bool | None = None,
1675
+ teams: Sequence[str] | None = None,
1676
+ ) -> User:
1677
+ """Create a user.
1678
+
1679
+ Args:
1680
+ email: Email address.
1681
+ firstname: First name.
1682
+ lastname: Last name.
1683
+ country: Country.
1684
+ role: Role.
1685
+ send_email: Send invite email flag.
1686
+ phone: Phone number.
1687
+ questions_permission: Permission level.
1688
+ tests_permission: Permission level.
1689
+ interviews_permission: Permission level.
1690
+ candidates_permission: Permission level.
1691
+ shared_questions_permission: Permission level.
1692
+ shared_tests_permission: Permission level.
1693
+ shared_interviews_permission: Permission level.
1694
+ shared_candidates_permission: Permission level.
1695
+ company_admin: Company admin flag.
1696
+ team_admin: Team admin flag.
1697
+ teams: Team ids the user belongs to.
1698
+
1699
+ Returns:
1700
+ The created user.
1701
+ """
1702
+ body = _drop_none(
1703
+ {
1704
+ "email": email,
1705
+ "firstname": firstname,
1706
+ "lastname": lastname,
1707
+ "country": country,
1708
+ "role": role,
1709
+ "send_email": send_email,
1710
+ "phone": phone,
1711
+ "questions_permission": questions_permission,
1712
+ "tests_permission": tests_permission,
1713
+ "interviews_permission": interviews_permission,
1714
+ "candidates_permission": candidates_permission,
1715
+ "shared_questions_permission": (shared_questions_permission),
1716
+ "shared_tests_permission": (shared_tests_permission),
1717
+ "shared_interviews_permission": (shared_interviews_permission),
1718
+ "shared_candidates_permission": (shared_candidates_permission),
1719
+ "company_admin": company_admin,
1720
+ "team_admin": team_admin,
1721
+ "teams": (
1722
+ [{"id": t} for t in teams] if teams is not None else None
1723
+ ),
1724
+ },
1725
+ )
1726
+ response = self._request(
1727
+ method="POST",
1728
+ url=f"{_API_V3}/users",
1729
+ json=body,
1730
+ )
1731
+ return User.from_dict(data=response.json())
1732
+
1733
+ def get(self, *, user_id: str) -> User:
1734
+ """Retrieve a user.
1735
+
1736
+ Args:
1737
+ user_id: The id of the user.
1738
+
1739
+ Returns:
1740
+ The user.
1741
+ """
1742
+ response = self._request(
1743
+ method="GET",
1744
+ url=f"{_API_V3}/users/{user_id}",
1745
+ )
1746
+ return User.from_dict(data=response.json())
1747
+
1748
+ def update(
1749
+ self,
1750
+ *,
1751
+ user_id: str,
1752
+ body: Mapping[str, JSONValue],
1753
+ ) -> None:
1754
+ """Update a user.
1755
+
1756
+ Args:
1757
+ user_id: The id of the user.
1758
+ body: A mapping of fields to update. See the
1759
+ HackerRank API documentation for the
1760
+ supported fields.
1761
+ """
1762
+ self._request(
1763
+ method="PUT",
1764
+ url=f"{_API_V3}/users/{user_id}",
1765
+ json=body,
1766
+ )
1767
+
1768
+ def delete(self, *, user_id: str) -> None:
1769
+ """Lock (deactivate) a user.
1770
+
1771
+ Args:
1772
+ user_id: The id of the user.
1773
+ """
1774
+ self._request(
1775
+ method="DELETE",
1776
+ url=f"{_API_V3}/users/{user_id}",
1777
+ )
1778
+
1779
+
1780
+ @beartype
1781
+ class TeamMembershipsNamespace(_Namespace):
1782
+ """Namespace for user-team membership operations."""
1783
+
1784
+ def list(
1785
+ self,
1786
+ *,
1787
+ team_id: str,
1788
+ limit: int | None = None,
1789
+ offset: int | None = None,
1790
+ ) -> Page[UserTeamMembership]:
1791
+ """List memberships for a team.
1792
+
1793
+ Args:
1794
+ team_id: The id of the team.
1795
+ limit: Number of records to fetch.
1796
+ offset: Offset of records.
1797
+
1798
+ Returns:
1799
+ A page of memberships.
1800
+ """
1801
+ response = self._request(
1802
+ method="GET",
1803
+ url=f"{_API_V3}/teams/{team_id}/users",
1804
+ params=_list_params(limit=limit, offset=offset),
1805
+ )
1806
+ payload = response.json()
1807
+ raw_items = list(payload.get("data", []))
1808
+ items: list[UserTeamMembership] = [
1809
+ UserTeamMembership.from_dict(data=item) for item in raw_items
1810
+ ]
1811
+ return _make_page(items, payload)
1812
+
1813
+ def get(
1814
+ self,
1815
+ *,
1816
+ team_id: str,
1817
+ user_id: str,
1818
+ ) -> UserTeamMembership:
1819
+ """Retrieve a single membership.
1820
+
1821
+ Args:
1822
+ team_id: The id of the team.
1823
+ user_id: The id of the user.
1824
+
1825
+ Returns:
1826
+ The membership.
1827
+ """
1828
+ response = self._request(
1829
+ method="GET",
1830
+ url=f"{_API_V3}/teams/{team_id}/users/{user_id}",
1831
+ )
1832
+ return UserTeamMembership.from_dict(data=response.json())
1833
+
1834
+ def create(
1835
+ self,
1836
+ *,
1837
+ team_id: str,
1838
+ user_id: str,
1839
+ license: str | None = None, # noqa: A002 # pylint: disable=redefined-builtin
1840
+ ) -> UserTeamMembership:
1841
+ """Add a user to a team.
1842
+
1843
+ Args:
1844
+ team_id: The id of the team.
1845
+ user_id: The id of the user.
1846
+ license: License kind to assign.
1847
+
1848
+ Returns:
1849
+ The created membership.
1850
+ """
1851
+ params: dict[str, str | int] = {}
1852
+ if license is not None:
1853
+ params["license"] = license
1854
+ response = self._request(
1855
+ method="POST",
1856
+ url=f"{_API_V3}/teams/{team_id}/users/{user_id}",
1857
+ params=params or None,
1858
+ )
1859
+ return UserTeamMembership.from_dict(data=response.json())
1860
+
1861
+ def delete(
1862
+ self,
1863
+ *,
1864
+ team_id: str,
1865
+ user_id: str,
1866
+ ) -> None:
1867
+ """Remove a user from a team.
1868
+
1869
+ Args:
1870
+ team_id: The id of the team.
1871
+ user_id: The id of the user.
1872
+ """
1873
+ self._request(
1874
+ method="DELETE",
1875
+ url=f"{_API_V3}/teams/{team_id}/users/{user_id}",
1876
+ )
1877
+
1878
+
1879
+ @beartype
1880
+ class TeamsNamespace(_Namespace):
1881
+ """Namespace for team operations."""
1882
+
1883
+ def __init__(
1884
+ self,
1885
+ *,
1886
+ transport: Transport,
1887
+ base_url: str,
1888
+ headers: dict[str, str],
1889
+ ) -> None:
1890
+ """Create the namespace.
1891
+
1892
+ Args:
1893
+ transport: The HTTP transport.
1894
+ base_url: The base URL for the API.
1895
+ headers: Headers to send with every request.
1896
+ """
1897
+ super().__init__(
1898
+ transport=transport,
1899
+ base_url=base_url,
1900
+ headers=headers,
1901
+ )
1902
+ self.memberships: TeamMembershipsNamespace = TeamMembershipsNamespace(
1903
+ transport=transport,
1904
+ base_url=base_url,
1905
+ headers=headers,
1906
+ )
1907
+
1908
+ def list(
1909
+ self,
1910
+ *,
1911
+ limit: int | None = None,
1912
+ offset: int | None = None,
1913
+ ) -> Page[Team]:
1914
+ """List teams.
1915
+
1916
+ Args:
1917
+ limit: Number of records to fetch.
1918
+ offset: Offset of records.
1919
+
1920
+ Returns:
1921
+ A page of teams.
1922
+ """
1923
+ response = self._request(
1924
+ method="GET",
1925
+ url=f"{_API_V3}/teams",
1926
+ params=_list_params(limit=limit, offset=offset),
1927
+ )
1928
+ payload = response.json()
1929
+ raw_items = list(payload.get("data", []))
1930
+ items: list[Team] = [Team.from_dict(data=item) for item in raw_items]
1931
+ return _make_page(items, payload)
1932
+
1933
+ def create(
1934
+ self,
1935
+ *,
1936
+ name: str,
1937
+ recruiter_cap: int | None = None,
1938
+ developer_cap: int | None = None,
1939
+ invite_as: str | None = None,
1940
+ locations: Sequence[str] | None = None,
1941
+ departments: Sequence[str] | None = None,
1942
+ ) -> Team:
1943
+ """Create a team.
1944
+
1945
+ Args:
1946
+ name: Team name.
1947
+ recruiter_cap: Recruiter seat cap.
1948
+ developer_cap: Developer seat cap.
1949
+ invite_as: Default role for invites.
1950
+ locations: Allowed locations.
1951
+ departments: Allowed departments.
1952
+
1953
+ Returns:
1954
+ The created team.
1955
+ """
1956
+ body = _drop_none(
1957
+ {
1958
+ "name": name,
1959
+ "recruiter_cap": recruiter_cap,
1960
+ "developer_cap": developer_cap,
1961
+ "invite_as": invite_as,
1962
+ "locations": (
1963
+ list(locations) if locations is not None else None
1964
+ ),
1965
+ "departments": (
1966
+ list(departments) if departments is not None else None
1967
+ ),
1968
+ },
1969
+ )
1970
+ response = self._request(
1971
+ method="POST",
1972
+ url=f"{_API_V3}/teams",
1973
+ json=body,
1974
+ )
1975
+ return Team.from_dict(data=response.json())
1976
+
1977
+ def get(self, *, team_id: str) -> Team:
1978
+ """Retrieve a team.
1979
+
1980
+ Args:
1981
+ team_id: The id of the team.
1982
+
1983
+ Returns:
1984
+ The team.
1985
+ """
1986
+ response = self._request(
1987
+ method="GET",
1988
+ url=f"{_API_V3}/teams/{team_id}",
1989
+ )
1990
+ return Team.from_dict(data=response.json())
1991
+
1992
+ def update(
1993
+ self,
1994
+ *,
1995
+ team_id: str,
1996
+ name: str | None = None,
1997
+ recruiter_cap: int | None = None,
1998
+ developer_cap: int | None = None,
1999
+ invite_as: str | None = None,
2000
+ locations: Sequence[str] | None = None,
2001
+ departments: Sequence[str] | None = None,
2002
+ ) -> None:
2003
+ """Update a team.
2004
+
2005
+ Args:
2006
+ team_id: The id of the team.
2007
+ name: New team name.
2008
+ recruiter_cap: New recruiter seat cap.
2009
+ developer_cap: New developer seat cap.
2010
+ invite_as: New default invite role.
2011
+ locations: New allowed locations.
2012
+ departments: New allowed departments.
2013
+ """
2014
+ body = _drop_none(
2015
+ {
2016
+ "name": name,
2017
+ "recruiter_cap": recruiter_cap,
2018
+ "developer_cap": developer_cap,
2019
+ "invite_as": invite_as,
2020
+ "locations": (
2021
+ list(locations) if locations is not None else None
2022
+ ),
2023
+ "departments": (
2024
+ list(departments) if departments is not None else None
2025
+ ),
2026
+ },
2027
+ )
2028
+ self._request(
2029
+ method="PUT",
2030
+ url=f"{_API_V3}/teams/{team_id}",
2031
+ json=body,
2032
+ )
2033
+
2034
+ def delete(self, *, team_id: str) -> None:
2035
+ """Delete a team.
2036
+
2037
+ Args:
2038
+ team_id: The id of the team.
2039
+ """
2040
+ self._request(
2041
+ method="DELETE",
2042
+ url=f"{_API_V3}/teams/{team_id}",
2043
+ )
2044
+
2045
+
2046
+ @beartype
2047
+ class AuditLogsNamespace(_Namespace):
2048
+ """Namespace for audit-log operations."""
2049
+
2050
+ def list(
2051
+ self,
2052
+ *,
2053
+ limit: int | None = None,
2054
+ offset: int | None = None,
2055
+ user_id: str | None = None,
2056
+ ) -> Page[AuditLog]:
2057
+ """List audit logs.
2058
+
2059
+ Args:
2060
+ limit: Number of records to fetch.
2061
+ offset: Offset of records.
2062
+ user_id: Filter to a specific user.
2063
+
2064
+ Returns:
2065
+ A page of audit logs.
2066
+ """
2067
+ extra: dict[str, str | int] = {}
2068
+ if user_id is not None:
2069
+ extra["user_id"] = user_id
2070
+ response = self._request(
2071
+ method="GET",
2072
+ url=f"{_API_V3}/audit_log",
2073
+ params=_list_params(
2074
+ limit=limit,
2075
+ offset=offset,
2076
+ extra=extra,
2077
+ ),
2078
+ )
2079
+ payload = response.json()
2080
+ raw_items = list(payload.get("data", []))
2081
+ items: list[AuditLog] = [
2082
+ AuditLog.from_dict(data=item) for item in raw_items
2083
+ ]
2084
+ return _make_page(items, payload)
2085
+
2086
+
2087
+ @beartype
2088
+ class ATSCodePairNamespace(_Namespace):
2089
+ """Namespace for ATS Codepair operations."""
2090
+
2091
+ def invite(
2092
+ self,
2093
+ *,
2094
+ title: str | None = None,
2095
+ requisition_id: str | None = None,
2096
+ candidate_id: str | None = None,
2097
+ candidate: Mapping[str, JSONValue] | None = None,
2098
+ send_email: bool | None = None,
2099
+ interview_metadata: Mapping[str, JSONValue] | None = None,
2100
+ ) -> ATSCodePair:
2101
+ """Invite a candidate to an ATS Codepair interview.
2102
+
2103
+ Args:
2104
+ title: The interview title.
2105
+ requisition_id: The ATS requisition id.
2106
+ candidate_id: The ATS candidate id.
2107
+ candidate: Candidate information.
2108
+ send_email: Whether to send an email.
2109
+ interview_metadata: Arbitrary metadata.
2110
+
2111
+ Returns:
2112
+ The ATS Codepair result.
2113
+ """
2114
+ body = _drop_none(
2115
+ {
2116
+ "title": title,
2117
+ "requisition_id": requisition_id,
2118
+ "candidate_id": candidate_id,
2119
+ "candidate": candidate,
2120
+ "send_email": send_email,
2121
+ "interview_metadata": interview_metadata,
2122
+ },
2123
+ )
2124
+ response = self._request(
2125
+ method="POST",
2126
+ url=f"{_API_V3}/ats/codepair",
2127
+ json=body,
2128
+ )
2129
+ return ATSCodePair.from_dict(data=response.json())
2130
+
2131
+
2132
+ @beartype
2133
+ class ATSCodeScreenNamespace(_Namespace):
2134
+ """Namespace for ATS CodeScreen operations."""
2135
+
2136
+ def invite(
2137
+ self,
2138
+ *,
2139
+ test_id: str,
2140
+ email: str,
2141
+ requisition_id: str | None = None,
2142
+ candidate_id: str | None = None,
2143
+ send_email: bool | None = None,
2144
+ test_result_url: str | None = None,
2145
+ webhook_authentication: Mapping[str, JSONValue] | None = None,
2146
+ accept_result_updates: bool | None = None,
2147
+ force: bool | None = None,
2148
+ force_reattempt_after: int | None = None,
2149
+ accommodations: Mapping[str, JSONValue] | None = None,
2150
+ ) -> ATSCodeScreen:
2151
+ """Invite a candidate to a CodeScreen test.
2152
+
2153
+ Args:
2154
+ test_id: The test id.
2155
+ email: Candidate email.
2156
+ requisition_id: ATS requisition id.
2157
+ candidate_id: ATS candidate id.
2158
+ send_email: Whether to send an email.
2159
+ test_result_url: URL for posting results.
2160
+ webhook_authentication: Webhook auth config.
2161
+ accept_result_updates: Accept-updates flag.
2162
+ force: Force inviting flag.
2163
+ force_reattempt_after: Seconds before re-attempt.
2164
+ accommodations: Accommodation settings.
2165
+
2166
+ Returns:
2167
+ The ATS CodeScreen result.
2168
+ """
2169
+ body = _drop_none(
2170
+ {
2171
+ "test_id": test_id,
2172
+ "email": email,
2173
+ "requisition_id": requisition_id,
2174
+ "candidate_id": candidate_id,
2175
+ "send_email": send_email,
2176
+ "test_result_url": test_result_url,
2177
+ "webhook_authentication": webhook_authentication,
2178
+ "accept_result_updates": accept_result_updates,
2179
+ "force": force,
2180
+ "force_reattempt_after": force_reattempt_after,
2181
+ "accommodations": accommodations,
2182
+ },
2183
+ )
2184
+ response = self._request(
2185
+ method="POST",
2186
+ url=f"{_API_V3}/ats/codescreen",
2187
+ json=body,
2188
+ )
2189
+ return ATSCodeScreen.from_dict(data=response.json())
2190
+
2191
+
2192
+ @beartype
2193
+ class ATSNamespace(_Namespace):
2194
+ """Namespace for ATS operations."""
2195
+
2196
+ def __init__(
2197
+ self,
2198
+ *,
2199
+ transport: Transport,
2200
+ base_url: str,
2201
+ headers: dict[str, str],
2202
+ ) -> None:
2203
+ """Create the ATS namespace.
2204
+
2205
+ Args:
2206
+ transport: The HTTP transport.
2207
+ base_url: The base URL for the API.
2208
+ headers: Headers to send with every request.
2209
+ """
2210
+ super().__init__(
2211
+ transport=transport,
2212
+ base_url=base_url,
2213
+ headers=headers,
2214
+ )
2215
+ self.codepair: ATSCodePairNamespace = ATSCodePairNamespace(
2216
+ transport=transport,
2217
+ base_url=base_url,
2218
+ headers=headers,
2219
+ )
2220
+ self.codescreen: ATSCodeScreenNamespace = ATSCodeScreenNamespace(
2221
+ transport=transport,
2222
+ base_url=base_url,
2223
+ headers=headers,
2224
+ )
2225
+
2226
+
2227
+ def _make_scim_page[T](
2228
+ items: list[T],
2229
+ payload: Mapping[str, JSONValue],
2230
+ /,
2231
+ ) -> SCIMPage[T]:
2232
+ """Wrap items and a SCIM payload into a ``SCIMPage``.
2233
+
2234
+ Args:
2235
+ items: The SCIM items in the page.
2236
+ payload: The raw SCIM response payload.
2237
+
2238
+ Returns:
2239
+ The populated ``SCIMPage`` instance.
2240
+ """
2241
+ schemas_raw = payload.get("schemas")
2242
+ schemas: list[str] = [
2243
+ item
2244
+ for item in (schemas_raw if isinstance(schemas_raw, list) else [])
2245
+ if isinstance(item, str)
2246
+ ]
2247
+ start_index = _coerce_int(payload.get("startIndex")) or 1
2248
+ return SCIMPage(
2249
+ items,
2250
+ schemas=schemas,
2251
+ start_index=start_index,
2252
+ items_per_page=_coerce_int(payload.get("itemsPerPage")),
2253
+ total_results=_coerce_int(payload.get("totalResults")),
2254
+ )
2255
+
2256
+
2257
+ @beartype
2258
+ class SCIMUsersNamespace(_Namespace):
2259
+ """Namespace for SCIM v2 user operations."""
2260
+
2261
+ def list(
2262
+ self,
2263
+ *,
2264
+ limit: int | None = None,
2265
+ offset: int | None = None,
2266
+ ) -> SCIMPage[SCIMUser]:
2267
+ """List SCIM users.
2268
+
2269
+ Args:
2270
+ limit: Number of records to fetch.
2271
+ offset: Offset of records.
2272
+
2273
+ Returns:
2274
+ A page of SCIM users.
2275
+ """
2276
+ response = self._request(
2277
+ method="GET",
2278
+ url="/Users",
2279
+ params=_list_params(limit=limit, offset=offset),
2280
+ )
2281
+ payload = response.json()
2282
+ raw = list(payload.get("Resources", []))
2283
+ items: list[SCIMUser] = [SCIMUser.from_dict(data=item) for item in raw]
2284
+ return _make_scim_page(items, payload)
2285
+
2286
+ def create(
2287
+ self,
2288
+ *,
2289
+ body: Mapping[str, JSONValue],
2290
+ ) -> SCIMUser:
2291
+ """Create a SCIM user.
2292
+
2293
+ Args:
2294
+ body: The SCIM user payload.
2295
+
2296
+ Returns:
2297
+ The created SCIM user.
2298
+ """
2299
+ response = self._request(
2300
+ method="POST",
2301
+ url="/Users",
2302
+ json=body,
2303
+ )
2304
+ return SCIMUser.from_dict(data=response.json())
2305
+
2306
+ def get(self, *, scim_user_id: str) -> SCIMUser:
2307
+ """Retrieve a SCIM user.
2308
+
2309
+ Args:
2310
+ scim_user_id: The id of the SCIM user.
2311
+
2312
+ Returns:
2313
+ The SCIM user.
2314
+ """
2315
+ response = self._request(
2316
+ method="GET",
2317
+ url=f"/Users/{scim_user_id}",
2318
+ )
2319
+ return SCIMUser.from_dict(data=response.json())
2320
+
2321
+ def replace(
2322
+ self,
2323
+ *,
2324
+ scim_user_id: str,
2325
+ body: Mapping[str, JSONValue],
2326
+ ) -> SCIMUser:
2327
+ """Replace a SCIM user (PUT).
2328
+
2329
+ Args:
2330
+ scim_user_id: The id of the SCIM user.
2331
+ body: The full SCIM user payload.
2332
+
2333
+ Returns:
2334
+ The updated SCIM user.
2335
+ """
2336
+ response = self._request(
2337
+ method="PUT",
2338
+ url=f"/Users/{scim_user_id}",
2339
+ json=body,
2340
+ )
2341
+ return SCIMUser.from_dict(data=response.json())
2342
+
2343
+ def patch(
2344
+ self,
2345
+ *,
2346
+ scim_user_id: str,
2347
+ operations: Sequence[Mapping[str, JSONValue]],
2348
+ ) -> SCIMUser:
2349
+ """Patch a SCIM user.
2350
+
2351
+ Args:
2352
+ scim_user_id: The id of the SCIM user.
2353
+ operations: The SCIM patch operations.
2354
+
2355
+ Returns:
2356
+ The updated SCIM user.
2357
+ """
2358
+ body: dict[str, JSONValue] = {
2359
+ "operations": [dict(op) for op in operations],
2360
+ }
2361
+ response = self._request(
2362
+ method="PATCH",
2363
+ url=f"/Users/{scim_user_id}",
2364
+ json=body,
2365
+ )
2366
+ return SCIMUser.from_dict(data=response.json())
2367
+
2368
+ def delete(self, *, scim_user_id: str) -> None:
2369
+ """Lock a SCIM user.
2370
+
2371
+ Args:
2372
+ scim_user_id: The id of the SCIM user.
2373
+ """
2374
+ self._request(
2375
+ method="DELETE",
2376
+ url=f"/Users/{scim_user_id}",
2377
+ )
2378
+
2379
+
2380
+ @beartype
2381
+ class SCIMGroupsNamespace(_Namespace):
2382
+ """Namespace for SCIM v2 group (team) operations."""
2383
+
2384
+ def list(
2385
+ self,
2386
+ *,
2387
+ limit: int | None = None,
2388
+ offset: int | None = None,
2389
+ ) -> SCIMPage[SCIMTeam]:
2390
+ """List SCIM groups.
2391
+
2392
+ Args:
2393
+ limit: Number of records to fetch.
2394
+ offset: Offset of records.
2395
+
2396
+ Returns:
2397
+ A page of SCIM teams.
2398
+ """
2399
+ response = self._request(
2400
+ method="GET",
2401
+ url="/Groups",
2402
+ params=_list_params(limit=limit, offset=offset),
2403
+ )
2404
+ payload = response.json()
2405
+ raw = list(payload.get("Resources", []))
2406
+ items: list[SCIMTeam] = [SCIMTeam.from_dict(data=item) for item in raw]
2407
+ return _make_scim_page(items, payload)
2408
+
2409
+ def create(self, *, body: Mapping[str, JSONValue]) -> SCIMTeam:
2410
+ """Create a SCIM group.
2411
+
2412
+ Args:
2413
+ body: The SCIM group payload.
2414
+
2415
+ Returns:
2416
+ The created SCIM team.
2417
+ """
2418
+ response = self._request(
2419
+ method="POST",
2420
+ url="/Groups",
2421
+ json=body,
2422
+ )
2423
+ return SCIMTeam.from_dict(data=response.json())
2424
+
2425
+ def get(self, *, scim_group_id: str) -> SCIMTeam:
2426
+ """Retrieve a SCIM group.
2427
+
2428
+ Args:
2429
+ scim_group_id: The id of the SCIM group.
2430
+
2431
+ Returns:
2432
+ The SCIM team.
2433
+ """
2434
+ response = self._request(
2435
+ method="GET",
2436
+ url=f"/Groups/{scim_group_id}",
2437
+ )
2438
+ return SCIMTeam.from_dict(data=response.json())
2439
+
2440
+ def patch(
2441
+ self,
2442
+ *,
2443
+ scim_group_id: str,
2444
+ operations: Sequence[Mapping[str, JSONValue]],
2445
+ ) -> SCIMTeam:
2446
+ """Patch a SCIM group.
2447
+
2448
+ Args:
2449
+ scim_group_id: The id of the SCIM group.
2450
+ operations: The SCIM patch operations.
2451
+
2452
+ Returns:
2453
+ The updated SCIM team.
2454
+ """
2455
+ body: dict[str, JSONValue] = {
2456
+ "operations": [dict(op) for op in operations],
2457
+ }
2458
+ response = self._request(
2459
+ method="PATCH",
2460
+ url=f"/Groups/{scim_group_id}",
2461
+ json=body,
2462
+ )
2463
+ return SCIMTeam.from_dict(data=response.json())
2464
+
2465
+ def delete(self, *, scim_group_id: str) -> None:
2466
+ """Deprovision a SCIM group.
2467
+
2468
+ Args:
2469
+ scim_group_id: The id of the SCIM group.
2470
+ """
2471
+ self._request(
2472
+ method="DELETE",
2473
+ url=f"/Groups/{scim_group_id}",
2474
+ )
2475
+
2476
+
2477
+ @beartype
2478
+ class SCIMNamespace(_Namespace):
2479
+ """Namespace for SCIM v2 operations."""
2480
+
2481
+ def __init__(
2482
+ self,
2483
+ *,
2484
+ transport: Transport,
2485
+ base_url: str,
2486
+ headers: dict[str, str],
2487
+ ) -> None:
2488
+ """Create the SCIM namespace.
2489
+
2490
+ Args:
2491
+ transport: The HTTP transport.
2492
+ base_url: The base URL for the API.
2493
+ headers: Headers to send with every request.
2494
+ """
2495
+ super().__init__(
2496
+ transport=transport,
2497
+ base_url=base_url,
2498
+ headers=headers,
2499
+ )
2500
+ self.users: SCIMUsersNamespace = SCIMUsersNamespace(
2501
+ transport=transport,
2502
+ base_url=base_url,
2503
+ headers=headers,
2504
+ )
2505
+ self.groups: SCIMGroupsNamespace = SCIMGroupsNamespace(
2506
+ transport=transport,
2507
+ base_url=base_url,
2508
+ headers=headers,
2509
+ )
2510
+
2511
+
2512
+ @beartype
2513
+ class HackerRank:
2514
+ """A client for the HackerRank for Work API."""
2515
+
2516
+ def __init__(
2517
+ self,
2518
+ *,
2519
+ api_key: str,
2520
+ base_url: str = "https://www.hackerrank.com",
2521
+ scim_base_url: str = _SCIM_BASE_URL,
2522
+ transport: Transport | None = None,
2523
+ ) -> None:
2524
+ """Create a new HackerRank client.
2525
+
2526
+ Args:
2527
+ api_key: The API key for authentication.
2528
+ base_url: The base URL for the v3 API.
2529
+ scim_base_url: The base URL for the SCIM v2 API. This
2530
+ is a different host from ``base_url``; the two APIs
2531
+ cannot share a base URL.
2532
+ transport: The HTTP transport. Defaults to
2533
+ ``HTTPXTransport()``.
2534
+ """
2535
+ self.base_url = base_url
2536
+ self.scim_base_url = scim_base_url
2537
+ resolved_transport = transport or HTTPXTransport()
2538
+ headers = {
2539
+ "Authorization": f"Bearer {api_key}",
2540
+ "Accept": "application/json",
2541
+ }
2542
+ self.interviews: InterviewsNamespace = InterviewsNamespace(
2543
+ transport=resolved_transport,
2544
+ base_url=base_url,
2545
+ headers=headers,
2546
+ )
2547
+ self.interview_templates: InterviewTemplatesNamespace = (
2548
+ InterviewTemplatesNamespace(
2549
+ transport=resolved_transport,
2550
+ base_url=base_url,
2551
+ headers=headers,
2552
+ )
2553
+ )
2554
+ self.environments: EnvironmentsNamespace = EnvironmentsNamespace(
2555
+ transport=resolved_transport,
2556
+ base_url=base_url,
2557
+ headers=headers,
2558
+ )
2559
+ self.questions: QuestionsNamespace = QuestionsNamespace(
2560
+ transport=resolved_transport,
2561
+ base_url=base_url,
2562
+ headers=headers,
2563
+ )
2564
+ self.tests: TestsNamespace = TestsNamespace(
2565
+ transport=resolved_transport,
2566
+ base_url=base_url,
2567
+ headers=headers,
2568
+ )
2569
+ self.templates: TemplatesNamespace = TemplatesNamespace(
2570
+ transport=resolved_transport,
2571
+ base_url=base_url,
2572
+ headers=headers,
2573
+ )
2574
+ self.users: UsersNamespace = UsersNamespace(
2575
+ transport=resolved_transport,
2576
+ base_url=base_url,
2577
+ headers=headers,
2578
+ )
2579
+ self.teams: TeamsNamespace = TeamsNamespace(
2580
+ transport=resolved_transport,
2581
+ base_url=base_url,
2582
+ headers=headers,
2583
+ )
2584
+ self.audit_logs: AuditLogsNamespace = AuditLogsNamespace(
2585
+ transport=resolved_transport,
2586
+ base_url=base_url,
2587
+ headers=headers,
2588
+ )
2589
+ self.ats: ATSNamespace = ATSNamespace(
2590
+ transport=resolved_transport,
2591
+ base_url=base_url,
2592
+ headers=headers,
2593
+ )
2594
+ self.scim: SCIMNamespace = SCIMNamespace(
2595
+ transport=resolved_transport,
2596
+ base_url=scim_base_url,
2597
+ headers=headers,
2598
+ )
2599
+ if isinstance(resolved_transport, HTTPXTransport):
2600
+ self._close = resolved_transport.close
2601
+ else:
2602
+ self._close = lambda: None
2603
+
2604
+ def close(self) -> None:
2605
+ """Close the underlying transport if it supports closing."""
2606
+ self._close()
2607
+
2608
+ def __enter__(self) -> Self:
2609
+ """Enter the context manager.
2610
+
2611
+ Returns:
2612
+ This client instance.
2613
+ """
2614
+ return self
2615
+
2616
+ def __exit__(
2617
+ self,
2618
+ _exc_type: type[BaseException] | None,
2619
+ _exc_val: BaseException | None,
2620
+ _exc_tb: TracebackType | None,
2621
+ ) -> None:
2622
+ """Exit the context manager and close the transport."""
2623
+ self.close()