valentina-python-client 3.5.0__tar.gz → 3.5.1__tar.gz

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 (85) hide show
  1. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/PKG-INFO +1 -1
  2. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/pyproject.toml +1 -1
  3. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/pyproject.toml.orig +1 -1
  4. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/__init__.py +1 -1
  5. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/client.py +9 -0
  6. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/base.py +8 -7
  7. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/campaign_book_chapters.py +7 -3
  8. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/campaign_books.py +5 -2
  9. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/campaigns.py +5 -2
  10. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/characters.py +2 -2
  11. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/developers.py +7 -3
  12. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/identity.py +7 -2
  13. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/users.py +19 -14
  14. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/client.py +9 -0
  15. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/users.py +1 -1
  16. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/base.py +8 -11
  17. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/campaign_book_chapters.py +7 -3
  18. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/campaign_books.py +5 -2
  19. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/campaigns.py +5 -2
  20. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/characters.py +2 -2
  21. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/developers.py +7 -3
  22. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/identity.py +7 -2
  23. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/users.py +19 -14
  24. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/LICENSE +0 -0
  25. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/README.md +0 -0
  26. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_codegen.py +0 -0
  27. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/__init__.py +0 -0
  28. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/registry.py +0 -0
  29. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/__init__.py +0 -0
  30. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/_audit_params.py +0 -0
  31. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/character_autogen.py +0 -0
  32. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/character_blueprint.py +0 -0
  33. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/character_traits.py +0 -0
  34. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/companies.py +0 -0
  35. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/dicerolls.py +0 -0
  36. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/dictionary.py +0 -0
  37. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/global_admin.py +0 -0
  38. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/options.py +0 -0
  39. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/system.py +0 -0
  40. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/services/user_lookup.py +0 -0
  41. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/testing/__init__.py +0 -0
  42. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/_sync/testing/_client.py +0 -0
  43. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/config.py +0 -0
  44. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/constants.py +0 -0
  45. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/endpoints.py +0 -0
  46. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/exceptions.py +0 -0
  47. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/__init__.py +0 -0
  48. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/audit_logs.py +0 -0
  49. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/books.py +0 -0
  50. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/campaigns.py +0 -0
  51. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/chapters.py +0 -0
  52. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/character_autogen.py +0 -0
  53. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/character_blueprint.py +0 -0
  54. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/character_trait.py +0 -0
  55. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/characters.py +0 -0
  56. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/companies.py +0 -0
  57. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/developers.py +0 -0
  58. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/diceroll.py +0 -0
  59. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/dictionary.py +0 -0
  60. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/full_sheet.py +0 -0
  61. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/global_admin.py +0 -0
  62. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/pagination.py +0 -0
  63. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/shared.py +0 -0
  64. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/system.py +0 -0
  65. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/models/user_lookup.py +0 -0
  66. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/py.typed +0 -0
  67. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/registry.py +0 -0
  68. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/__init__.py +0 -0
  69. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/_audit_params.py +0 -0
  70. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/character_autogen.py +0 -0
  71. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/character_blueprint.py +0 -0
  72. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/character_traits.py +0 -0
  73. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/companies.py +0 -0
  74. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/dicerolls.py +0 -0
  75. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/dictionary.py +0 -0
  76. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/global_admin.py +0 -0
  77. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/options.py +0 -0
  78. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/system.py +0 -0
  79. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/services/user_lookup.py +0 -0
  80. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/testing/__init__.py +0 -0
  81. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/testing/_client.py +0 -0
  82. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/testing/_factories.py +0 -0
  83. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/testing/_router.py +0 -0
  84. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/testing/_routes.py +0 -0
  85. {valentina_python_client-3.5.0 → valentina_python_client-3.5.1}/src/vclient/validate_constants.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: valentina-python-client
3
- Version: 3.5.0
3
+ Version: 3.5.1
4
4
  Summary: Async Python client library for the Valentina Noir API
5
5
  Author: Nate Landau
6
6
  Author-email: Nate Landau <github@natenate.org>
@@ -15,7 +15,7 @@ description = "Async Python client library for the Valentina Noir API"
15
15
  name = "valentina-python-client"
16
16
  readme = "README.md"
17
17
  requires-python = ">=3.13"
18
- version = "3.5.0"
18
+ version = "3.5.1"
19
19
 
20
20
  [[project.authors]]
21
21
  name = "Nate Landau"
@@ -17,7 +17,7 @@
17
17
  name = "valentina-python-client"
18
18
  readme = "README.md"
19
19
  requires-python = ">=3.13"
20
- version = "3.5.0"
20
+ version = "3.5.1"
21
21
 
22
22
  [project.optional-dependencies]
23
23
  testing = ["polyfactory>=3.3.0"]
@@ -116,4 +116,4 @@ __all__ = (
116
116
  "users_service",
117
117
  )
118
118
 
119
- __version__ = "3.5.0"
119
+ __version__ = "3.5.1"
@@ -200,6 +200,15 @@ class SyncVClient:
200
200
  """Check if the client has been closed."""
201
201
  return self._http.is_closed
202
202
 
203
+ def _set_api_key(self, api_key: str) -> None:
204
+ """Send a new API key on every later request from this client and its services.
205
+
206
+ Args:
207
+ api_key: The API key to authenticate with from now on.
208
+ """
209
+ self._config.api_key = api_key
210
+ self._http.headers[API_KEY_HEADER] = api_key
211
+
203
212
  @property
204
213
  def default_company_id(self) -> str | None:
205
214
  """Return the default company ID from config."""
@@ -91,21 +91,22 @@ class SyncBaseService:
91
91
  def _calculate_backoff_delay(self, attempt: int, retry_after: int | None) -> float:
92
92
  """Calculate the delay before the next retry attempt.
93
93
 
94
- Uses exponential backoff with jitter: base_delay * (2 ** attempt) + random jitter.
95
- If retry_after is provided from the RateLimit header, uses that as the base.
94
+ Wait the server's ``retry_after`` when it gives one, since the server knows
95
+ when capacity returns. Otherwise use exponential backoff:
96
+ ``retry_delay * 2 ** attempt``. Either way, add up to 25% random jitter so
97
+ clients that were limited together do not retry together.
96
98
 
97
99
  Args:
98
100
  attempt: The current attempt number (0-indexed).
99
- retry_after: Seconds to wait from the RateLimit header, or None.
101
+ retry_after: Seconds to wait from the RateLimit or Retry-After header, or None.
100
102
 
101
103
  Returns:
102
104
  Number of seconds to wait before retrying.
103
105
  """
104
- config = self._client._config
105
- base_delay = config.retry_delay
106
106
  if retry_after is not None and retry_after > 0:
107
- base_delay = max(base_delay, float(retry_after))
108
- delay = base_delay * 2**attempt
107
+ delay = float(retry_after)
108
+ else:
109
+ delay = self._client._config.retry_delay * 2**attempt
109
110
  jitter = random.uniform(0, delay * 0.25)
110
111
  return delay + jitter
111
112
 
@@ -26,7 +26,11 @@ if TYPE_CHECKING:
26
26
 
27
27
 
28
28
  class SyncChaptersService(SyncBaseService):
29
- """Service for managing campaign book chapters within a campaign book in the Valentina API."""
29
+ """Service for managing campaign book chapters within a campaign book in the Valentina API.
30
+
31
+ Requests return 403 (``AuthorizationError``) when the ``on_behalf_of`` user is
32
+ UNAPPROVED or DEACTIVATED.
33
+ """
30
34
 
31
35
  def __init__(
32
36
  self,
@@ -413,7 +417,7 @@ class SyncChaptersService(SyncBaseService):
413
417
  Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
414
418
  upload (documents, audio, video, archives, SVG, or a non-image payload
415
419
  mislabeled as an image) is rejected with 400 Bad Request, as are
416
- oversized images that trip the decompression-bomb guard. The stored
420
+ images over 24 megapixels (width x height). The stored
417
421
  ``mime_type`` is detected from the file's bytes; the ``content_type``
418
422
  argument is ignored server-side and cannot change or bypass the stored
419
423
  type. Uploaded assets therefore always have ``asset_type == "image"``.
@@ -450,7 +454,7 @@ class SyncChaptersService(SyncBaseService):
450
454
  asset_id: The ID of the asset to delete.
451
455
 
452
456
  Raises:
453
- NotFoundError: If the asset does not exist.
457
+ NotFoundError: If the asset does not exist or belongs to a different chapter.
454
458
  AuthorizationError: If you don't have appropriate access.
455
459
  """
456
460
  self._delete(
@@ -34,6 +34,9 @@ class SyncBooksService(SyncBaseService):
34
34
  Provides methods to create, retrieve, update, and delete campaign books,
35
35
  as well as access book notes and assets.
36
36
 
37
+ Requests return 403 (``AuthorizationError``) when the ``on_behalf_of`` user is
38
+ UNAPPROVED or DEACTIVATED.
39
+
37
40
  Example:
38
41
  >>> async with SyncVClient() as client:
39
42
  ... books = client.books("company_id", "campaign_id")
@@ -477,7 +480,7 @@ class SyncBooksService(SyncBaseService):
477
480
  Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
478
481
  upload (documents, audio, video, archives, SVG, or a non-image payload
479
482
  mislabeled as an image) is rejected with 400 Bad Request, as are
480
- oversized images that trip the decompression-bomb guard. The stored
483
+ images over 24 megapixels (width x height). The stored
481
484
  ``mime_type`` is detected from the file's bytes; the ``content_type``
482
485
  argument is ignored server-side and cannot change or bypass the stored
483
486
  type. Uploaded assets therefore always have ``asset_type == "image"``.
@@ -514,7 +517,7 @@ class SyncBooksService(SyncBaseService):
514
517
  asset_id: The ID of the asset to delete.
515
518
 
516
519
  Raises:
517
- NotFoundError: If the asset does not exist.
520
+ NotFoundError: If the asset does not exist or belongs to a different book.
518
521
  AuthorizationError: If you don't have appropriate access.
519
522
  """
520
523
  self._delete(
@@ -33,6 +33,9 @@ class SyncCampaignsService(SyncBaseService):
33
33
  Provides methods to create, retrieve, update, and delete campaigns,
34
34
  as well as access campaign statistics, assets, and notes.
35
35
 
36
+ Requests return 403 (``AuthorizationError``) when the ``on_behalf_of`` user is
37
+ UNAPPROVED or DEACTIVATED.
38
+
36
39
  Example:
37
40
  >>> async with SyncVClient() as client:
38
41
  ... campaigns = client.campaigns("company_id")
@@ -308,7 +311,7 @@ class SyncCampaignsService(SyncBaseService):
308
311
  asset_id: The ID of the asset to delete.
309
312
 
310
313
  Raises:
311
- NotFoundError: If the asset does not exist.
314
+ NotFoundError: If the asset does not exist or belongs to a different campaign.
312
315
  AuthorizationError: If you don't have appropriate access.
313
316
  """
314
317
  self._delete(
@@ -327,7 +330,7 @@ class SyncCampaignsService(SyncBaseService):
327
330
  Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
328
331
  upload (documents, audio, video, archives, SVG, or a non-image payload
329
332
  mislabeled as an image) is rejected with 400 Bad Request, as are
330
- oversized images that trip the decompression-bomb guard. The stored
333
+ images over 24 megapixels (width x height). The stored
331
334
  ``mime_type`` is detected from the file's bytes; the ``content_type``
332
335
  argument is ignored server-side and cannot change or bypass the stored
333
336
  type. Uploaded assets therefore always have ``asset_type == "image"``.
@@ -495,7 +495,7 @@ class SyncCharactersService(SyncBaseService):
495
495
  asset_id: The ID of the asset to delete.
496
496
 
497
497
  Raises:
498
- NotFoundError: If the asset does not exist.
498
+ NotFoundError: If the asset does not exist or belongs to a different character.
499
499
  AuthorizationError: If you don't have appropriate access.
500
500
  """
501
501
  self._delete(
@@ -514,7 +514,7 @@ class SyncCharactersService(SyncBaseService):
514
514
  Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
515
515
  upload (documents, audio, video, archives, SVG, or a non-image payload
516
516
  mislabeled as an image) is rejected with 400 Bad Request, as are
517
- oversized images that trip the decompression-bomb guard. The stored
517
+ images over 24 megapixels (width x height). The stored
518
518
  ``mime_type`` is detected from the file's bytes; the ``content_type``
519
519
  argument is ignored server-side and cannot change or bypass the stored
520
520
  type. Uploaded assets therefore always have ``asset_type == "image"``.
@@ -64,8 +64,10 @@ class SyncDeveloperService(SyncBaseService):
64
64
  def regenerate_api_key(self) -> MeDeveloperWithApiKey:
65
65
  """Generate a new API key for the current developer.
66
66
 
67
- The current key will be immediately invalidated and all cached
68
- authentication data will be cleared.
67
+ The API invalidates the current key at once, so this client switches to
68
+ the new key for every later request, including those from services it
69
+ already created. Other clients that still hold the old key get
70
+ ``AuthenticationError`` until they are given the new one.
69
71
 
70
72
  **Be certain to save the API key from the response. It will not be displayed again.**
71
73
 
@@ -76,4 +78,6 @@ class SyncDeveloperService(SyncBaseService):
76
78
  AuthenticationError: If the current API key is invalid.
77
79
  """
78
80
  response = self._post(Endpoints.DEVELOPER_ME_NEW_KEY)
79
- return MeDeveloperWithApiKey.model_validate(response.json())
81
+ developer = MeDeveloperWithApiKey.model_validate(response.json())
82
+ self._client._set_api_key(developer.api_key)
83
+ return developer
@@ -18,7 +18,10 @@ class SyncIdentityService(SyncBaseService):
18
18
  ID token for apple/google, an OAuth access token for discord/github). The
19
19
  API verifies the credential with the provider and resolves the user in
20
20
  order: match by provider ID, auto-link by provider-verified email, or
21
- create a new UNAPPROVED user.
21
+ create a new UNAPPROVED user. Auto-link never replaces an identity: when
22
+ the email's user already holds a different identity from the same
23
+ provider, a new user is created instead. OIDC ID tokens must carry
24
+ ``exp`` and ``iat`` claims.
22
25
 
23
26
  This service does not require an acting user (no On-Behalf-Of header).
24
27
  Authentication is via developer API key only.
@@ -50,7 +53,9 @@ class SyncIdentityService(SyncBaseService):
50
53
  The returned ``resolution`` field reports which path the API took:
51
54
  ``matched`` (an existing user holds this provider identity), ``linked``
52
55
  (the identity was auto-linked onto an existing user by provider-verified
53
- email), or ``created`` (a new UNAPPROVED user was registered).
56
+ email), or ``created`` (a new UNAPPROVED user was registered). An email
57
+ match whose user already holds a different identity from the same
58
+ provider resolves to ``created``, not ``linked``.
54
59
 
55
60
  Args:
56
61
  request: A UserIdentifyDTO model, OR pass fields as keyword arguments.
@@ -547,8 +547,9 @@ class SyncUsersService(SyncBaseService):
547
547
  asset_id: The ID of the asset to delete.
548
548
 
549
549
  Raises:
550
- NotFoundError: If the asset does not exist.
551
- AuthorizationError: If you don't have appropriate access.
550
+ NotFoundError: If the asset does not exist or belongs to a different user.
551
+ AuthorizationError: If the acting user is neither ``user_id`` nor a
552
+ company admin.
552
553
  """
553
554
  self._delete(
554
555
  self._format_endpoint(Endpoints.USER_ASSET, user_id=user_id, asset_id=asset_id)
@@ -564,7 +565,7 @@ class SyncUsersService(SyncBaseService):
564
565
  Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
565
566
  upload (documents, audio, video, archives, SVG, or a non-image payload
566
567
  mislabeled as an image) is rejected with 400 Bad Request, as are
567
- oversized images that trip the decompression-bomb guard. The stored
568
+ images over 24 megapixels (width x height). The stored
568
569
  ``mime_type`` is detected from the file's bytes; the ``content_type``
569
570
  argument is ignored server-side and cannot change or bypass the stored
570
571
  type. Uploaded assets therefore always have ``asset_type == "image"``.
@@ -580,7 +581,8 @@ class SyncUsersService(SyncBaseService):
580
581
 
581
582
  Raises:
582
583
  NotFoundError: If the user does not exist.
583
- AuthorizationError: If you don't have appropriate access.
584
+ AuthorizationError: If the acting user is neither ``user_id`` nor a
585
+ company admin.
584
586
  ValidationError: If the file is invalid or exceeds size limits.
585
587
  """
586
588
  if content_type is None:
@@ -596,10 +598,10 @@ class SyncUsersService(SyncBaseService):
596
598
  ) -> User:
597
599
  """Upload a custom avatar for a user, replacing any existing one.
598
600
 
599
- Accepts PNG, JPEG, WEBP, or GIF (first frame) up to 5 MB. The server
600
- normalizes the image to a 512x512 WebP and it overrides any
601
- identity-provider-derived avatar. Requires the ``On-Behalf-Of`` header
602
- (permitted for the user themselves or an admin).
601
+ Accepts PNG, JPEG, WEBP, or GIF (first frame) up to 5 MB and 24
602
+ megapixels. The server normalizes the image to a 512x512 WebP and it
603
+ overrides any identity-provider-derived avatar. Requires the
604
+ ``On-Behalf-Of`` header (permitted for the user themselves or an admin).
603
605
 
604
606
  Args:
605
607
  user_id: The ID of the user to set the avatar for.
@@ -667,7 +669,7 @@ class SyncUsersService(SyncBaseService):
667
669
  Args:
668
670
  user_id: The ID of the user to award XP to.
669
671
  campaign_id: The ID of the campaign to add XP for.
670
- amount: The amount of XP to add.
672
+ amount: The amount of XP to add. Must be at least 1.
671
673
 
672
674
  Returns:
673
675
  Updated CampaignExperience object.
@@ -692,7 +694,7 @@ class SyncUsersService(SyncBaseService):
692
694
  Args:
693
695
  user_id: The ID of the user to remove XP from.
694
696
  campaign_id: The ID of the campaign to remove XP for.
695
- amount: The amount of XP to remove.
697
+ amount: The amount of XP to remove. Must be at least 1.
696
698
 
697
699
  Returns:
698
700
  Updated CampaignExperience object.
@@ -719,7 +721,7 @@ class SyncUsersService(SyncBaseService):
719
721
  Args:
720
722
  user_id: The ID of the user to award cool points to.
721
723
  campaign_id: The ID of the campaign to add cool points for.
722
- amount: The amount of cool points to add.
724
+ amount: The amount of cool points to add. Must be at least 1.
723
725
 
724
726
  Returns:
725
727
  Updated CampaignExperience object.
@@ -969,7 +971,8 @@ class SyncUsersService(SyncBaseService):
969
971
  The Quickroll object with full details.
970
972
 
971
973
  Raises:
972
- NotFoundError: If the quickroll does not exist.
974
+ NotFoundError: If the quickroll does not exist or does not belong to
975
+ ``user_id``.
973
976
  AuthorizationError: If you don't have access to the company.
974
977
  """
975
978
  response = self._get(
@@ -1029,7 +1032,8 @@ class SyncUsersService(SyncBaseService):
1029
1032
  The updated Quickroll object.
1030
1033
 
1031
1034
  Raises:
1032
- NotFoundError: If the quickroll does not exist.
1035
+ NotFoundError: If the quickroll does not exist or does not belong to
1036
+ ``user_id``.
1033
1037
  AuthorizationError: If you don't have appropriate access.
1034
1038
  RequestValidationError: If the input parameters fail client-side validation.
1035
1039
  ValidationError: If the request data is invalid.
@@ -1053,7 +1057,8 @@ class SyncUsersService(SyncBaseService):
1053
1057
  quickroll_id: The ID of the quickroll to delete.
1054
1058
 
1055
1059
  Raises:
1056
- NotFoundError: If the quickroll does not exist.
1060
+ NotFoundError: If the quickroll does not exist or does not belong to
1061
+ ``user_id``.
1057
1062
  AuthorizationError: If you don't have appropriate access.
1058
1063
  """
1059
1064
  self._delete(
@@ -212,6 +212,15 @@ class VClient:
212
212
  """Check if the client has been closed."""
213
213
  return self._http.is_closed
214
214
 
215
+ def _set_api_key(self, api_key: str) -> None:
216
+ """Send a new API key on every later request from this client and its services.
217
+
218
+ Args:
219
+ api_key: The API key to authenticate with from now on.
220
+ """
221
+ self._config.api_key = api_key
222
+ self._http.headers[API_KEY_HEADER] = api_key
223
+
215
224
  @property
216
225
  def default_company_id(self) -> str | None:
217
226
  """Return the default company ID from config."""
@@ -311,7 +311,7 @@ class _ExperienceAddRemove(BaseModel):
311
311
  Used to construct the JSON payload for XP/CP modifications.
312
312
  """
313
313
 
314
- amount: int
314
+ amount: int = Field(ge=1)
315
315
  campaign_id: str
316
316
 
317
317
 
@@ -91,25 +91,22 @@ class BaseService:
91
91
  def _calculate_backoff_delay(self, attempt: int, retry_after: int | None) -> float:
92
92
  """Calculate the delay before the next retry attempt.
93
93
 
94
- Uses exponential backoff with jitter: base_delay * (2 ** attempt) + random jitter.
95
- If retry_after is provided from the RateLimit header, uses that as the base.
94
+ Wait the server's ``retry_after`` when it gives one, since the server knows
95
+ when capacity returns. Otherwise use exponential backoff:
96
+ ``retry_delay * 2 ** attempt``. Either way, add up to 25% random jitter so
97
+ clients that were limited together do not retry together.
96
98
 
97
99
  Args:
98
100
  attempt: The current attempt number (0-indexed).
99
- retry_after: Seconds to wait from the RateLimit header, or None.
101
+ retry_after: Seconds to wait from the RateLimit or Retry-After header, or None.
100
102
 
101
103
  Returns:
102
104
  Number of seconds to wait before retrying.
103
105
  """
104
- config = self._client._config # noqa: SLF001
105
- base_delay = config.retry_delay
106
-
107
- # Use retry_after if provided, otherwise use configured base delay
108
106
  if retry_after is not None and retry_after > 0:
109
- base_delay = max(base_delay, float(retry_after))
110
-
111
- # Exponential backoff: base * 2^attempt
112
- delay = base_delay * (2**attempt)
107
+ delay = float(retry_after)
108
+ else:
109
+ delay = self._client._config.retry_delay * (2**attempt) # noqa: SLF001
113
110
 
114
111
  # Add jitter (up to 25% of the delay)
115
112
  jitter = random.uniform(0, delay * 0.25)
@@ -25,7 +25,11 @@ if TYPE_CHECKING:
25
25
 
26
26
 
27
27
  class ChaptersService(BaseService):
28
- """Service for managing campaign book chapters within a campaign book in the Valentina API."""
28
+ """Service for managing campaign book chapters within a campaign book in the Valentina API.
29
+
30
+ Requests return 403 (``AuthorizationError``) when the ``on_behalf_of`` user is
31
+ UNAPPROVED or DEACTIVATED.
32
+ """
29
33
 
30
34
  def __init__(
31
35
  self,
@@ -493,7 +497,7 @@ class ChaptersService(BaseService):
493
497
  Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
494
498
  upload (documents, audio, video, archives, SVG, or a non-image payload
495
499
  mislabeled as an image) is rejected with 400 Bad Request, as are
496
- oversized images that trip the decompression-bomb guard. The stored
500
+ images over 24 megapixels (width x height). The stored
497
501
  ``mime_type`` is detected from the file's bytes; the ``content_type``
498
502
  argument is ignored server-side and cannot change or bypass the stored
499
503
  type. Uploaded assets therefore always have ``asset_type == "image"``.
@@ -535,7 +539,7 @@ class ChaptersService(BaseService):
535
539
  asset_id: The ID of the asset to delete.
536
540
 
537
541
  Raises:
538
- NotFoundError: If the asset does not exist.
542
+ NotFoundError: If the asset does not exist or belongs to a different chapter.
539
543
  AuthorizationError: If you don't have appropriate access.
540
544
  """
541
545
  await self._delete(
@@ -33,6 +33,9 @@ class BooksService(BaseService):
33
33
  Provides methods to create, retrieve, update, and delete campaign books,
34
34
  as well as access book notes and assets.
35
35
 
36
+ Requests return 403 (``AuthorizationError``) when the ``on_behalf_of`` user is
37
+ UNAPPROVED or DEACTIVATED.
38
+
36
39
  Example:
37
40
  >>> async with VClient() as client:
38
41
  ... books = client.books("company_id", "campaign_id")
@@ -569,7 +572,7 @@ class BooksService(BaseService):
569
572
  Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
570
573
  upload (documents, audio, video, archives, SVG, or a non-image payload
571
574
  mislabeled as an image) is rejected with 400 Bad Request, as are
572
- oversized images that trip the decompression-bomb guard. The stored
575
+ images over 24 megapixels (width x height). The stored
573
576
  ``mime_type`` is detected from the file's bytes; the ``content_type``
574
577
  argument is ignored server-side and cannot change or bypass the stored
575
578
  type. Uploaded assets therefore always have ``asset_type == "image"``.
@@ -611,7 +614,7 @@ class BooksService(BaseService):
611
614
  asset_id: The ID of the asset to delete.
612
615
 
613
616
  Raises:
614
- NotFoundError: If the asset does not exist.
617
+ NotFoundError: If the asset does not exist or belongs to a different book.
615
618
  AuthorizationError: If you don't have appropriate access.
616
619
  """
617
620
  await self._delete(
@@ -32,6 +32,9 @@ class CampaignsService(BaseService):
32
32
  Provides methods to create, retrieve, update, and delete campaigns,
33
33
  as well as access campaign statistics, assets, and notes.
34
34
 
35
+ Requests return 403 (``AuthorizationError``) when the ``on_behalf_of`` user is
36
+ UNAPPROVED or DEACTIVATED.
37
+
35
38
  Example:
36
39
  >>> async with VClient() as client:
37
40
  ... campaigns = client.campaigns("company_id")
@@ -371,7 +374,7 @@ class CampaignsService(BaseService):
371
374
  asset_id: The ID of the asset to delete.
372
375
 
373
376
  Raises:
374
- NotFoundError: If the asset does not exist.
377
+ NotFoundError: If the asset does not exist or belongs to a different campaign.
375
378
  AuthorizationError: If you don't have appropriate access.
376
379
  """
377
380
  await self._delete(
@@ -394,7 +397,7 @@ class CampaignsService(BaseService):
394
397
  Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
395
398
  upload (documents, audio, video, archives, SVG, or a non-image payload
396
399
  mislabeled as an image) is rejected with 400 Bad Request, as are
397
- oversized images that trip the decompression-bomb guard. The stored
400
+ images over 24 megapixels (width x height). The stored
398
401
  ``mime_type`` is detected from the file's bytes; the ``content_type``
399
402
  argument is ignored server-side and cannot change or bypass the stored
400
403
  type. Uploaded assets therefore always have ``asset_type == "image"``.
@@ -550,7 +550,7 @@ class CharactersService(BaseService):
550
550
  asset_id: The ID of the asset to delete.
551
551
 
552
552
  Raises:
553
- NotFoundError: If the asset does not exist.
553
+ NotFoundError: If the asset does not exist or belongs to a different character.
554
554
  AuthorizationError: If you don't have appropriate access.
555
555
  """
556
556
  await self._delete(
@@ -573,7 +573,7 @@ class CharactersService(BaseService):
573
573
  Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
574
574
  upload (documents, audio, video, archives, SVG, or a non-image payload
575
575
  mislabeled as an image) is rejected with 400 Bad Request, as are
576
- oversized images that trip the decompression-bomb guard. The stored
576
+ images over 24 megapixels (width x height). The stored
577
577
  ``mime_type`` is detected from the file's bytes; the ``content_type``
578
578
  argument is ignored server-side and cannot change or bypass the stored
579
579
  type. Uploaded assets therefore always have ``asset_type == "image"``.
@@ -71,8 +71,10 @@ class DeveloperService(BaseService):
71
71
  async def regenerate_api_key(self) -> MeDeveloperWithApiKey:
72
72
  """Generate a new API key for the current developer.
73
73
 
74
- The current key will be immediately invalidated and all cached
75
- authentication data will be cleared.
74
+ The API invalidates the current key at once, so this client switches to
75
+ the new key for every later request, including those from services it
76
+ already created. Other clients that still hold the old key get
77
+ ``AuthenticationError`` until they are given the new one.
76
78
 
77
79
  **Be certain to save the API key from the response. It will not be displayed again.**
78
80
 
@@ -83,4 +85,6 @@ class DeveloperService(BaseService):
83
85
  AuthenticationError: If the current API key is invalid.
84
86
  """
85
87
  response = await self._post(Endpoints.DEVELOPER_ME_NEW_KEY)
86
- return MeDeveloperWithApiKey.model_validate(response.json())
88
+ developer = MeDeveloperWithApiKey.model_validate(response.json())
89
+ self._client._set_api_key(developer.api_key) # noqa: SLF001
90
+ return developer
@@ -17,7 +17,10 @@ class IdentityService(BaseService):
17
17
  ID token for apple/google, an OAuth access token for discord/github). The
18
18
  API verifies the credential with the provider and resolves the user in
19
19
  order: match by provider ID, auto-link by provider-verified email, or
20
- create a new UNAPPROVED user.
20
+ create a new UNAPPROVED user. Auto-link never replaces an identity: when
21
+ the email's user already holds a different identity from the same
22
+ provider, a new user is created instead. OIDC ID tokens must carry
23
+ ``exp`` and ``iat`` claims.
21
24
 
22
25
  This service does not require an acting user (no On-Behalf-Of header).
23
26
  Authentication is via developer API key only.
@@ -53,7 +56,9 @@ class IdentityService(BaseService):
53
56
  The returned ``resolution`` field reports which path the API took:
54
57
  ``matched`` (an existing user holds this provider identity), ``linked``
55
58
  (the identity was auto-linked onto an existing user by provider-verified
56
- email), or ``created`` (a new UNAPPROVED user was registered).
59
+ email), or ``created`` (a new UNAPPROVED user was registered). An email
60
+ match whose user already holds a different identity from the same
61
+ provider resolves to ``created``, not ``linked``.
57
62
 
58
63
  Args:
59
64
  request: A UserIdentifyDTO model, OR pass fields as keyword arguments.
@@ -642,8 +642,9 @@ class UsersService(BaseService):
642
642
  asset_id: The ID of the asset to delete.
643
643
 
644
644
  Raises:
645
- NotFoundError: If the asset does not exist.
646
- AuthorizationError: If you don't have appropriate access.
645
+ NotFoundError: If the asset does not exist or belongs to a different user.
646
+ AuthorizationError: If the acting user is neither ``user_id`` nor a
647
+ company admin.
647
648
  """
648
649
  await self._delete(
649
650
  self._format_endpoint(Endpoints.USER_ASSET, user_id=user_id, asset_id=asset_id)
@@ -663,7 +664,7 @@ class UsersService(BaseService):
663
664
  Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
664
665
  upload (documents, audio, video, archives, SVG, or a non-image payload
665
666
  mislabeled as an image) is rejected with 400 Bad Request, as are
666
- oversized images that trip the decompression-bomb guard. The stored
667
+ images over 24 megapixels (width x height). The stored
667
668
  ``mime_type`` is detected from the file's bytes; the ``content_type``
668
669
  argument is ignored server-side and cannot change or bypass the stored
669
670
  type. Uploaded assets therefore always have ``asset_type == "image"``.
@@ -679,7 +680,8 @@ class UsersService(BaseService):
679
680
 
680
681
  Raises:
681
682
  NotFoundError: If the user does not exist.
682
- AuthorizationError: If you don't have appropriate access.
683
+ AuthorizationError: If the acting user is neither ``user_id`` nor a
684
+ company admin.
683
685
  ValidationError: If the file is invalid or exceeds size limits.
684
686
  """
685
687
  if content_type is None:
@@ -702,10 +704,10 @@ class UsersService(BaseService):
702
704
  ) -> User:
703
705
  """Upload a custom avatar for a user, replacing any existing one.
704
706
 
705
- Accepts PNG, JPEG, WEBP, or GIF (first frame) up to 5 MB. The server
706
- normalizes the image to a 512x512 WebP and it overrides any
707
- identity-provider-derived avatar. Requires the ``On-Behalf-Of`` header
708
- (permitted for the user themselves or an admin).
707
+ Accepts PNG, JPEG, WEBP, or GIF (first frame) up to 5 MB and 24
708
+ megapixels. The server normalizes the image to a 512x512 WebP and it
709
+ overrides any identity-provider-derived avatar. Requires the
710
+ ``On-Behalf-Of`` header (permitted for the user themselves or an admin).
709
711
 
710
712
  Args:
711
713
  user_id: The ID of the user to set the avatar for.
@@ -787,7 +789,7 @@ class UsersService(BaseService):
787
789
  Args:
788
790
  user_id: The ID of the user to award XP to.
789
791
  campaign_id: The ID of the campaign to add XP for.
790
- amount: The amount of XP to add.
792
+ amount: The amount of XP to add. Must be at least 1.
791
793
 
792
794
  Returns:
793
795
  Updated CampaignExperience object.
@@ -821,7 +823,7 @@ class UsersService(BaseService):
821
823
  Args:
822
824
  user_id: The ID of the user to remove XP from.
823
825
  campaign_id: The ID of the campaign to remove XP for.
824
- amount: The amount of XP to remove.
826
+ amount: The amount of XP to remove. Must be at least 1.
825
827
 
826
828
  Returns:
827
829
  Updated CampaignExperience object.
@@ -857,7 +859,7 @@ class UsersService(BaseService):
857
859
  Args:
858
860
  user_id: The ID of the user to award cool points to.
859
861
  campaign_id: The ID of the campaign to add cool points for.
860
- amount: The amount of cool points to add.
862
+ amount: The amount of cool points to add. Must be at least 1.
861
863
 
862
864
  Returns:
863
865
  Updated CampaignExperience object.
@@ -1164,7 +1166,8 @@ class UsersService(BaseService):
1164
1166
  The Quickroll object with full details.
1165
1167
 
1166
1168
  Raises:
1167
- NotFoundError: If the quickroll does not exist.
1169
+ NotFoundError: If the quickroll does not exist or does not belong to
1170
+ ``user_id``.
1168
1171
  AuthorizationError: If you don't have access to the company.
1169
1172
  """
1170
1173
  response = await self._get(
@@ -1231,7 +1234,8 @@ class UsersService(BaseService):
1231
1234
  The updated Quickroll object.
1232
1235
 
1233
1236
  Raises:
1234
- NotFoundError: If the quickroll does not exist.
1237
+ NotFoundError: If the quickroll does not exist or does not belong to
1238
+ ``user_id``.
1235
1239
  AuthorizationError: If you don't have appropriate access.
1236
1240
  RequestValidationError: If the input parameters fail client-side validation.
1237
1241
  ValidationError: If the request data is invalid.
@@ -1259,7 +1263,8 @@ class UsersService(BaseService):
1259
1263
  quickroll_id: The ID of the quickroll to delete.
1260
1264
 
1261
1265
  Raises:
1262
- NotFoundError: If the quickroll does not exist.
1266
+ NotFoundError: If the quickroll does not exist or does not belong to
1267
+ ``user_id``.
1263
1268
  AuthorizationError: If you don't have appropriate access.
1264
1269
  """
1265
1270
  await self._delete(