docket-client 0.3.2__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.
Files changed (188) hide show
  1. docket/__init__.py +8 -0
  2. docket/api/__init__.py +1 -0
  3. docket/api/access/__init__.py +1 -0
  4. docket/api/access/add_principal.py +193 -0
  5. docket/api/access/archive_role_assignment.py +182 -0
  6. docket/api/access/create_api_token.py +198 -0
  7. docket/api/access/create_application_principal.py +188 -0
  8. docket/api/access/create_role_assignment.py +194 -0
  9. docket/api/access/list_api_tokens.py +184 -0
  10. docket/api/access/list_principals.py +184 -0
  11. docket/api/access/list_role_assignments.py +200 -0
  12. docket/api/access/revoke_api_token.py +173 -0
  13. docket/api/identity/__init__.py +1 -0
  14. docket/api/identity/create_my_api_token.py +206 -0
  15. docket/api/identity/get_me.py +127 -0
  16. docket/api/identity/list_my_api_tokens.py +151 -0
  17. docket/api/identity/revoke_my_api_token.py +178 -0
  18. docket/api/recurring_acquisitions/__init__.py +1 -0
  19. docket/api/recurring_acquisitions/archive_recurring_acquisition.py +207 -0
  20. docket/api/recurring_acquisitions/create_recurring_acquisition.py +218 -0
  21. docket/api/recurring_acquisitions/get_recurring_acquisition.py +163 -0
  22. docket/api/recurring_acquisitions/list_recurring_acquisitions.py +248 -0
  23. docket/api/recurring_acquisitions/preview_recurring_acquisition.py +187 -0
  24. docket/api/recurring_acquisitions/preview_split_recurring_acquisition.py +232 -0
  25. docket/api/recurring_acquisitions/split_recurring_acquisition.py +258 -0
  26. docket/api/runs/__init__.py +1 -0
  27. docket/api/runs/get_run.py +160 -0
  28. docket/api/runs/list_runs.py +180 -0
  29. docket/api/runs/report_run_outcome.py +206 -0
  30. docket/api/scheduled_acquisitions/__init__.py +1 -0
  31. docket/api/scheduled_acquisitions/archive_scheduled_acquisition.py +203 -0
  32. docket/api/scheduled_acquisitions/create_scheduled_acquisition.py +185 -0
  33. docket/api/scheduled_acquisitions/get_resolved_settings.py +169 -0
  34. docket/api/scheduled_acquisitions/get_scheduled_acquisition.py +159 -0
  35. docket/api/scheduled_acquisitions/list_active_acquisitions.py +297 -0
  36. docket/api/scheduled_acquisitions/list_runs_for_scheduled_acquisition.py +191 -0
  37. docket/api/scheduled_acquisitions/list_scheduled_acquisitions.py +332 -0
  38. docket/api/scheduled_acquisitions/preview_scheduled_acquisition_edit.py +224 -0
  39. docket/api/scheduled_acquisitions/set_planning_status.py +199 -0
  40. docket/api/scheduled_acquisitions/start_run.py +221 -0
  41. docket/api/scheduled_acquisitions/update_scheduled_acquisition.py +226 -0
  42. docket/api/settings/__init__.py +1 -0
  43. docket/api/settings/list_behavior_curriculum_discriminator_values.py +188 -0
  44. docket/api/settings/list_behavior_curriculum_settings.py +199 -0
  45. docket/api/settings/list_fip_discriminator_values.py +184 -0
  46. docket/api/settings/list_fip_settings.py +195 -0
  47. docket/api/settings/list_live_behavior_curriculum_settings.py +233 -0
  48. docket/api/settings/list_live_fip_settings.py +225 -0
  49. docket/api/settings/preview_behavior_curriculum_settings.py +191 -0
  50. docket/api/settings/preview_fip_settings.py +177 -0
  51. docket/api/settings/push_behavior_curriculum_settings.py +222 -0
  52. docket/api/settings/push_fip_settings.py +206 -0
  53. docket/api/templates/__init__.py +1 -0
  54. docket/api/templates/archive_template.py +203 -0
  55. docket/api/templates/create_template.py +206 -0
  56. docket/api/templates/get_template.py +159 -0
  57. docket/api/templates/list_templates.py +174 -0
  58. docket/api/templates/update_template.py +234 -0
  59. docket/api/vocabulary/__init__.py +1 -0
  60. docket/api/vocabulary/list_instruments.py +165 -0
  61. docket/api/vocabulary/list_persons.py +132 -0
  62. docket/api/vocabulary/list_projects.py +132 -0
  63. docket/api/vocabulary/list_subjects.py +132 -0
  64. docket/api/vocabulary/lookup_person.py +166 -0
  65. docket/api/vocabulary/lookup_subject.py +166 -0
  66. docket/api/vocabulary/sync_vocabulary.py +148 -0
  67. docket/client.py +272 -0
  68. docket/errors.py +16 -0
  69. docket/models/__init__.py +221 -0
  70. docket/models/access_role.py +12 -0
  71. docket/models/access_role_nullable_type_1.py +12 -0
  72. docket/models/access_role_nullable_type_2_type_1.py +12 -0
  73. docket/models/access_role_nullable_type_3_type_1.py +12 -0
  74. docket/models/acquisition_variable.py +72 -0
  75. docket/models/acquisition_warning.py +126 -0
  76. docket/models/add_principal_request.py +83 -0
  77. docket/models/api_token_response.py +271 -0
  78. docket/models/api_token_status.py +10 -0
  79. docket/models/archive_recurring_acquisition_request.py +77 -0
  80. docket/models/archive_recurring_acquisition_response.py +83 -0
  81. docket/models/archive_scheduled_acquisition_request.py +78 -0
  82. docket/models/archive_template_request.py +66 -0
  83. docket/models/auth_mode.py +10 -0
  84. docket/models/behavior_curriculum_push_settings_request.py +85 -0
  85. docket/models/behavior_curriculum_settings.py +55 -0
  86. docket/models/behavior_curriculum_settings_preview_response.py +102 -0
  87. docket/models/behavior_curriculum_settings_preview_response_promoted.py +50 -0
  88. docket/models/behavior_curriculum_settings_version_response.py +160 -0
  89. docket/models/behavior_curriculum_settings_version_response_promoted.py +50 -0
  90. docket/models/caller_kind.py +11 -0
  91. docket/models/create_api_token_request.py +97 -0
  92. docket/models/create_application_principal_request.py +65 -0
  93. docket/models/create_personal_api_token_request.py +167 -0
  94. docket/models/create_recurring_acquisition_request.py +269 -0
  95. docket/models/create_role_assignment_request.py +99 -0
  96. docket/models/create_scheduled_acquisition_request.py +265 -0
  97. docket/models/create_template_request.py +323 -0
  98. docket/models/created_api_token_response.py +82 -0
  99. docket/models/effective_status.py +15 -0
  100. docket/models/fip_color.py +13 -0
  101. docket/models/fip_fiber.py +93 -0
  102. docket/models/fip_mode.py +9 -0
  103. docket/models/fip_push_settings_request.py +82 -0
  104. docket/models/fip_settings.py +91 -0
  105. docket/models/fip_settings_preview_response.py +97 -0
  106. docket/models/fip_settings_preview_response_promoted.py +50 -0
  107. docket/models/fip_settings_version_response.py +155 -0
  108. docket/models/fip_settings_version_response_promoted.py +50 -0
  109. docket/models/future_occurrences.py +9 -0
  110. docket/models/get_resolved_settings_response_200.py +65 -0
  111. docket/models/held_role_response.py +116 -0
  112. docket/models/http_validation_problem_details.py +204 -0
  113. docket/models/http_validation_problem_details_errors.py +59 -0
  114. docket/models/instrument_response.py +91 -0
  115. docket/models/kept_occurrence.py +85 -0
  116. docket/models/kept_occurrence_reason.py +10 -0
  117. docket/models/list_active_acquisitions_effective_status_item.py +15 -0
  118. docket/models/list_scheduled_acquisitions_planning_status.py +11 -0
  119. docket/models/me_caller.py +118 -0
  120. docket/models/me_permission.py +98 -0
  121. docket/models/me_response.py +158 -0
  122. docket/models/me_role.py +75 -0
  123. docket/models/occurrence_preview.py +121 -0
  124. docket/models/occurrence_summary.py +126 -0
  125. docket/models/occurrence_warnings.py +91 -0
  126. docket/models/permission_action.py +12 -0
  127. docket/models/person_assignment.py +74 -0
  128. docket/models/person_lookup_response.py +131 -0
  129. docket/models/person_response.py +103 -0
  130. docket/models/person_role.py +9 -0
  131. docket/models/personal_token_limits.py +92 -0
  132. docket/models/planning_status.py +11 -0
  133. docket/models/principal_kind.py +9 -0
  134. docket/models/principal_response.py +216 -0
  135. docket/models/problem_details.py +177 -0
  136. docket/models/project_response.py +75 -0
  137. docket/models/recurrence_exception_kind.py +13 -0
  138. docket/models/recurrence_exception_response.py +103 -0
  139. docket/models/recurrence_frequency.py +9 -0
  140. docket/models/recurrence_preview_response.py +101 -0
  141. docket/models/recurrence_rule.py +207 -0
  142. docket/models/recurring_acquisition_created_response.py +123 -0
  143. docket/models/recurring_acquisition_detail_response.py +105 -0
  144. docket/models/recurring_acquisition_response.py +326 -0
  145. docket/models/report_run_outcome_request.py +89 -0
  146. docket/models/resolution_mode.py +10 -0
  147. docket/models/resolved_settings.py +73 -0
  148. docket/models/role_assignment_response.py +157 -0
  149. docket/models/role_origin.py +10 -0
  150. docket/models/role_source_kind.py +10 -0
  151. docket/models/run_origin.py +9 -0
  152. docket/models/run_outcome.py +9 -0
  153. docket/models/run_response.py +181 -0
  154. docket/models/run_settings_snapshot.py +72 -0
  155. docket/models/run_start_response.py +91 -0
  156. docket/models/run_start_response_settings.py +65 -0
  157. docket/models/run_status.py +11 -0
  158. docket/models/scheduled_acquisition_edit_preview_response.py +150 -0
  159. docket/models/scheduled_acquisition_response.py +366 -0
  160. docket/models/set_planning_status_request.py +90 -0
  161. docket/models/settings_selector.py +138 -0
  162. docket/models/skipped_occurrence.py +85 -0
  163. docket/models/split_preview_response.py +129 -0
  164. docket/models/split_recurring_acquisition_request.py +261 -0
  165. docket/models/split_recurring_acquisition_response.py +159 -0
  166. docket/models/start_run_request.py +73 -0
  167. docket/models/subject_lookup_response.py +149 -0
  168. docket/models/subject_response.py +128 -0
  169. docket/models/sync_counts.py +80 -0
  170. docket/models/sync_response.py +81 -0
  171. docket/models/template_response.py +302 -0
  172. docket/models/update_scheduled_acquisition_request.py +235 -0
  173. docket/models/update_template_request.py +279 -0
  174. docket/models/weekday.py +14 -0
  175. docket/types.py +54 -0
  176. docket_auth/__init__.py +239 -0
  177. docket_client-0.3.2.dist-info/METADATA +233 -0
  178. docket_client-0.3.2.dist-info/RECORD +188 -0
  179. docket_client-0.3.2.dist-info/WHEEL +4 -0
  180. docket_client-0.3.2.dist-info/entry_points.txt +2 -0
  181. docket_mcp/__init__.py +6 -0
  182. docket_mcp/api.py +207 -0
  183. docket_mcp/config.py +57 -0
  184. docket_mcp/server.py +78 -0
  185. docket_mcp/signin.py +155 -0
  186. docket_mcp/slots.py +80 -0
  187. docket_mcp/times.py +160 -0
  188. docket_mcp/tools.py +1202 -0
@@ -0,0 +1,279 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator
5
+
6
+ from attrs import define as _attrs_define
7
+ from attrs import field as _attrs_field
8
+
9
+ from ..types import UNSET, Unset
10
+
11
+ from ..types import UNSET, Unset
12
+ from typing import cast
13
+
14
+ if TYPE_CHECKING:
15
+ from ..models.acquisition_variable import AcquisitionVariable
16
+ from ..models.person_assignment import PersonAssignment
17
+ from ..models.settings_selector import SettingsSelector
18
+
19
+
20
+ T = TypeVar("T", bound="UpdateTemplateRequest")
21
+
22
+
23
+ @_attrs_define
24
+ class UpdateTemplateRequest:
25
+ """Change a template. Every field except `expected_version` is optional: an
26
+ omitted field is left unchanged. For the scalar defaults (`subject_id`,
27
+ `instrument_id`, `project_id`, `operator_instructions`,
28
+ `duration_minutes`, `local_start_time`) an explicit `null` clears the
29
+ default. `name`, `people`, `type_refs` and `variables` cannot be
30
+ null; send an empty list to clear a list. Lists replace the whole list when present.
31
+
32
+ Attributes:
33
+ expected_version (int): The `version` the client last read. A stale value is rejected with 409
34
+ `version_conflict`.
35
+ name (str | Unset): New name; unique among non-archived templates.
36
+ subject_id (None | str | Unset): New default subject; `null` clears it.
37
+ instrument_id (None | str | Unset): New default instrument; `null` clears it.
38
+ project_id (None | str | Unset): New default project; `null` clears it.
39
+ operator_instructions (None | str | Unset): New default operator text; `null` clears it.
40
+ people (list[PersonAssignment] | Unset): Replaces the default people.
41
+ type_refs (list[SettingsSelector] | Unset): Replaces the default type refs.
42
+ variables (list[AcquisitionVariable] | Unset): Replaces the default variables.
43
+ duration_minutes (int | None | Unset): New default duration in minutes; `null` clears it.
44
+ local_start_time (None | str | Unset):
45
+ """
46
+
47
+ expected_version: int
48
+ name: str | Unset = UNSET
49
+ subject_id: None | str | Unset = UNSET
50
+ instrument_id: None | str | Unset = UNSET
51
+ project_id: None | str | Unset = UNSET
52
+ operator_instructions: None | str | Unset = UNSET
53
+ people: list[PersonAssignment] | Unset = UNSET
54
+ type_refs: list[SettingsSelector] | Unset = UNSET
55
+ variables: list[AcquisitionVariable] | Unset = UNSET
56
+ duration_minutes: int | None | Unset = UNSET
57
+ local_start_time: None | str | Unset = UNSET
58
+ additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
59
+
60
+ def to_dict(self) -> dict[str, Any]:
61
+ from ..models.acquisition_variable import AcquisitionVariable
62
+ from ..models.person_assignment import PersonAssignment
63
+ from ..models.settings_selector import SettingsSelector
64
+
65
+ expected_version = self.expected_version
66
+
67
+ name = self.name
68
+
69
+ subject_id: None | str | Unset
70
+ if isinstance(self.subject_id, Unset):
71
+ subject_id = UNSET
72
+ else:
73
+ subject_id = self.subject_id
74
+
75
+ instrument_id: None | str | Unset
76
+ if isinstance(self.instrument_id, Unset):
77
+ instrument_id = UNSET
78
+ else:
79
+ instrument_id = self.instrument_id
80
+
81
+ project_id: None | str | Unset
82
+ if isinstance(self.project_id, Unset):
83
+ project_id = UNSET
84
+ else:
85
+ project_id = self.project_id
86
+
87
+ operator_instructions: None | str | Unset
88
+ if isinstance(self.operator_instructions, Unset):
89
+ operator_instructions = UNSET
90
+ else:
91
+ operator_instructions = self.operator_instructions
92
+
93
+ people: list[dict[str, Any]] | Unset = UNSET
94
+ if not isinstance(self.people, Unset):
95
+ people = []
96
+ for people_item_data in self.people:
97
+ people_item = people_item_data.to_dict()
98
+ people.append(people_item)
99
+
100
+ type_refs: list[dict[str, Any]] | Unset = UNSET
101
+ if not isinstance(self.type_refs, Unset):
102
+ type_refs = []
103
+ for type_refs_item_data in self.type_refs:
104
+ type_refs_item = type_refs_item_data.to_dict()
105
+ type_refs.append(type_refs_item)
106
+
107
+ variables: list[dict[str, Any]] | Unset = UNSET
108
+ if not isinstance(self.variables, Unset):
109
+ variables = []
110
+ for variables_item_data in self.variables:
111
+ variables_item = variables_item_data.to_dict()
112
+ variables.append(variables_item)
113
+
114
+ duration_minutes: int | None | Unset
115
+ if isinstance(self.duration_minutes, Unset):
116
+ duration_minutes = UNSET
117
+ else:
118
+ duration_minutes = self.duration_minutes
119
+
120
+ local_start_time: None | str | Unset
121
+ if isinstance(self.local_start_time, Unset):
122
+ local_start_time = UNSET
123
+ else:
124
+ local_start_time = self.local_start_time
125
+
126
+ field_dict: dict[str, Any] = {}
127
+ field_dict.update(self.additional_properties)
128
+ field_dict.update(
129
+ {
130
+ "expected_version": expected_version,
131
+ }
132
+ )
133
+ if name is not UNSET:
134
+ field_dict["name"] = name
135
+ if subject_id is not UNSET:
136
+ field_dict["subject_id"] = subject_id
137
+ if instrument_id is not UNSET:
138
+ field_dict["instrument_id"] = instrument_id
139
+ if project_id is not UNSET:
140
+ field_dict["project_id"] = project_id
141
+ if operator_instructions is not UNSET:
142
+ field_dict["operator_instructions"] = operator_instructions
143
+ if people is not UNSET:
144
+ field_dict["people"] = people
145
+ if type_refs is not UNSET:
146
+ field_dict["type_refs"] = type_refs
147
+ if variables is not UNSET:
148
+ field_dict["variables"] = variables
149
+ if duration_minutes is not UNSET:
150
+ field_dict["duration_minutes"] = duration_minutes
151
+ if local_start_time is not UNSET:
152
+ field_dict["local_start_time"] = local_start_time
153
+
154
+ return field_dict
155
+
156
+ @classmethod
157
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
158
+ from ..models.acquisition_variable import AcquisitionVariable
159
+ from ..models.person_assignment import PersonAssignment
160
+ from ..models.settings_selector import SettingsSelector
161
+
162
+ d = dict(src_dict)
163
+ expected_version = d.pop("expected_version")
164
+
165
+ name = d.pop("name", UNSET)
166
+
167
+ def _parse_subject_id(data: object) -> None | str | Unset:
168
+ if data is None:
169
+ return data
170
+ if isinstance(data, Unset):
171
+ return data
172
+ return cast(None | str | Unset, data)
173
+
174
+ subject_id = _parse_subject_id(d.pop("subject_id", UNSET))
175
+
176
+ def _parse_instrument_id(data: object) -> None | str | Unset:
177
+ if data is None:
178
+ return data
179
+ if isinstance(data, Unset):
180
+ return data
181
+ return cast(None | str | Unset, data)
182
+
183
+ instrument_id = _parse_instrument_id(d.pop("instrument_id", UNSET))
184
+
185
+ def _parse_project_id(data: object) -> None | str | Unset:
186
+ if data is None:
187
+ return data
188
+ if isinstance(data, Unset):
189
+ return data
190
+ return cast(None | str | Unset, data)
191
+
192
+ project_id = _parse_project_id(d.pop("project_id", UNSET))
193
+
194
+ def _parse_operator_instructions(data: object) -> None | str | Unset:
195
+ if data is None:
196
+ return data
197
+ if isinstance(data, Unset):
198
+ return data
199
+ return cast(None | str | Unset, data)
200
+
201
+ operator_instructions = _parse_operator_instructions(d.pop("operator_instructions", UNSET))
202
+
203
+ _people = d.pop("people", UNSET)
204
+ people: list[PersonAssignment] | Unset = UNSET
205
+ if _people is not UNSET:
206
+ people = []
207
+ for people_item_data in _people:
208
+ people_item = PersonAssignment.from_dict(people_item_data)
209
+
210
+ people.append(people_item)
211
+
212
+ _type_refs = d.pop("type_refs", UNSET)
213
+ type_refs: list[SettingsSelector] | Unset = UNSET
214
+ if _type_refs is not UNSET:
215
+ type_refs = []
216
+ for type_refs_item_data in _type_refs:
217
+ type_refs_item = SettingsSelector.from_dict(type_refs_item_data)
218
+
219
+ type_refs.append(type_refs_item)
220
+
221
+ _variables = d.pop("variables", UNSET)
222
+ variables: list[AcquisitionVariable] | Unset = UNSET
223
+ if _variables is not UNSET:
224
+ variables = []
225
+ for variables_item_data in _variables:
226
+ variables_item = AcquisitionVariable.from_dict(variables_item_data)
227
+
228
+ variables.append(variables_item)
229
+
230
+ def _parse_duration_minutes(data: object) -> int | None | Unset:
231
+ if data is None:
232
+ return data
233
+ if isinstance(data, Unset):
234
+ return data
235
+ return cast(int | None | Unset, data)
236
+
237
+ duration_minutes = _parse_duration_minutes(d.pop("duration_minutes", UNSET))
238
+
239
+ def _parse_local_start_time(data: object) -> None | str | Unset:
240
+ if data is None:
241
+ return data
242
+ if isinstance(data, Unset):
243
+ return data
244
+ return cast(None | str | Unset, data)
245
+
246
+ local_start_time = _parse_local_start_time(d.pop("local_start_time", UNSET))
247
+
248
+ update_template_request = cls(
249
+ expected_version=expected_version,
250
+ name=name,
251
+ subject_id=subject_id,
252
+ instrument_id=instrument_id,
253
+ project_id=project_id,
254
+ operator_instructions=operator_instructions,
255
+ people=people,
256
+ type_refs=type_refs,
257
+ variables=variables,
258
+ duration_minutes=duration_minutes,
259
+ local_start_time=local_start_time,
260
+ )
261
+
262
+ update_template_request.additional_properties = d
263
+ return update_template_request
264
+
265
+ @property
266
+ def additional_keys(self) -> list[str]:
267
+ return list(self.additional_properties.keys())
268
+
269
+ def __getitem__(self, key: str) -> Any:
270
+ return self.additional_properties[key]
271
+
272
+ def __setitem__(self, key: str, value: Any) -> None:
273
+ self.additional_properties[key] = value
274
+
275
+ def __delitem__(self, key: str) -> None:
276
+ del self.additional_properties[key]
277
+
278
+ def __contains__(self, key: str) -> bool:
279
+ return key in self.additional_properties
@@ -0,0 +1,14 @@
1
+ from enum import Enum
2
+
3
+
4
+ class Weekday(str, Enum):
5
+ FRIDAY = "friday"
6
+ MONDAY = "monday"
7
+ SATURDAY = "saturday"
8
+ SUNDAY = "sunday"
9
+ THURSDAY = "thursday"
10
+ TUESDAY = "tuesday"
11
+ WEDNESDAY = "wednesday"
12
+
13
+ def __str__(self) -> str:
14
+ return str(self.value)
docket/types.py ADDED
@@ -0,0 +1,54 @@
1
+ """Contains some shared types for properties"""
2
+
3
+ from collections.abc import Mapping, MutableMapping
4
+ from http import HTTPStatus
5
+ from typing import BinaryIO, Generic, TypeVar, Literal, IO
6
+
7
+ from attrs import define
8
+
9
+
10
+ class Unset:
11
+ def __bool__(self) -> Literal[False]:
12
+ return False
13
+
14
+
15
+ UNSET: Unset = Unset()
16
+
17
+ # The types that `httpx.Client(files=)` can accept, copied from that library.
18
+ FileContent = IO[bytes] | bytes | str
19
+ FileTypes = (
20
+ # (filename, file (or bytes), content_type)
21
+ tuple[str | None, FileContent, str | None]
22
+ # (filename, file (or bytes), content_type, headers)
23
+ | tuple[str | None, FileContent, str | None, Mapping[str, str]]
24
+ )
25
+ RequestFiles = list[tuple[str, FileTypes]]
26
+
27
+
28
+ @define
29
+ class File:
30
+ """Contains information for file uploads"""
31
+
32
+ payload: BinaryIO
33
+ file_name: str | None = None
34
+ mime_type: str | None = None
35
+
36
+ def to_tuple(self) -> FileTypes:
37
+ """Return a tuple representation that httpx will accept for multipart/form-data"""
38
+ return self.file_name, self.payload, self.mime_type
39
+
40
+
41
+ T = TypeVar("T")
42
+
43
+
44
+ @define
45
+ class Response(Generic[T]):
46
+ """A response from an endpoint"""
47
+
48
+ status_code: HTTPStatus
49
+ content: bytes
50
+ headers: MutableMapping[str, str]
51
+ parsed: T | None
52
+
53
+
54
+ __all__ = ["UNSET", "File", "FileTypes", "RequestFiles", "Response", "Unset"]
@@ -0,0 +1,239 @@
1
+ """Bearer-token providers for the docket client.
2
+
3
+ Hand-written; ``client/docket/`` is generated and never edited. A *token
4
+ provider* is any zero-argument callable returning a current access token.
5
+ [`authenticated_client`][docket_auth.authenticated_client] asks it before every request, so tokens refresh
6
+ without rebuilding the client.
7
+
8
+ Two providers use ``msal`` (install ``docket-client[auth]``):
9
+
10
+ * [`ClientCredentialsTokenProvider`][docket_auth.ClientCredentialsTokenProvider] - rigs, clabe and services: a
11
+ confidential client signing in as itself (Entra app role ``Docket.Machine``).
12
+ * [`DeviceCodeTokenProvider`][docket_auth.DeviceCodeTokenProvider] - people: device-code sign-in in a terminal,
13
+ with a token cache so the code is needed once.
14
+
15
+ Reads need no token; only mutations do. Run tokens (``/runs/{token}``) identify
16
+ a run and grant no authority.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import sys
23
+ from collections.abc import Callable, Generator
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import httpx
28
+
29
+ from docket import AuthenticatedClient
30
+
31
+ __all__ = [
32
+ "ClientCredentialsTokenProvider",
33
+ "api_token_provider",
34
+ "DeviceCodeTokenProvider",
35
+ "TokenProvider",
36
+ "authenticated_client",
37
+ "static_provider",
38
+ ]
39
+
40
+ TokenProvider = Callable[[], str]
41
+ """Returns a valid access token; called before each request."""
42
+
43
+
44
+ class AuthenticationError(RuntimeError):
45
+ """Raised when a token cannot be acquired."""
46
+
47
+
48
+ def _authority(tenant_id: str | None, authority: str | None) -> tuple[str, bool]:
49
+ """Returns (authority URL, is_generic_oidc). Entra by tenant id, or any OIDC authority."""
50
+ if authority:
51
+ return authority.rstrip("/"), True
52
+ if not tenant_id:
53
+ raise ValueError("Pass tenant_id (Microsoft Entra) or authority (another OIDC provider).")
54
+ return f"https://login.microsoftonline.com/{tenant_id}", False
55
+
56
+
57
+ def _msal_kwargs(tenant_id: str | None, authority: str | None) -> dict[str, Any]:
58
+ url, generic = _authority(tenant_id, authority)
59
+ return {"oidc_authority": url} if generic else {"authority": url}
60
+
61
+
62
+ def _result_token(result: dict[str, Any] | None) -> str:
63
+ result = result or {}
64
+ token = result.get("access_token")
65
+ if not token:
66
+ # error / error_description never contain the secret or a token.
67
+ raise AuthenticationError(
68
+ f"Could not get a token: {result.get('error')}: {result.get('error_description')}"
69
+ )
70
+ return str(token)
71
+
72
+
73
+ class ClientCredentialsTokenProvider:
74
+ """Signs in as an application (OAuth2 client credentials) with ``msal``.
75
+
76
+ Args:
77
+ client_id: The application (client) id of the rig's app registration.
78
+ client_secret: Its client secret. Keep it out of source control.
79
+ scope: What to request, normally ``"<API client id or app id URI>/.default"``.
80
+ tenant_id: The Entra tenant id. Omit when ``authority`` is given.
81
+ authority: Another OIDC authority (the dev mock server), e.g. ``http://localhost:8003/docket``.
82
+ """
83
+
84
+ def __init__(
85
+ self,
86
+ client_id: str,
87
+ client_secret: str,
88
+ scope: str,
89
+ *,
90
+ tenant_id: str | None = None,
91
+ authority: str | None = None,
92
+ ) -> None:
93
+ import msal
94
+
95
+ self._scopes = [scope]
96
+ self._app = msal.ConfidentialClientApplication(
97
+ client_id,
98
+ client_credential=client_secret,
99
+ **_msal_kwargs(tenant_id, authority),
100
+ )
101
+
102
+ def __call__(self) -> str:
103
+ # msal serves the cached token until shortly before it expires.
104
+ return _result_token(self._app.acquire_token_for_client(scopes=self._scopes))
105
+
106
+
107
+ class DeviceCodeTokenProvider:
108
+ """Signs a person in with the device-code flow (``msal`` public client).
109
+
110
+ The first call prints a URL and a code to open in any browser; after that
111
+ the cached token and its refresh token are used. Pass ``cache_path`` to keep
112
+ the cache between runs (it holds refresh tokens: protect the file).
113
+
114
+ Args:
115
+ client_id: The SPA / public-client application id registered for this API.
116
+ scope: Normally ``"<API client id or app id URI>/access_as_user"``.
117
+ tenant_id: The Entra tenant id. Omit when ``authority`` is given.
118
+ authority: Another OIDC authority (the dev mock server).
119
+ cache_path: File to persist the token cache in. Omit for in-memory.
120
+ prompt: Called with the sign-in instructions; defaults to printing them.
121
+ """
122
+
123
+ def __init__(
124
+ self,
125
+ client_id: str,
126
+ scope: str,
127
+ *,
128
+ tenant_id: str | None = None,
129
+ authority: str | None = None,
130
+ cache_path: str | Path | None = None,
131
+ prompt: Callable[[str], None] | None = None,
132
+ ) -> None:
133
+ import msal
134
+
135
+ self._scopes = [scope]
136
+ self._prompt = prompt or (lambda message: print(message, file=sys.stderr))
137
+ self._cache_path = Path(cache_path).expanduser() if cache_path else None
138
+ self._cache = msal.SerializableTokenCache()
139
+ if self._cache_path and self._cache_path.exists():
140
+ self._cache.deserialize(self._cache_path.read_text(encoding="utf-8"))
141
+ self._app = msal.PublicClientApplication(
142
+ client_id, token_cache=self._cache, **_msal_kwargs(tenant_id, authority)
143
+ )
144
+
145
+ def __call__(self) -> str:
146
+ for account in self._app.get_accounts():
147
+ result = self._app.acquire_token_silent(self._scopes, account=account)
148
+ if result and result.get("access_token"):
149
+ self._save()
150
+ return str(result["access_token"])
151
+
152
+ flow = self._app.initiate_device_flow(scopes=self._scopes)
153
+ if "user_code" not in flow:
154
+ raise AuthenticationError(
155
+ f"Could not start device sign-in: {flow.get('error_description')}"
156
+ )
157
+ self._prompt(str(flow["message"]))
158
+ token = _result_token(self._app.acquire_token_by_device_flow(flow))
159
+ self._save()
160
+ return token
161
+
162
+ def silent_token(self) -> str | None:
163
+ """A cached token if one can be had without any interaction, else None (never signs in)."""
164
+ for account in self._app.get_accounts():
165
+ result = self._app.acquire_token_silent(self._scopes, account=account)
166
+ if result and result.get("access_token"):
167
+ self._save()
168
+ return str(result["access_token"])
169
+ return None
170
+
171
+ def _save(self) -> None:
172
+ if self._cache_path and self._cache.has_state_changed:
173
+ # It holds refresh tokens: created owner-only (the mode is ignored on Windows,
174
+ # where the user profile's ACLs apply).
175
+ self._cache_path.parent.mkdir(parents=True, exist_ok=True)
176
+ descriptor = os.open(self._cache_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
177
+ with os.fdopen(descriptor, "w", encoding="utf-8") as cache_file:
178
+ cache_file.write(self._cache.serialize())
179
+ if os.name == "posix":
180
+ self._cache_path.chmod(0o600)
181
+
182
+
183
+ class _ProviderAuth(httpx.Auth):
184
+ """Sets ``Authorization: Bearer <token>`` from the provider on every request."""
185
+
186
+ def __init__(self, provider: TokenProvider) -> None:
187
+ self._provider = provider
188
+
189
+ def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
190
+ request.headers["Authorization"] = f"Bearer {self._provider()}"
191
+ yield request
192
+
193
+
194
+ def authenticated_client(
195
+ base_url: str,
196
+ token_provider: TokenProvider,
197
+ *,
198
+ verify_ssl: bool | str = True,
199
+ **kwargs: Any,
200
+ ) -> AuthenticatedClient:
201
+ """An [`docket.AuthenticatedClient`][docket.client.AuthenticatedClient] that gets its token from ``token_provider``.
202
+
203
+ Extra keyword arguments (``timeout``, ``headers``, ...) go to the client.
204
+ The provider is called for every request, so rotation and refresh need no
205
+ special handling.
206
+ """
207
+ httpx_args = dict(kwargs.pop("httpx_args", {}))
208
+ httpx_args["auth"] = _ProviderAuth(token_provider)
209
+ return AuthenticatedClient(
210
+ base_url=base_url,
211
+ token="provider", # placeholder; replaced per request by _ProviderAuth
212
+ verify_ssl=verify_ssl,
213
+ httpx_args=httpx_args,
214
+ **kwargs,
215
+ )
216
+
217
+
218
+ def api_token_provider(variable: str = "DOCKET_API_TOKEN") -> TokenProvider:
219
+ """A provider for a Docket-issued API token (``dkt_...``) read from the environment.
220
+
221
+ No Entra registration is involved: an admin issues the token in Docket's Access page and
222
+ it is set as ``DOCKET_API_TOKEN`` on the rig or script host. The variable is read on every
223
+ request, so a rotated token is picked up without a restart. The token is never printed.
224
+ """
225
+
226
+ def provider() -> str:
227
+ token = os.environ.get(variable, "").strip()
228
+ if not token:
229
+ raise AuthenticationError(
230
+ f"Set {variable} to a Docket API token (issued by a Docket admin)."
231
+ )
232
+ return token
233
+
234
+ return provider
235
+
236
+
237
+ def static_provider(token: str) -> TokenProvider:
238
+ """A provider that always returns ``token`` (tests, or a token you obtained elsewhere)."""
239
+ return lambda: token