valentina-python-client 2.7.0__tar.gz → 2.9.0__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 (84) hide show
  1. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/PKG-INFO +1 -1
  2. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/pyproject.toml +1 -1
  3. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/__init__.py +1 -1
  4. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/base.py +28 -0
  5. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/campaign_book_chapters.py +10 -2
  6. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/campaign_books.py +10 -2
  7. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/campaigns.py +10 -2
  8. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/characters.py +10 -2
  9. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/users.py +85 -2
  10. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/endpoints.py +2 -0
  11. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/users.py +4 -0
  12. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/base.py +33 -0
  13. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/campaign_book_chapters.py +10 -2
  14. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/campaign_books.py +10 -2
  15. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/campaigns.py +10 -2
  16. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/characters.py +10 -2
  17. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/users.py +99 -2
  18. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/testing/_routes.py +5 -0
  19. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/LICENSE +0 -0
  20. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/README.md +0 -0
  21. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_codegen.py +0 -0
  22. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/__init__.py +0 -0
  23. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/client.py +0 -0
  24. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/registry.py +0 -0
  25. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/__init__.py +0 -0
  26. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/_audit_params.py +0 -0
  27. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/character_autogen.py +0 -0
  28. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/character_blueprint.py +0 -0
  29. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/character_traits.py +0 -0
  30. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/companies.py +0 -0
  31. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/developers.py +0 -0
  32. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/dicerolls.py +0 -0
  33. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/dictionary.py +0 -0
  34. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/global_admin.py +0 -0
  35. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/identity.py +0 -0
  36. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/options.py +0 -0
  37. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/system.py +0 -0
  38. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/user_lookup.py +0 -0
  39. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/testing/__init__.py +0 -0
  40. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/_sync/testing/_client.py +0 -0
  41. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/client.py +0 -0
  42. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/config.py +0 -0
  43. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/constants.py +0 -0
  44. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/exceptions.py +0 -0
  45. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/__init__.py +0 -0
  46. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/audit_logs.py +0 -0
  47. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/books.py +0 -0
  48. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/campaigns.py +0 -0
  49. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/chapters.py +0 -0
  50. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/character_autogen.py +0 -0
  51. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/character_blueprint.py +0 -0
  52. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/character_trait.py +0 -0
  53. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/characters.py +0 -0
  54. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/companies.py +0 -0
  55. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/developers.py +0 -0
  56. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/diceroll.py +0 -0
  57. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/dictionary.py +0 -0
  58. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/full_sheet.py +0 -0
  59. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/global_admin.py +0 -0
  60. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/pagination.py +0 -0
  61. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/shared.py +0 -0
  62. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/system.py +0 -0
  63. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/models/user_lookup.py +0 -0
  64. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/py.typed +0 -0
  65. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/registry.py +0 -0
  66. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/__init__.py +0 -0
  67. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/_audit_params.py +0 -0
  68. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/character_autogen.py +0 -0
  69. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/character_blueprint.py +0 -0
  70. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/character_traits.py +0 -0
  71. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/companies.py +0 -0
  72. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/developers.py +0 -0
  73. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/dicerolls.py +0 -0
  74. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/dictionary.py +0 -0
  75. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/global_admin.py +0 -0
  76. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/identity.py +0 -0
  77. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/options.py +0 -0
  78. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/system.py +0 -0
  79. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/services/user_lookup.py +0 -0
  80. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/testing/__init__.py +0 -0
  81. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/testing/_client.py +0 -0
  82. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/testing/_factories.py +0 -0
  83. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/src/vclient/testing/_router.py +0 -0
  84. {valentina_python_client-2.7.0 → valentina_python_client-2.9.0}/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: 2.7.0
3
+ Version: 2.9.0
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>
@@ -16,7 +16,7 @@
16
16
  name = "valentina-python-client"
17
17
  readme = "README.md"
18
18
  requires-python = ">=3.13"
19
- version = "2.7.0"
19
+ version = "2.9.0"
20
20
 
21
21
  [project.optional-dependencies]
22
22
  testing = ["polyfactory>=3.3.0"]
@@ -116,4 +116,4 @@ __all__ = (
116
116
  "users_service",
117
117
  )
118
118
 
119
- __version__ = "2.7.0"
119
+ __version__ = "2.9.0"
@@ -592,6 +592,34 @@ class SyncBaseService:
592
592
  headers=self._build_idempotency_headers(idempotency_key),
593
593
  )
594
594
 
595
+ def _put_file(
596
+ self, path: str, *, file: tuple[str, bytes, str], idempotency_key: str | None = None
597
+ ) -> httpx.Response:
598
+ """Make a PUT request with a file upload (multipart/form-data).
599
+
600
+ Mirrors ``_post_file``: an idempotency key is sent only when supplied.
601
+
602
+ Args:
603
+ path: API endpoint path.
604
+ file: Tuple of (filename, content, content_type) for the file to upload.
605
+ idempotency_key: Optional idempotency key for safe retries.
606
+
607
+ Returns:
608
+ The HTTP response.
609
+
610
+ Raises:
611
+ ServerError: When server error occurs and max retries are exhausted.
612
+ RateLimitError: When rate limit is exceeded and max retries are exhausted.
613
+ APIError: For other API error responses.
614
+ """
615
+ filename, content, content_type = file
616
+ return self._request(
617
+ "PUT",
618
+ path,
619
+ files={"data": (filename, content, content_type)},
620
+ headers=self._build_idempotency_headers(idempotency_key),
621
+ )
622
+
595
623
  def _get_paginated(
596
624
  self,
597
625
  path: str,
@@ -406,9 +406,17 @@ class SyncChaptersService(SyncBaseService):
406
406
  def upload_asset(
407
407
  self, chapter_id: str, filename: str, content: bytes, content_type: str | None = None
408
408
  ) -> Asset:
409
- """Upload a new asset for a chapter.
409
+ """Upload a new image asset for a chapter.
410
410
 
411
- Uploads a file to S3 storage and associates it with the chapter.
411
+ Uploads an image to S3 storage and associates it with the chapter.
412
+
413
+ Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
414
+ upload (documents, audio, video, archives, SVG, or a non-image payload
415
+ mislabeled as an image) is rejected with 400 Bad Request, as are
416
+ oversized images that trip the decompression-bomb guard. The stored
417
+ ``mime_type`` is detected from the file's bytes; the ``content_type``
418
+ argument is ignored server-side and cannot change or bypass the stored
419
+ type. Uploaded assets therefore always have ``asset_type == "image"``.
412
420
 
413
421
  Args:
414
422
  chapter_id: The ID of the chapter to upload the asset for.
@@ -470,9 +470,17 @@ class SyncBooksService(SyncBaseService):
470
470
  def upload_asset(
471
471
  self, book_id: str, filename: str, content: bytes, content_type: str | None = None
472
472
  ) -> Asset:
473
- """Upload a new asset for a book.
473
+ """Upload a new image asset for a book.
474
474
 
475
- Uploads a file to S3 storage and associates it with the book.
475
+ Uploads an image to S3 storage and associates it with the book.
476
+
477
+ Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
478
+ upload (documents, audio, video, archives, SVG, or a non-image payload
479
+ mislabeled as an image) is rejected with 400 Bad Request, as are
480
+ oversized images that trip the decompression-bomb guard. The stored
481
+ ``mime_type`` is detected from the file's bytes; the ``content_type``
482
+ argument is ignored server-side and cannot change or bypass the stored
483
+ type. Uploaded assets therefore always have ``asset_type == "image"``.
476
484
 
477
485
  Args:
478
486
  book_id: The ID of the book to upload the asset for.
@@ -316,9 +316,17 @@ class SyncCampaignsService(SyncBaseService):
316
316
  def upload_asset(
317
317
  self, campaign_id: str, filename: str, content: bytes, content_type: str | None = None
318
318
  ) -> Asset:
319
- """Upload a new asset for a campaign.
319
+ """Upload a new image asset for a campaign.
320
320
 
321
- Uploads a file to S3 storage and associates it with the campaign.
321
+ Uploads an image to S3 storage and associates it with the campaign.
322
+
323
+ Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
324
+ upload (documents, audio, video, archives, SVG, or a non-image payload
325
+ mislabeled as an image) is rejected with 400 Bad Request, as are
326
+ oversized images that trip the decompression-bomb guard. The stored
327
+ ``mime_type`` is detected from the file's bytes; the ``content_type``
328
+ argument is ignored server-side and cannot change or bypass the stored
329
+ type. Uploaded assets therefore always have ``asset_type == "image"``.
322
330
 
323
331
  Args:
324
332
  campaign_id: The ID of the campaign to upload the asset for.
@@ -499,9 +499,17 @@ class SyncCharactersService(SyncBaseService):
499
499
  def upload_asset(
500
500
  self, character_id: str, filename: str, content: bytes, content_type: str | None = None
501
501
  ) -> Asset:
502
- """Upload a new asset for a campaign.
502
+ """Upload a new image asset for a character.
503
503
 
504
- Uploads a file to S3 storage and associates it with the campaign.
504
+ Uploads an image to S3 storage and associates it with the character.
505
+
506
+ Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
507
+ upload (documents, audio, video, archives, SVG, or a non-image payload
508
+ mislabeled as an image) is rejected with 400 Bad Request, as are
509
+ oversized images that trip the decompression-bomb guard. The stored
510
+ ``mime_type`` is detected from the file's bytes; the ``content_type``
511
+ argument is ignored server-side and cannot change or bypass the stored
512
+ type. Uploaded assets therefore always have ``asset_type == "image"``.
505
513
 
506
514
  Args:
507
515
  character_id: The ID of the character to upload the asset for.
@@ -208,6 +208,37 @@ class SyncUsersService(SyncBaseService):
208
208
  )
209
209
  return User.model_validate(response.json())
210
210
 
211
+ def unlink_identity(self, user_id: str, *, provider: IdentityProvider) -> User:
212
+ """Disconnect a verified provider identity from a user.
213
+
214
+ The counterpart to link_identity for "disconnect your account" settings
215
+ flows: remove a single OAuth provider identity so it can no longer be
216
+ used to log in. Only the acting user themselves or a company ADMIN may
217
+ unlink identities. The provider's profile field on the returned User is
218
+ cleared to None.
219
+
220
+ The final remaining identity cannot be removed, since that would leave
221
+ the account unable to authenticate. Link another provider first.
222
+
223
+ Args:
224
+ user_id: The ID of the user losing the identity.
225
+ provider: The identity provider to disconnect.
226
+
227
+ Returns:
228
+ The updated User object with the provider's profile field cleared.
229
+
230
+ Raises:
231
+ AuthorizationError: If the acting user is neither the target user nor an admin.
232
+ NotFoundError: If the user has no identity from this provider
233
+ (code IDENTITY_NOT_LINKED).
234
+ ConflictError: If the provider is the user's only linked identity,
235
+ which cannot be removed (code LAST_IDENTITY).
236
+ """
237
+ response = self._delete(
238
+ self._format_endpoint(Endpoints.USER_IDENTITY, user_id=user_id, provider=provider)
239
+ )
240
+ return User.model_validate(response.json())
241
+
211
242
  def get_page(
212
243
  self,
213
244
  *,
@@ -526,9 +557,17 @@ class SyncUsersService(SyncBaseService):
526
557
  def upload_asset(
527
558
  self, user_id: str, filename: str, content: bytes, content_type: str | None = None
528
559
  ) -> Asset:
529
- """Upload a new asset for a user.
560
+ """Upload a new image asset for a user.
561
+
562
+ Uploads an image to S3 storage and associates it with the user.
530
563
 
531
- Uploads a file to S3 storage and associates it with the user.
564
+ Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
565
+ upload (documents, audio, video, archives, SVG, or a non-image payload
566
+ mislabeled as an image) is rejected with 400 Bad Request, as are
567
+ oversized images that trip the decompression-bomb guard. The stored
568
+ ``mime_type`` is detected from the file's bytes; the ``content_type``
569
+ argument is ignored server-side and cannot change or bypass the stored
570
+ type. Uploaded assets therefore always have ``asset_type == "image"``.
532
571
 
533
572
  Args:
534
573
  user_id: The ID of the user to upload the asset for.
@@ -552,6 +591,50 @@ class SyncUsersService(SyncBaseService):
552
591
  )
553
592
  return Asset.model_validate(response.json())
554
593
 
594
+ def upload_avatar(
595
+ self, user_id: str, filename: str, content: bytes, content_type: str | None = None
596
+ ) -> User:
597
+ """Upload a custom avatar for a user, replacing any existing one.
598
+
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).
603
+
604
+ Args:
605
+ user_id: The ID of the user to set the avatar for.
606
+ filename: The original filename of the image.
607
+ content: The raw bytes of the image to upload.
608
+ content_type: The MIME type. If not provided, inferred from filename.
609
+
610
+ Returns:
611
+ The updated User object with the new ``avatar_url``.
612
+ """
613
+ if content_type is None:
614
+ content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
615
+ response = self._put_file(
616
+ self._format_endpoint(Endpoints.USER_AVATAR, user_id=user_id),
617
+ file=(filename, content, content_type),
618
+ )
619
+ return User.model_validate(response.json())
620
+
621
+ def delete_avatar(self, user_id: str) -> User:
622
+ """Remove a user's custom avatar.
623
+
624
+ The avatar falls back to the identity-provider avatar, or ``None`` if
625
+ none exists. Requires the ``On-Behalf-Of`` header (permitted for the
626
+ user themselves or an admin). Responds 200 OK with the updated user
627
+ body (not 204).
628
+
629
+ Args:
630
+ user_id: The ID of the user whose custom avatar to remove.
631
+
632
+ Returns:
633
+ The updated User object with the resolved ``avatar_url``.
634
+ """
635
+ response = self._delete(self._format_endpoint(Endpoints.USER_AVATAR, user_id=user_id))
636
+ return User.model_validate(response.json())
637
+
555
638
  def get_experience(self, user_id: str, campaign_id: str) -> CampaignExperience:
556
639
  """Retrieve a user's experience points and cool points for a specific campaign.
557
640
 
@@ -49,12 +49,14 @@ class Endpoints:
49
49
  USER = f"{USERS}/{{user_id}}"
50
50
  USER_MERGE = f"{USERS}/merge"
51
51
  USER_IDENTITIES = f"{USER}/identities"
52
+ USER_IDENTITY = f"{USER_IDENTITIES}/{{provider}}"
52
53
  USER_APPROVE = f"{USER}/approve"
53
54
  USER_DENY = f"{USER}/deny"
54
55
  USER_STATISTICS = f"{USER}/statistics"
55
56
  USER_ASSETS = f"{USER}/assets"
56
57
  USER_ASSET = f"{USER_ASSETS}/{{asset_id}}"
57
58
  USER_ASSET_UPLOAD = f"{USER_ASSETS}/upload"
59
+ USER_AVATAR = f"{USER}/avatar"
58
60
  USER_EXPERIENCE_CAMPAIGN = f"{USER}/experience/{{campaign_id}}"
59
61
  USER_EXPERIENCE_XP_ADD = f"{USER}/experience/xp/add"
60
62
  USER_EXPERIENCE_XP_REMOVE = f"{USER}/experience/xp/remove"
@@ -101,6 +101,10 @@ class User(BaseModel):
101
101
  google_profile: GoogleProfile | None = None
102
102
  github_profile: GitHubProfile | None = None
103
103
  apple_profile: AppleProfile | None = None
104
+ avatar_url: str | None = None
105
+ """Resolved avatar URL. Precedence: custom uploaded avatar (CloudFront URL),
106
+ then the identity-provider-derived avatar (currently Discord), else ``None``.
107
+ Prefer this over ``discord_profile.avatar_url`` for display."""
104
108
  campaign_experience: list[CampaignExperience] = Field(default_factory=list)
105
109
  asset_ids: list[str] = Field(default_factory=list)
106
110
  lifetime_xp: int = 0
@@ -667,6 +667,39 @@ class BaseService:
667
667
  headers=self._build_idempotency_headers(idempotency_key),
668
668
  )
669
669
 
670
+ async def _put_file(
671
+ self,
672
+ path: str,
673
+ *,
674
+ file: tuple[str, bytes, str],
675
+ idempotency_key: str | None = None,
676
+ ) -> httpx.Response:
677
+ """Make a PUT request with a file upload (multipart/form-data).
678
+
679
+ Mirrors ``_post_file``: an idempotency key is sent only when supplied.
680
+
681
+ Args:
682
+ path: API endpoint path.
683
+ file: Tuple of (filename, content, content_type) for the file to upload.
684
+ idempotency_key: Optional idempotency key for safe retries.
685
+
686
+ Returns:
687
+ The HTTP response.
688
+
689
+ Raises:
690
+ ServerError: When server error occurs and max retries are exhausted.
691
+ RateLimitError: When rate limit is exceeded and max retries are exhausted.
692
+ APIError: For other API error responses.
693
+ """
694
+ filename, content, content_type = file
695
+
696
+ return await self._request(
697
+ "PUT",
698
+ path,
699
+ files={"data": (filename, content, content_type)},
700
+ headers=self._build_idempotency_headers(idempotency_key),
701
+ )
702
+
670
703
  # -------------------------------------------------------------------------
671
704
  # Pagination Methods
672
705
  # -------------------------------------------------------------------------
@@ -486,9 +486,17 @@ class ChaptersService(BaseService):
486
486
  content: bytes,
487
487
  content_type: str | None = None,
488
488
  ) -> Asset:
489
- """Upload a new asset for a chapter.
489
+ """Upload a new image asset for a chapter.
490
490
 
491
- Uploads a file to S3 storage and associates it with the chapter.
491
+ Uploads an image to S3 storage and associates it with the chapter.
492
+
493
+ Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
494
+ upload (documents, audio, video, archives, SVG, or a non-image payload
495
+ mislabeled as an image) is rejected with 400 Bad Request, as are
496
+ oversized images that trip the decompression-bomb guard. The stored
497
+ ``mime_type`` is detected from the file's bytes; the ``content_type``
498
+ argument is ignored server-side and cannot change or bypass the stored
499
+ type. Uploaded assets therefore always have ``asset_type == "image"``.
492
500
 
493
501
  Args:
494
502
  chapter_id: The ID of the chapter to upload the asset for.
@@ -562,9 +562,17 @@ class BooksService(BaseService):
562
562
  content: bytes,
563
563
  content_type: str | None = None,
564
564
  ) -> Asset:
565
- """Upload a new asset for a book.
565
+ """Upload a new image asset for a book.
566
566
 
567
- Uploads a file to S3 storage and associates it with the book.
567
+ Uploads an image to S3 storage and associates it with the book.
568
+
569
+ Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
570
+ upload (documents, audio, video, archives, SVG, or a non-image payload
571
+ mislabeled as an image) is rejected with 400 Bad Request, as are
572
+ oversized images that trip the decompression-bomb guard. The stored
573
+ ``mime_type`` is detected from the file's bytes; the ``content_type``
574
+ argument is ignored server-side and cannot change or bypass the stored
575
+ type. Uploaded assets therefore always have ``asset_type == "image"``.
568
576
 
569
577
  Args:
570
578
  book_id: The ID of the book to upload the asset for.
@@ -380,9 +380,17 @@ class CampaignsService(BaseService):
380
380
  content: bytes,
381
381
  content_type: str | None = None,
382
382
  ) -> Asset:
383
- """Upload a new asset for a campaign.
383
+ """Upload a new image asset for a campaign.
384
384
 
385
- Uploads a file to S3 storage and associates it with the campaign.
385
+ Uploads an image to S3 storage and associates it with the campaign.
386
+
387
+ Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
388
+ upload (documents, audio, video, archives, SVG, or a non-image payload
389
+ mislabeled as an image) is rejected with 400 Bad Request, as are
390
+ oversized images that trip the decompression-bomb guard. The stored
391
+ ``mime_type`` is detected from the file's bytes; the ``content_type``
392
+ argument is ignored server-side and cannot change or bypass the stored
393
+ type. Uploaded assets therefore always have ``asset_type == "image"``.
386
394
 
387
395
  Args:
388
396
  campaign_id: The ID of the campaign to upload the asset for.
@@ -555,9 +555,17 @@ class CharactersService(BaseService):
555
555
  content: bytes,
556
556
  content_type: str | None = None,
557
557
  ) -> Asset:
558
- """Upload a new asset for a campaign.
558
+ """Upload a new image asset for a character.
559
559
 
560
- Uploads a file to S3 storage and associates it with the campaign.
560
+ Uploads an image to S3 storage and associates it with the character.
561
+
562
+ Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
563
+ upload (documents, audio, video, archives, SVG, or a non-image payload
564
+ mislabeled as an image) is rejected with 400 Bad Request, as are
565
+ oversized images that trip the decompression-bomb guard. The stored
566
+ ``mime_type`` is detected from the file's bytes; the ``content_type``
567
+ argument is ignored server-side and cannot change or bypass the stored
568
+ type. Uploaded assets therefore always have ``asset_type == "image"``.
561
569
 
562
570
  Args:
563
571
  character_id: The ID of the character to upload the asset for.
@@ -242,6 +242,42 @@ class UsersService(BaseService):
242
242
  )
243
243
  return User.model_validate(response.json())
244
244
 
245
+ async def unlink_identity(
246
+ self,
247
+ user_id: str,
248
+ *,
249
+ provider: IdentityProvider,
250
+ ) -> User:
251
+ """Disconnect a verified provider identity from a user.
252
+
253
+ The counterpart to link_identity for "disconnect your account" settings
254
+ flows: remove a single OAuth provider identity so it can no longer be
255
+ used to log in. Only the acting user themselves or a company ADMIN may
256
+ unlink identities. The provider's profile field on the returned User is
257
+ cleared to None.
258
+
259
+ The final remaining identity cannot be removed, since that would leave
260
+ the account unable to authenticate. Link another provider first.
261
+
262
+ Args:
263
+ user_id: The ID of the user losing the identity.
264
+ provider: The identity provider to disconnect.
265
+
266
+ Returns:
267
+ The updated User object with the provider's profile field cleared.
268
+
269
+ Raises:
270
+ AuthorizationError: If the acting user is neither the target user nor an admin.
271
+ NotFoundError: If the user has no identity from this provider
272
+ (code IDENTITY_NOT_LINKED).
273
+ ConflictError: If the provider is the user's only linked identity,
274
+ which cannot be removed (code LAST_IDENTITY).
275
+ """
276
+ response = await self._delete(
277
+ self._format_endpoint(Endpoints.USER_IDENTITY, user_id=user_id, provider=provider),
278
+ )
279
+ return User.model_validate(response.json())
280
+
245
281
  # User CRUD Methods
246
282
 
247
283
  async def get_page(
@@ -620,9 +656,17 @@ class UsersService(BaseService):
620
656
  content: bytes,
621
657
  content_type: str | None = None,
622
658
  ) -> Asset:
623
- """Upload a new asset for a user.
659
+ """Upload a new image asset for a user.
624
660
 
625
- Uploads a file to S3 storage and associates it with the user.
661
+ Uploads an image to S3 storage and associates it with the user.
662
+
663
+ Only image files are accepted: PNG, JPEG, GIF, and WEBP. Any other
664
+ upload (documents, audio, video, archives, SVG, or a non-image payload
665
+ mislabeled as an image) is rejected with 400 Bad Request, as are
666
+ oversized images that trip the decompression-bomb guard. The stored
667
+ ``mime_type`` is detected from the file's bytes; the ``content_type``
668
+ argument is ignored server-side and cannot change or bypass the stored
669
+ type. Uploaded assets therefore always have ``asset_type == "image"``.
626
670
 
627
671
  Args:
628
672
  user_id: The ID of the user to upload the asset for.
@@ -647,6 +691,59 @@ class UsersService(BaseService):
647
691
  )
648
692
  return Asset.model_validate(response.json())
649
693
 
694
+ # Avatar Methods
695
+
696
+ async def upload_avatar(
697
+ self,
698
+ user_id: str,
699
+ filename: str,
700
+ content: bytes,
701
+ content_type: str | None = None,
702
+ ) -> User:
703
+ """Upload a custom avatar for a user, replacing any existing one.
704
+
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).
709
+
710
+ Args:
711
+ user_id: The ID of the user to set the avatar for.
712
+ filename: The original filename of the image.
713
+ content: The raw bytes of the image to upload.
714
+ content_type: The MIME type. If not provided, inferred from filename.
715
+
716
+ Returns:
717
+ The updated User object with the new ``avatar_url``.
718
+ """
719
+ if content_type is None:
720
+ content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
721
+
722
+ response = await self._put_file(
723
+ self._format_endpoint(Endpoints.USER_AVATAR, user_id=user_id),
724
+ file=(filename, content, content_type),
725
+ )
726
+ return User.model_validate(response.json())
727
+
728
+ async def delete_avatar(self, user_id: str) -> User:
729
+ """Remove a user's custom avatar.
730
+
731
+ The avatar falls back to the identity-provider avatar, or ``None`` if
732
+ none exists. Requires the ``On-Behalf-Of`` header (permitted for the
733
+ user themselves or an admin). Responds 200 OK with the updated user
734
+ body (not 204).
735
+
736
+ Args:
737
+ user_id: The ID of the user whose custom avatar to remove.
738
+
739
+ Returns:
740
+ The updated User object with the resolved ``avatar_url``.
741
+ """
742
+ response = await self._delete(
743
+ self._format_endpoint(Endpoints.USER_AVATAR, user_id=user_id),
744
+ )
745
+ return User.model_validate(response.json())
746
+
650
747
  # Experience Methods
651
748
 
652
749
  async def get_experience(
@@ -149,6 +149,7 @@ class Routes:
149
149
  USERS_DENY = RouteSpec("POST", Endpoints.USER_DENY, NO_CONTENT, None)
150
150
  USERS_MERGE = RouteSpec("POST", Endpoints.USER_MERGE, SINGLE, User)
151
151
  USERS_IDENTITY_LINK = RouteSpec("POST", Endpoints.USER_IDENTITIES, SINGLE, User)
152
+ USERS_IDENTITY_UNLINK = RouteSpec("DELETE", Endpoints.USER_IDENTITY, SINGLE, User)
152
153
 
153
154
  USERS_STATISTICS = RouteSpec("GET", Endpoints.USER_STATISTICS, SINGLE, RollStatistics)
154
155
  # User lookup (cross-company)
@@ -163,6 +164,10 @@ class Routes:
163
164
  USERS_ASSETS_DELETE = RouteSpec("DELETE", Endpoints.USER_ASSET, NO_CONTENT, None)
164
165
  USERS_ASSETS_UPLOAD = RouteSpec("POST", Endpoints.USER_ASSET_UPLOAD, SINGLE, Asset)
165
166
 
167
+ # User avatar
168
+ USERS_AVATAR_UPLOAD = RouteSpec("PUT", Endpoints.USER_AVATAR, SINGLE, User)
169
+ USERS_AVATAR_DELETE = RouteSpec("DELETE", Endpoints.USER_AVATAR, SINGLE, User)
170
+
166
171
  # User experience
167
172
  USERS_EXPERIENCE_GET = RouteSpec(
168
173
  "GET", Endpoints.USER_EXPERIENCE_CAMPAIGN, SINGLE, CampaignExperience