label-studio-sdk 1.0.10__py3-none-any.whl → 1.0.12__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.

Potentially problematic release.


This version of label-studio-sdk might be problematic. Click here for more details.

Files changed (40) hide show
  1. label_studio_sdk/__init__.py +21 -1
  2. label_studio_sdk/_extensions/label_studio_tools/core/utils/json_schema.py +5 -0
  3. label_studio_sdk/base_client.py +8 -0
  4. label_studio_sdk/converter/converter.py +22 -0
  5. label_studio_sdk/converter/exports/brush_to_coco.py +332 -0
  6. label_studio_sdk/converter/main.py +14 -0
  7. label_studio_sdk/core/client_wrapper.py +34 -15
  8. label_studio_sdk/errors/__init__.py +3 -1
  9. label_studio_sdk/errors/not_found_error.py +9 -0
  10. label_studio_sdk/errors/unauthorized_error.py +9 -0
  11. label_studio_sdk/jwt_settings/__init__.py +2 -0
  12. label_studio_sdk/jwt_settings/client.py +259 -0
  13. label_studio_sdk/label_interface/control_tags.py +15 -2
  14. label_studio_sdk/label_interface/interface.py +80 -1
  15. label_studio_sdk/label_interface/object_tags.py +2 -2
  16. label_studio_sdk/projects/__init__.py +2 -1
  17. label_studio_sdk/projects/client.py +4 -0
  18. label_studio_sdk/projects/exports/client_ext.py +106 -40
  19. label_studio_sdk/projects/pauses/__init__.py +2 -0
  20. label_studio_sdk/projects/pauses/client.py +704 -0
  21. label_studio_sdk/projects/types/projects_update_response.py +10 -0
  22. label_studio_sdk/tokens/__init__.py +2 -0
  23. label_studio_sdk/tokens/client.py +610 -0
  24. label_studio_sdk/tokens/client_ext.py +94 -0
  25. label_studio_sdk/types/__init__.py +14 -0
  26. label_studio_sdk/types/access_token_response.py +22 -0
  27. label_studio_sdk/types/annotation.py +2 -1
  28. label_studio_sdk/types/annotation_completed_by.py +6 -0
  29. label_studio_sdk/types/api_token_response.py +32 -0
  30. label_studio_sdk/types/jwt_settings_response.py +32 -0
  31. label_studio_sdk/types/model_provider_connection_provider.py +1 -1
  32. label_studio_sdk/types/pause.py +34 -0
  33. label_studio_sdk/types/pause_paused_by.py +5 -0
  34. label_studio_sdk/types/project.py +10 -0
  35. label_studio_sdk/types/prompt_version_provider.py +1 -1
  36. label_studio_sdk/types/rotate_token_response.py +22 -0
  37. {label_studio_sdk-1.0.10.dist-info → label_studio_sdk-1.0.12.dist-info}/METADATA +3 -2
  38. {label_studio_sdk-1.0.10.dist-info → label_studio_sdk-1.0.12.dist-info}/RECORD +40 -23
  39. {label_studio_sdk-1.0.10.dist-info → label_studio_sdk-1.0.12.dist-info}/WHEEL +1 -1
  40. {label_studio_sdk-1.0.10.dist-info → label_studio_sdk-1.0.12.dist-info}/LICENSE +0 -0
@@ -0,0 +1,610 @@
1
+ # This file was auto-generated by Fern from our API Definition.
2
+
3
+ import typing
4
+ from ..core.client_wrapper import SyncClientWrapper
5
+ from ..core.request_options import RequestOptions
6
+ from ..errors.not_found_error import NotFoundError
7
+ from ..core.pydantic_utilities import parse_obj_as
8
+ from json.decoder import JSONDecodeError
9
+ from ..core.api_error import ApiError
10
+ from ..types.api_token_response import ApiTokenResponse
11
+ from ..types.access_token_response import AccessTokenResponse
12
+ from ..errors.unauthorized_error import UnauthorizedError
13
+ from ..types.rotate_token_response import RotateTokenResponse
14
+ from ..errors.bad_request_error import BadRequestError
15
+ from ..core.client_wrapper import AsyncClientWrapper
16
+
17
+ # this is used as the default value for optional parameters
18
+ OMIT = typing.cast(typing.Any, ...)
19
+
20
+
21
+ class TokensClient:
22
+ def __init__(self, *, client_wrapper: SyncClientWrapper):
23
+ self._client_wrapper = client_wrapper
24
+
25
+ def blacklist(self, *, refresh: str, request_options: typing.Optional[RequestOptions] = None) -> None:
26
+ """
27
+ Blacklist a refresh token to prevent its future use.
28
+
29
+ Parameters
30
+ ----------
31
+ refresh : str
32
+ JWT refresh token
33
+
34
+ request_options : typing.Optional[RequestOptions]
35
+ Request-specific configuration.
36
+
37
+ Returns
38
+ -------
39
+ None
40
+
41
+ Examples
42
+ --------
43
+ from label_studio_sdk import LabelStudio
44
+
45
+ client = LabelStudio(
46
+ api_key="YOUR_API_KEY",
47
+ )
48
+ client.tokens.blacklist(
49
+ refresh="refresh",
50
+ )
51
+ """
52
+ _response = self._client_wrapper.httpx_client.request(
53
+ "api/token/blacklist",
54
+ method="POST",
55
+ json={
56
+ "refresh": refresh,
57
+ },
58
+ headers={
59
+ "content-type": "application/json",
60
+ },
61
+ request_options=request_options,
62
+ omit=OMIT,
63
+ )
64
+ try:
65
+ if 200 <= _response.status_code < 300:
66
+ return
67
+ if _response.status_code == 404:
68
+ raise NotFoundError(
69
+ typing.cast(
70
+ typing.Optional[typing.Any],
71
+ parse_obj_as(
72
+ type_=typing.Optional[typing.Any], # type: ignore
73
+ object_=_response.json(),
74
+ ),
75
+ )
76
+ )
77
+ _response_json = _response.json()
78
+ except JSONDecodeError:
79
+ raise ApiError(status_code=_response.status_code, body=_response.text)
80
+ raise ApiError(status_code=_response.status_code, body=_response_json)
81
+
82
+ def get(self, *, request_options: typing.Optional[RequestOptions] = None) -> typing.List[ApiTokenResponse]:
83
+ """
84
+ List all API tokens for the current user.
85
+
86
+ Parameters
87
+ ----------
88
+ request_options : typing.Optional[RequestOptions]
89
+ Request-specific configuration.
90
+
91
+ Returns
92
+ -------
93
+ typing.List[ApiTokenResponse]
94
+ List of API tokens retrieved successfully
95
+
96
+ Examples
97
+ --------
98
+ from label_studio_sdk import LabelStudio
99
+
100
+ client = LabelStudio(
101
+ api_key="YOUR_API_KEY",
102
+ )
103
+ client.tokens.get()
104
+ """
105
+ _response = self._client_wrapper.httpx_client.request(
106
+ "api/token",
107
+ method="GET",
108
+ request_options=request_options,
109
+ )
110
+ try:
111
+ if 200 <= _response.status_code < 300:
112
+ return typing.cast(
113
+ typing.List[ApiTokenResponse],
114
+ parse_obj_as(
115
+ type_=typing.List[ApiTokenResponse], # type: ignore
116
+ object_=_response.json(),
117
+ ),
118
+ )
119
+ _response_json = _response.json()
120
+ except JSONDecodeError:
121
+ raise ApiError(status_code=_response.status_code, body=_response.text)
122
+ raise ApiError(status_code=_response.status_code, body=_response_json)
123
+
124
+ def create(self, *, request_options: typing.Optional[RequestOptions] = None) -> ApiTokenResponse:
125
+ """
126
+ Create a new API token for the current user.
127
+
128
+ Parameters
129
+ ----------
130
+ request_options : typing.Optional[RequestOptions]
131
+ Request-specific configuration.
132
+
133
+ Returns
134
+ -------
135
+ ApiTokenResponse
136
+ Token created successfully
137
+
138
+ Examples
139
+ --------
140
+ from label_studio_sdk import LabelStudio
141
+
142
+ client = LabelStudio(
143
+ api_key="YOUR_API_KEY",
144
+ )
145
+ client.tokens.create()
146
+ """
147
+ _response = self._client_wrapper.httpx_client.request(
148
+ "api/token",
149
+ method="POST",
150
+ request_options=request_options,
151
+ )
152
+ try:
153
+ if 200 <= _response.status_code < 300:
154
+ return typing.cast(
155
+ ApiTokenResponse,
156
+ parse_obj_as(
157
+ type_=ApiTokenResponse, # type: ignore
158
+ object_=_response.json(),
159
+ ),
160
+ )
161
+ _response_json = _response.json()
162
+ except JSONDecodeError:
163
+ raise ApiError(status_code=_response.status_code, body=_response.text)
164
+ raise ApiError(status_code=_response.status_code, body=_response_json)
165
+
166
+ def refresh(self, *, refresh: str, request_options: typing.Optional[RequestOptions] = None) -> AccessTokenResponse:
167
+ """
168
+ Get a new access token, using a refresh token.
169
+
170
+ Parameters
171
+ ----------
172
+ refresh : str
173
+ JWT refresh token
174
+
175
+ request_options : typing.Optional[RequestOptions]
176
+ Request-specific configuration.
177
+
178
+ Returns
179
+ -------
180
+ AccessTokenResponse
181
+ New access token created successfully
182
+
183
+ Examples
184
+ --------
185
+ from label_studio_sdk import LabelStudio
186
+
187
+ client = LabelStudio(
188
+ api_key="YOUR_API_KEY",
189
+ )
190
+ client.tokens.refresh(
191
+ refresh="refresh",
192
+ )
193
+ """
194
+ _response = self._client_wrapper.httpx_client.request(
195
+ "api/token/refresh",
196
+ method="POST",
197
+ json={
198
+ "refresh": refresh,
199
+ },
200
+ headers={
201
+ "content-type": "application/json",
202
+ },
203
+ request_options=request_options,
204
+ omit=OMIT,
205
+ )
206
+ try:
207
+ if 200 <= _response.status_code < 300:
208
+ return typing.cast(
209
+ AccessTokenResponse,
210
+ parse_obj_as(
211
+ type_=AccessTokenResponse, # type: ignore
212
+ object_=_response.json(),
213
+ ),
214
+ )
215
+ if _response.status_code == 401:
216
+ raise UnauthorizedError(
217
+ typing.cast(
218
+ typing.Optional[typing.Any],
219
+ parse_obj_as(
220
+ type_=typing.Optional[typing.Any], # type: ignore
221
+ object_=_response.json(),
222
+ ),
223
+ )
224
+ )
225
+ _response_json = _response.json()
226
+ except JSONDecodeError:
227
+ raise ApiError(status_code=_response.status_code, body=_response.text)
228
+ raise ApiError(status_code=_response.status_code, body=_response_json)
229
+
230
+ def rotate(self, *, refresh: str, request_options: typing.Optional[RequestOptions] = None) -> RotateTokenResponse:
231
+ """
232
+ Blacklist existing refresh token, and get a new refresh token.
233
+
234
+ Parameters
235
+ ----------
236
+ refresh : str
237
+ JWT refresh token
238
+
239
+ request_options : typing.Optional[RequestOptions]
240
+ Request-specific configuration.
241
+
242
+ Returns
243
+ -------
244
+ RotateTokenResponse
245
+ Refresh token successfully rotated
246
+
247
+ Examples
248
+ --------
249
+ from label_studio_sdk import LabelStudio
250
+
251
+ client = LabelStudio(
252
+ api_key="YOUR_API_KEY",
253
+ )
254
+ client.tokens.rotate(
255
+ refresh="refresh",
256
+ )
257
+ """
258
+ _response = self._client_wrapper.httpx_client.request(
259
+ "api/token/rotate",
260
+ method="POST",
261
+ json={
262
+ "refresh": refresh,
263
+ },
264
+ headers={
265
+ "content-type": "application/json",
266
+ },
267
+ request_options=request_options,
268
+ omit=OMIT,
269
+ )
270
+ try:
271
+ if 200 <= _response.status_code < 300:
272
+ return typing.cast(
273
+ RotateTokenResponse,
274
+ parse_obj_as(
275
+ type_=RotateTokenResponse, # type: ignore
276
+ object_=_response.json(),
277
+ ),
278
+ )
279
+ if _response.status_code == 400:
280
+ raise BadRequestError(
281
+ typing.cast(
282
+ typing.Optional[typing.Any],
283
+ parse_obj_as(
284
+ type_=typing.Optional[typing.Any], # type: ignore
285
+ object_=_response.json(),
286
+ ),
287
+ )
288
+ )
289
+ _response_json = _response.json()
290
+ except JSONDecodeError:
291
+ raise ApiError(status_code=_response.status_code, body=_response.text)
292
+ raise ApiError(status_code=_response.status_code, body=_response_json)
293
+
294
+
295
+ class AsyncTokensClient:
296
+ def __init__(self, *, client_wrapper: AsyncClientWrapper):
297
+ self._client_wrapper = client_wrapper
298
+
299
+ async def blacklist(self, *, refresh: str, request_options: typing.Optional[RequestOptions] = None) -> None:
300
+ """
301
+ Blacklist a refresh token to prevent its future use.
302
+
303
+ Parameters
304
+ ----------
305
+ refresh : str
306
+ JWT refresh token
307
+
308
+ request_options : typing.Optional[RequestOptions]
309
+ Request-specific configuration.
310
+
311
+ Returns
312
+ -------
313
+ None
314
+
315
+ Examples
316
+ --------
317
+ import asyncio
318
+
319
+ from label_studio_sdk import AsyncLabelStudio
320
+
321
+ client = AsyncLabelStudio(
322
+ api_key="YOUR_API_KEY",
323
+ )
324
+
325
+
326
+ async def main() -> None:
327
+ await client.tokens.blacklist(
328
+ refresh="refresh",
329
+ )
330
+
331
+
332
+ asyncio.run(main())
333
+ """
334
+ _response = await self._client_wrapper.httpx_client.request(
335
+ "api/token/blacklist",
336
+ method="POST",
337
+ json={
338
+ "refresh": refresh,
339
+ },
340
+ headers={
341
+ "content-type": "application/json",
342
+ },
343
+ request_options=request_options,
344
+ omit=OMIT,
345
+ )
346
+ try:
347
+ if 200 <= _response.status_code < 300:
348
+ return
349
+ if _response.status_code == 404:
350
+ raise NotFoundError(
351
+ typing.cast(
352
+ typing.Optional[typing.Any],
353
+ parse_obj_as(
354
+ type_=typing.Optional[typing.Any], # type: ignore
355
+ object_=_response.json(),
356
+ ),
357
+ )
358
+ )
359
+ _response_json = _response.json()
360
+ except JSONDecodeError:
361
+ raise ApiError(status_code=_response.status_code, body=_response.text)
362
+ raise ApiError(status_code=_response.status_code, body=_response_json)
363
+
364
+ async def get(self, *, request_options: typing.Optional[RequestOptions] = None) -> typing.List[ApiTokenResponse]:
365
+ """
366
+ List all API tokens for the current user.
367
+
368
+ Parameters
369
+ ----------
370
+ request_options : typing.Optional[RequestOptions]
371
+ Request-specific configuration.
372
+
373
+ Returns
374
+ -------
375
+ typing.List[ApiTokenResponse]
376
+ List of API tokens retrieved successfully
377
+
378
+ Examples
379
+ --------
380
+ import asyncio
381
+
382
+ from label_studio_sdk import AsyncLabelStudio
383
+
384
+ client = AsyncLabelStudio(
385
+ api_key="YOUR_API_KEY",
386
+ )
387
+
388
+
389
+ async def main() -> None:
390
+ await client.tokens.get()
391
+
392
+
393
+ asyncio.run(main())
394
+ """
395
+ _response = await self._client_wrapper.httpx_client.request(
396
+ "api/token",
397
+ method="GET",
398
+ request_options=request_options,
399
+ )
400
+ try:
401
+ if 200 <= _response.status_code < 300:
402
+ return typing.cast(
403
+ typing.List[ApiTokenResponse],
404
+ parse_obj_as(
405
+ type_=typing.List[ApiTokenResponse], # type: ignore
406
+ object_=_response.json(),
407
+ ),
408
+ )
409
+ _response_json = _response.json()
410
+ except JSONDecodeError:
411
+ raise ApiError(status_code=_response.status_code, body=_response.text)
412
+ raise ApiError(status_code=_response.status_code, body=_response_json)
413
+
414
+ async def create(self, *, request_options: typing.Optional[RequestOptions] = None) -> ApiTokenResponse:
415
+ """
416
+ Create a new API token for the current user.
417
+
418
+ Parameters
419
+ ----------
420
+ request_options : typing.Optional[RequestOptions]
421
+ Request-specific configuration.
422
+
423
+ Returns
424
+ -------
425
+ ApiTokenResponse
426
+ Token created successfully
427
+
428
+ Examples
429
+ --------
430
+ import asyncio
431
+
432
+ from label_studio_sdk import AsyncLabelStudio
433
+
434
+ client = AsyncLabelStudio(
435
+ api_key="YOUR_API_KEY",
436
+ )
437
+
438
+
439
+ async def main() -> None:
440
+ await client.tokens.create()
441
+
442
+
443
+ asyncio.run(main())
444
+ """
445
+ _response = await self._client_wrapper.httpx_client.request(
446
+ "api/token",
447
+ method="POST",
448
+ request_options=request_options,
449
+ )
450
+ try:
451
+ if 200 <= _response.status_code < 300:
452
+ return typing.cast(
453
+ ApiTokenResponse,
454
+ parse_obj_as(
455
+ type_=ApiTokenResponse, # type: ignore
456
+ object_=_response.json(),
457
+ ),
458
+ )
459
+ _response_json = _response.json()
460
+ except JSONDecodeError:
461
+ raise ApiError(status_code=_response.status_code, body=_response.text)
462
+ raise ApiError(status_code=_response.status_code, body=_response_json)
463
+
464
+ async def refresh(
465
+ self, *, refresh: str, request_options: typing.Optional[RequestOptions] = None
466
+ ) -> AccessTokenResponse:
467
+ """
468
+ Get a new access token, using a refresh token.
469
+
470
+ Parameters
471
+ ----------
472
+ refresh : str
473
+ JWT refresh token
474
+
475
+ request_options : typing.Optional[RequestOptions]
476
+ Request-specific configuration.
477
+
478
+ Returns
479
+ -------
480
+ AccessTokenResponse
481
+ New access token created successfully
482
+
483
+ Examples
484
+ --------
485
+ import asyncio
486
+
487
+ from label_studio_sdk import AsyncLabelStudio
488
+
489
+ client = AsyncLabelStudio(
490
+ api_key="YOUR_API_KEY",
491
+ )
492
+
493
+
494
+ async def main() -> None:
495
+ await client.tokens.refresh(
496
+ refresh="refresh",
497
+ )
498
+
499
+
500
+ asyncio.run(main())
501
+ """
502
+ _response = await self._client_wrapper.httpx_client.request(
503
+ "api/token/refresh",
504
+ method="POST",
505
+ json={
506
+ "refresh": refresh,
507
+ },
508
+ headers={
509
+ "content-type": "application/json",
510
+ },
511
+ request_options=request_options,
512
+ omit=OMIT,
513
+ )
514
+ try:
515
+ if 200 <= _response.status_code < 300:
516
+ return typing.cast(
517
+ AccessTokenResponse,
518
+ parse_obj_as(
519
+ type_=AccessTokenResponse, # type: ignore
520
+ object_=_response.json(),
521
+ ),
522
+ )
523
+ if _response.status_code == 401:
524
+ raise UnauthorizedError(
525
+ typing.cast(
526
+ typing.Optional[typing.Any],
527
+ parse_obj_as(
528
+ type_=typing.Optional[typing.Any], # type: ignore
529
+ object_=_response.json(),
530
+ ),
531
+ )
532
+ )
533
+ _response_json = _response.json()
534
+ except JSONDecodeError:
535
+ raise ApiError(status_code=_response.status_code, body=_response.text)
536
+ raise ApiError(status_code=_response.status_code, body=_response_json)
537
+
538
+ async def rotate(
539
+ self, *, refresh: str, request_options: typing.Optional[RequestOptions] = None
540
+ ) -> RotateTokenResponse:
541
+ """
542
+ Blacklist existing refresh token, and get a new refresh token.
543
+
544
+ Parameters
545
+ ----------
546
+ refresh : str
547
+ JWT refresh token
548
+
549
+ request_options : typing.Optional[RequestOptions]
550
+ Request-specific configuration.
551
+
552
+ Returns
553
+ -------
554
+ RotateTokenResponse
555
+ Refresh token successfully rotated
556
+
557
+ Examples
558
+ --------
559
+ import asyncio
560
+
561
+ from label_studio_sdk import AsyncLabelStudio
562
+
563
+ client = AsyncLabelStudio(
564
+ api_key="YOUR_API_KEY",
565
+ )
566
+
567
+
568
+ async def main() -> None:
569
+ await client.tokens.rotate(
570
+ refresh="refresh",
571
+ )
572
+
573
+
574
+ asyncio.run(main())
575
+ """
576
+ _response = await self._client_wrapper.httpx_client.request(
577
+ "api/token/rotate",
578
+ method="POST",
579
+ json={
580
+ "refresh": refresh,
581
+ },
582
+ headers={
583
+ "content-type": "application/json",
584
+ },
585
+ request_options=request_options,
586
+ omit=OMIT,
587
+ )
588
+ try:
589
+ if 200 <= _response.status_code < 300:
590
+ return typing.cast(
591
+ RotateTokenResponse,
592
+ parse_obj_as(
593
+ type_=RotateTokenResponse, # type: ignore
594
+ object_=_response.json(),
595
+ ),
596
+ )
597
+ if _response.status_code == 400:
598
+ raise BadRequestError(
599
+ typing.cast(
600
+ typing.Optional[typing.Any],
601
+ parse_obj_as(
602
+ type_=typing.Optional[typing.Any], # type: ignore
603
+ object_=_response.json(),
604
+ ),
605
+ )
606
+ )
607
+ _response_json = _response.json()
608
+ except JSONDecodeError:
609
+ raise ApiError(status_code=_response.status_code, body=_response.text)
610
+ raise ApiError(status_code=_response.status_code, body=_response_json)
@@ -0,0 +1,94 @@
1
+ import threading
2
+ import typing
3
+ from datetime import datetime, timezone
4
+
5
+ import httpx
6
+ import jwt
7
+
8
+ from ..core.api_error import ApiError
9
+ from ..types.access_token_response import AccessTokenResponse
10
+
11
+
12
+ class TokensClientExt:
13
+ """Client for managing authentication tokens."""
14
+
15
+ def __init__(self, base_url: str, api_key: str):
16
+ self._base_url = base_url
17
+ self._api_key = api_key
18
+ self._use_legacy_token = not self._is_valid_jwt_token(api_key, raise_if_expired=True)
19
+
20
+ # cache state for access token when using jwt-based api_key
21
+ self._access_token: typing.Optional[str] = None
22
+ self._access_token_expiration: typing.Optional[datetime] = None
23
+ # Used to keep simultaneous refresh requests from spamming refresh endpoint
24
+ self._token_refresh_lock = threading.Lock()
25
+
26
+
27
+ def _is_valid_jwt_token(self, token: str, raise_if_expired: bool = False) -> bool:
28
+ """Check if a token is a valid JWT token by attempting to decode its header and check expiration."""
29
+ try:
30
+ decoded = jwt.decode(token, options={"verify_signature": False})
31
+ except jwt.InvalidTokenError:
32
+ # presumably a lagacy token
33
+ return False
34
+ expiration = decoded.get("exp")
35
+ if expiration is None:
36
+ raise ApiError(
37
+ status_code=401,
38
+ body={"detail": "API key does not have an expiration set, and is not valid. Please obtain a new refresh token."}
39
+ )
40
+ expiration_time = datetime.fromtimestamp(expiration, timezone.utc)
41
+ if expiration_time < datetime.now(timezone.utc):
42
+ if raise_if_expired:
43
+ raise ApiError(
44
+ status_code=401,
45
+ body={"detail": "API key has expired. Please obtain a new refresh token."}
46
+ )
47
+ else:
48
+ return False
49
+ return True
50
+
51
+ def _set_access_token(self, token: str) -> None:
52
+ """Set the access token and cache its expiration time."""
53
+ try:
54
+ decoded = jwt.decode(token, options={"verify_signature": False})
55
+ expiration = decoded.get("exp")
56
+ if expiration is not None:
57
+ self._access_token_expiration = datetime.fromtimestamp(expiration, timezone.utc)
58
+ except jwt.InvalidTokenError:
59
+ pass
60
+ self._access_token = token
61
+
62
+ @property
63
+ def api_key(self) -> str:
64
+ """Get the current access token, refreshing if necessary."""
65
+ # Legacy tokens: just return the API key directly
66
+ if self._use_legacy_token:
67
+ return self._api_key
68
+
69
+ # JWT tokens: handle refresh if needed
70
+ if (not self._access_token) or (not self._is_valid_jwt_token(self._access_token)):
71
+ with self._token_refresh_lock:
72
+ # Check again after acquiring lock, in case another invocation already refreshed
73
+ if (not self._access_token) or (not self._is_valid_jwt_token(self._access_token)):
74
+ token_response = self.refresh()
75
+ self._set_access_token(token_response.access)
76
+
77
+ return self._access_token
78
+
79
+ def refresh(self) -> AccessTokenResponse:
80
+ """Refresh the access token and return the token response."""
81
+ # We don't do this often, just use a separate httpx client for simplicity here
82
+ # (avoids complicated state management and sync vs async handling)
83
+ with httpx.Client() as sync_client:
84
+ response = sync_client.request(
85
+ method="POST",
86
+ url=f"{self._base_url}/api/token/refresh/",
87
+ json={"refresh": self._api_key},
88
+ headers={"Content-Type": "application/json"},
89
+ )
90
+
91
+ if response.status_code == 200:
92
+ return AccessTokenResponse.parse_obj(response.json())
93
+ else:
94
+ raise ApiError(status_code=response.status_code, body=response.json())