valentina-python-client 2.8.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.8.0 → valentina_python_client-2.9.0}/PKG-INFO +1 -1
  2. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/pyproject.toml +1 -1
  3. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/__init__.py +1 -1
  4. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/base.py +28 -0
  5. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/campaign_book_chapters.py +10 -2
  6. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/campaign_books.py +10 -2
  7. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/campaigns.py +10 -2
  8. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/characters.py +10 -2
  9. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/users.py +54 -2
  10. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/endpoints.py +1 -0
  11. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/users.py +4 -0
  12. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/base.py +33 -0
  13. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/campaign_book_chapters.py +10 -2
  14. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/campaign_books.py +10 -2
  15. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/campaigns.py +10 -2
  16. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/characters.py +10 -2
  17. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/users.py +63 -2
  18. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/testing/_routes.py +4 -0
  19. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/LICENSE +0 -0
  20. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/README.md +0 -0
  21. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_codegen.py +0 -0
  22. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/__init__.py +0 -0
  23. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/client.py +0 -0
  24. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/registry.py +0 -0
  25. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/__init__.py +0 -0
  26. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/_audit_params.py +0 -0
  27. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/character_autogen.py +0 -0
  28. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/character_blueprint.py +0 -0
  29. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/character_traits.py +0 -0
  30. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/companies.py +0 -0
  31. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/developers.py +0 -0
  32. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/dicerolls.py +0 -0
  33. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/dictionary.py +0 -0
  34. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/global_admin.py +0 -0
  35. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/identity.py +0 -0
  36. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/options.py +0 -0
  37. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/system.py +0 -0
  38. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/services/user_lookup.py +0 -0
  39. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/testing/__init__.py +0 -0
  40. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/_sync/testing/_client.py +0 -0
  41. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/client.py +0 -0
  42. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/config.py +0 -0
  43. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/constants.py +0 -0
  44. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/exceptions.py +0 -0
  45. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/__init__.py +0 -0
  46. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/audit_logs.py +0 -0
  47. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/books.py +0 -0
  48. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/campaigns.py +0 -0
  49. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/chapters.py +0 -0
  50. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/character_autogen.py +0 -0
  51. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/character_blueprint.py +0 -0
  52. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/character_trait.py +0 -0
  53. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/characters.py +0 -0
  54. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/companies.py +0 -0
  55. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/developers.py +0 -0
  56. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/diceroll.py +0 -0
  57. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/dictionary.py +0 -0
  58. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/full_sheet.py +0 -0
  59. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/global_admin.py +0 -0
  60. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/pagination.py +0 -0
  61. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/shared.py +0 -0
  62. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/system.py +0 -0
  63. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/models/user_lookup.py +0 -0
  64. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/py.typed +0 -0
  65. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/registry.py +0 -0
  66. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/__init__.py +0 -0
  67. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/_audit_params.py +0 -0
  68. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/character_autogen.py +0 -0
  69. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/character_blueprint.py +0 -0
  70. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/character_traits.py +0 -0
  71. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/companies.py +0 -0
  72. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/developers.py +0 -0
  73. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/dicerolls.py +0 -0
  74. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/dictionary.py +0 -0
  75. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/global_admin.py +0 -0
  76. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/identity.py +0 -0
  77. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/options.py +0 -0
  78. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/system.py +0 -0
  79. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/services/user_lookup.py +0 -0
  80. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/testing/__init__.py +0 -0
  81. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/testing/_client.py +0 -0
  82. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/testing/_factories.py +0 -0
  83. {valentina_python_client-2.8.0 → valentina_python_client-2.9.0}/src/vclient/testing/_router.py +0 -0
  84. {valentina_python_client-2.8.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.8.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.8.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.8.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.
@@ -557,9 +557,17 @@ class SyncUsersService(SyncBaseService):
557
557
  def upload_asset(
558
558
  self, user_id: str, filename: str, content: bytes, content_type: str | None = None
559
559
  ) -> Asset:
560
- """Upload a new asset for a user.
560
+ """Upload a new image asset for a user.
561
561
 
562
- Uploads a file to S3 storage and associates it with the user.
562
+ Uploads an image to S3 storage and associates it with the user.
563
+
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"``.
563
571
 
564
572
  Args:
565
573
  user_id: The ID of the user to upload the asset for.
@@ -583,6 +591,50 @@ class SyncUsersService(SyncBaseService):
583
591
  )
584
592
  return Asset.model_validate(response.json())
585
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
+
586
638
  def get_experience(self, user_id: str, campaign_id: str) -> CampaignExperience:
587
639
  """Retrieve a user's experience points and cool points for a specific campaign.
588
640
 
@@ -56,6 +56,7 @@ class Endpoints:
56
56
  USER_ASSETS = f"{USER}/assets"
57
57
  USER_ASSET = f"{USER_ASSETS}/{{asset_id}}"
58
58
  USER_ASSET_UPLOAD = f"{USER_ASSETS}/upload"
59
+ USER_AVATAR = f"{USER}/avatar"
59
60
  USER_EXPERIENCE_CAMPAIGN = f"{USER}/experience/{{campaign_id}}"
60
61
  USER_EXPERIENCE_XP_ADD = f"{USER}/experience/xp/add"
61
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.
@@ -656,9 +656,17 @@ class UsersService(BaseService):
656
656
  content: bytes,
657
657
  content_type: str | None = None,
658
658
  ) -> Asset:
659
- """Upload a new asset for a user.
659
+ """Upload a new image asset for a user.
660
660
 
661
- 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"``.
662
670
 
663
671
  Args:
664
672
  user_id: The ID of the user to upload the asset for.
@@ -683,6 +691,59 @@ class UsersService(BaseService):
683
691
  )
684
692
  return Asset.model_validate(response.json())
685
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
+
686
747
  # Experience Methods
687
748
 
688
749
  async def get_experience(
@@ -164,6 +164,10 @@ class Routes:
164
164
  USERS_ASSETS_DELETE = RouteSpec("DELETE", Endpoints.USER_ASSET, NO_CONTENT, None)
165
165
  USERS_ASSETS_UPLOAD = RouteSpec("POST", Endpoints.USER_ASSET_UPLOAD, SINGLE, Asset)
166
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
+
167
171
  # User experience
168
172
  USERS_EXPERIENCE_GET = RouteSpec(
169
173
  "GET", Endpoints.USER_EXPERIENCE_CAMPAIGN, SINGLE, CampaignExperience