valentina-python-client 2.8.0__tar.gz → 3.0.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-3.0.0}/PKG-INFO +3 -3
  2. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/README.md +1 -1
  3. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/pyproject.toml +8 -6
  4. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/__init__.py +1 -1
  5. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/client.py +5 -5
  6. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/base.py +46 -18
  7. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/campaign_book_chapters.py +10 -2
  8. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/campaign_books.py +10 -2
  9. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/campaigns.py +10 -2
  10. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/characters.py +10 -2
  11. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/users.py +54 -2
  12. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/testing/_client.py +5 -5
  13. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/client.py +5 -5
  14. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/endpoints.py +1 -0
  15. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/users.py +4 -0
  16. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/base.py +51 -18
  17. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/campaign_book_chapters.py +10 -2
  18. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/campaign_books.py +10 -2
  19. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/campaigns.py +10 -2
  20. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/characters.py +10 -2
  21. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/users.py +63 -2
  22. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/testing/_client.py +5 -5
  23. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/testing/_router.py +17 -17
  24. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/testing/_routes.py +4 -0
  25. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/LICENSE +0 -0
  26. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_codegen.py +0 -0
  27. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/__init__.py +0 -0
  28. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/registry.py +0 -0
  29. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/__init__.py +0 -0
  30. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/_audit_params.py +0 -0
  31. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/character_autogen.py +0 -0
  32. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/character_blueprint.py +0 -0
  33. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/character_traits.py +0 -0
  34. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/companies.py +0 -0
  35. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/developers.py +0 -0
  36. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/dicerolls.py +0 -0
  37. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/dictionary.py +0 -0
  38. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/global_admin.py +0 -0
  39. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/identity.py +0 -0
  40. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/options.py +0 -0
  41. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/system.py +0 -0
  42. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/services/user_lookup.py +0 -0
  43. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/_sync/testing/__init__.py +0 -0
  44. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/config.py +0 -0
  45. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/constants.py +0 -0
  46. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/exceptions.py +0 -0
  47. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/__init__.py +0 -0
  48. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/audit_logs.py +0 -0
  49. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/books.py +0 -0
  50. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/campaigns.py +0 -0
  51. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/chapters.py +0 -0
  52. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/character_autogen.py +0 -0
  53. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/character_blueprint.py +0 -0
  54. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/character_trait.py +0 -0
  55. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/characters.py +0 -0
  56. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/companies.py +0 -0
  57. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/developers.py +0 -0
  58. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/diceroll.py +0 -0
  59. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/dictionary.py +0 -0
  60. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/full_sheet.py +0 -0
  61. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/global_admin.py +0 -0
  62. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/pagination.py +0 -0
  63. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/shared.py +0 -0
  64. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/system.py +0 -0
  65. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/models/user_lookup.py +0 -0
  66. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/py.typed +0 -0
  67. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/registry.py +0 -0
  68. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/__init__.py +0 -0
  69. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/_audit_params.py +0 -0
  70. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/character_autogen.py +0 -0
  71. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/character_blueprint.py +0 -0
  72. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/character_traits.py +0 -0
  73. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/companies.py +0 -0
  74. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/developers.py +0 -0
  75. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/dicerolls.py +0 -0
  76. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/dictionary.py +0 -0
  77. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/global_admin.py +0 -0
  78. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/identity.py +0 -0
  79. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/options.py +0 -0
  80. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/system.py +0 -0
  81. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/services/user_lookup.py +0 -0
  82. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/testing/__init__.py +0 -0
  83. {valentina_python_client-2.8.0 → valentina_python_client-3.0.0}/src/vclient/testing/_factories.py +0 -0
  84. {valentina_python_client-2.8.0 → valentina_python_client-3.0.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: 3.0.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 @@ License: MIT License
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Programming Language :: Python :: 3.14
18
18
  Requires-Dist: anyio>=4.13.0,<5.0.0
19
- Requires-Dist: httpx>=0.28.1
19
+ Requires-Dist: httpx2>=2.4.0
20
20
  Requires-Dist: loguru>=0.7.3
21
21
  Requires-Dist: polyfactory>=3.3.0,<4.0.0
22
22
  Requires-Dist: pydantic[email]>=2.13.4,<3.0.0
@@ -33,7 +33,7 @@ Async and sync Python client library for accessing the Valentina Noir API.
33
33
 
34
34
  ## Features
35
35
 
36
- - **Async and sync clients** - Both `VClient` (async) and `SyncVClient` (sync) built on httpx
36
+ - **Async and sync clients** - Both `VClient` (async) and `SyncVClient` (sync) built on httpx2
37
37
  - **Type-safe** - Full type hints with Pydantic models for request/response validation
38
38
  - **Convenient factory pattern** - Create a client once, access services from anywhere
39
39
  - **Automatic pagination** - Stream through large datasets with `iter_all()` or fetch everything with `list_all()`
@@ -4,7 +4,7 @@ Async and sync Python client library for accessing the Valentina Noir API.
4
4
 
5
5
  ## Features
6
6
 
7
- - **Async and sync clients** - Both `VClient` (async) and `SyncVClient` (sync) built on httpx
7
+ - **Async and sync clients** - Both `VClient` (async) and `SyncVClient` (sync) built on httpx2
8
8
  - **Type-safe** - Full type hints with Pydantic models for request/response validation
9
9
  - **Convenient factory pattern** - Create a client once, access services from anywhere
10
10
  - **Automatic pagination** - Stream through large datasets with `iter_all()` or fetch everything with `list_all()`
@@ -6,7 +6,7 @@
6
6
  ]
7
7
  dependencies = [
8
8
  "anyio>=4.13.0, <5.0.0",
9
- "httpx>=0.28.1",
9
+ "httpx2>=2.4.0",
10
10
  "loguru>=0.7.3",
11
11
  "polyfactory>=3.3.0, <4.0.0",
12
12
  "pydantic[email]>=2.13.4, <3.0.0",
@@ -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 = "3.0.0"
20
20
 
21
21
  [project.optional-dependencies]
22
22
  testing = ["polyfactory>=3.3.0"]
@@ -37,25 +37,27 @@
37
37
  "commitizen>=4.16.3",
38
38
  "coverage>=7.14.1",
39
39
  "duty>=1.9.0",
40
+ "httpx>=0.28.1",
40
41
  "prek>=0.4.4",
41
42
  "pytest-anyio>=0.0.0",
42
43
  "pytest-clarity>=1.0.1",
43
44
  "pytest-cov>=7.1.0",
44
45
  "pytest-devtools>=1.2.0",
46
+ "pytest-httpx2>=1.0.0",
45
47
  "pytest-mock>=3.15.1",
46
48
  "pytest-repeat>=0.9.4",
47
49
  "pytest-sugar>=1.1.1",
48
50
  "pytest-xdist>=3.8.0",
49
- "pytest>=9.0.3",
51
+ "pytest>=9.1.0",
50
52
  "respx>=0.23.1",
51
- "ruff>=0.15.16",
53
+ "ruff>=0.15.17",
52
54
  "shellcheck-py>=0.11.0.1",
53
- "ty>=0.0.44",
55
+ "ty>=0.0.49",
54
56
  "typos>=1.47.2",
55
57
  "vulture>=2.16",
56
58
  "yamllint>=1.38.0",
57
59
  ]
58
- docs = ["zensical>=0.0.44"]
60
+ docs = ["zensical>=0.0.45"]
59
61
 
60
62
  [tool.commitizen]
61
63
  bump_message = "bump(release): v$current_version → v$new_version"
@@ -116,4 +116,4 @@ __all__ = (
116
116
  "users_service",
117
117
  )
118
118
 
119
- __version__ = "2.8.0"
119
+ __version__ = "3.0.0"
@@ -6,7 +6,7 @@ import platform
6
6
  from types import TracebackType
7
7
  from typing import TYPE_CHECKING, Self
8
8
 
9
- import httpx
9
+ import httpx2
10
10
  from loguru import logger
11
11
 
12
12
  from vclient.config import _APIConfig
@@ -144,7 +144,7 @@ class SyncVClient:
144
144
  default_company_id=resolved_company_id,
145
145
  headers=headers or {},
146
146
  )
147
- self._http: httpx.Client = self._create_http_client()
147
+ self._http: httpx2.Client = self._create_http_client()
148
148
  self._companies: SyncCompaniesService | None = None
149
149
  self._developer: SyncDeveloperService | None = None
150
150
  self._global_admin: SyncGlobalAdminService | None = None
@@ -160,7 +160,7 @@ class SyncVClient:
160
160
  max_retries=self._config.max_retries,
161
161
  ).info("Initialize SyncVClient")
162
162
 
163
- def _create_http_client(self) -> httpx.Client:
163
+ def _create_http_client(self) -> httpx2.Client:
164
164
  """Create and configure the HTTP client."""
165
165
  from vclient import __version__
166
166
 
@@ -168,10 +168,10 @@ class SyncVClient:
168
168
  headers = {"Accept": "application/json", "User-Agent": user_agent, **self._config.headers}
169
169
  if self._config.api_key:
170
170
  headers[API_KEY_HEADER] = self._config.api_key
171
- return httpx.Client(
171
+ return httpx2.Client(
172
172
  base_url=self._config.base_url,
173
173
  headers=headers,
174
- timeout=httpx.Timeout(self._config.timeout),
174
+ timeout=httpx2.Timeout(self._config.timeout),
175
175
  )
176
176
 
177
177
  def __enter__(self) -> Self:
@@ -7,7 +7,7 @@ import uuid
7
7
  from collections.abc import Iterator
8
8
  from typing import TYPE_CHECKING, Any, TypeVar
9
9
 
10
- import httpx
10
+ import httpx2
11
11
  from loguru import logger
12
12
  from pydantic import BaseModel, ValidationError as PydanticValidationError
13
13
 
@@ -57,7 +57,7 @@ class SyncBaseService:
57
57
  self._on_behalf_of: str | None = None
58
58
 
59
59
  @property
60
- def _http(self) -> httpx.Client:
60
+ def _http(self) -> httpx2.Client:
61
61
  """Get the HTTP client from the parent SyncVClient."""
62
62
  return self._client._http
63
63
 
@@ -137,7 +137,7 @@ class SyncBaseService:
137
137
  data: dict[str, Any] | None = None,
138
138
  headers: dict[str, str] | None = None,
139
139
  files: Any | None = None,
140
- ) -> httpx.Response:
140
+ ) -> httpx2.Response:
141
141
  """Make an HTTP request with automatic retry on transient errors.
142
142
 
143
143
  Retries on rate limits (429), server errors (5xx in retry_statuses),
@@ -153,7 +153,7 @@ class SyncBaseService:
153
153
  json: JSON body data.
154
154
  data: Form data.
155
155
  headers: Additional headers to include in the request.
156
- files: Files to upload (passed through to httpx).
156
+ files: Files to upload (passed through to httpx2).
157
157
 
158
158
  Returns:
159
159
  The HTTP response.
@@ -161,8 +161,8 @@ class SyncBaseService:
161
161
  Raises:
162
162
  RateLimitError: When rate limit is exceeded and max retries are exhausted.
163
163
  ServerError: When server error occurs and max retries are exhausted.
164
- httpx.ConnectError: When connection fails and max retries are exhausted.
165
- httpx.TimeoutException: When request times out and max retries are exhausted.
164
+ httpx2.ConnectError: When connection fails and max retries are exhausted.
165
+ httpx2.TimeoutException: When request times out and max retries are exhausted.
166
166
  APIError: For other API error responses.
167
167
  """
168
168
  headers = self._merge_on_behalf_of_header(headers)
@@ -183,7 +183,7 @@ class SyncBaseService:
183
183
  headers=headers,
184
184
  files=files,
185
185
  )
186
- except (httpx.ConnectError, httpx.TimeoutException) as exc:
186
+ except (httpx2.ConnectError, httpx2.TimeoutException) as exc:
187
187
  if not self._is_retryable_method(method, headers) or attempt >= max_attempts - 1:
188
188
  raise
189
189
  error_type = type(exc).__name__
@@ -232,7 +232,7 @@ class SyncBaseService:
232
232
  raise RuntimeError(msg)
233
233
 
234
234
  @staticmethod
235
- def _log_success_response(response: httpx.Response, request_logger: Any) -> None:
235
+ def _log_success_response(response: httpx2.Response, request_logger: Any) -> None:
236
236
  """Log a successful HTTP response with elapsed time and optional request_id.
237
237
 
238
238
  Args:
@@ -248,7 +248,7 @@ class SyncBaseService:
248
248
 
249
249
  @staticmethod
250
250
  def _inject_request_id_fallback(
251
- response_data: dict[str, Any], response: httpx.Response
251
+ response_data: dict[str, Any], response: httpx2.Response
252
252
  ) -> None:
253
253
  """Inject request_id from X-Request-Id header when the response body omits it.
254
254
 
@@ -262,7 +262,7 @@ class SyncBaseService:
262
262
  response_data["request_id"] = header_id
263
263
 
264
264
  def _raise_for_status(
265
- self, response: httpx.Response, method: str, url: str, params: dict[str, Any] | None = None
265
+ self, response: httpx2.Response, method: str, url: str, params: dict[str, Any] | None = None
266
266
  ) -> None:
267
267
  """Raise appropriate exception for error responses.
268
268
 
@@ -356,7 +356,7 @@ class SyncBaseService:
356
356
  return None
357
357
 
358
358
  @staticmethod
359
- def _parse_retry_after(response: httpx.Response) -> int | None:
359
+ def _parse_retry_after(response: httpx2.Response) -> int | None:
360
360
  """Parse the retry time from response headers.
361
361
 
362
362
  First checks the RateLimit header for the "t" parameter (seconds until next token),
@@ -382,7 +382,7 @@ class SyncBaseService:
382
382
  return None
383
383
 
384
384
  @staticmethod
385
- def _parse_remaining_tokens(response: httpx.Response) -> int | None:
385
+ def _parse_remaining_tokens(response: httpx2.Response) -> int | None:
386
386
  """Parse the remaining tokens from the RateLimit header.
387
387
 
388
388
  Args:
@@ -402,7 +402,7 @@ class SyncBaseService:
402
402
  *,
403
403
  params: dict[str, Any] | None = None,
404
404
  headers: dict[str, str] | None = None,
405
- ) -> httpx.Response:
405
+ ) -> httpx2.Response:
406
406
  """Make a GET request.
407
407
 
408
408
  Args:
@@ -466,7 +466,7 @@ class SyncBaseService:
466
466
  data: dict[str, Any] | None = None,
467
467
  params: dict[str, Any] | None = None,
468
468
  idempotency_key: str | None = None,
469
- ) -> httpx.Response:
469
+ ) -> httpx2.Response:
470
470
  """Make a POST request.
471
471
 
472
472
  Args:
@@ -498,7 +498,7 @@ class SyncBaseService:
498
498
  data: dict[str, Any] | None = None,
499
499
  params: dict[str, Any] | None = None,
500
500
  idempotency_key: str | None = None,
501
- ) -> httpx.Response:
501
+ ) -> httpx2.Response:
502
502
  """Make a PUT request.
503
503
 
504
504
  Args:
@@ -530,7 +530,7 @@ class SyncBaseService:
530
530
  data: dict[str, Any] | None = None,
531
531
  params: dict[str, Any] | None = None,
532
532
  idempotency_key: str | None = None,
533
- ) -> httpx.Response:
533
+ ) -> httpx2.Response:
534
534
  """Make a PATCH request.
535
535
 
536
536
  Args:
@@ -554,7 +554,7 @@ class SyncBaseService:
554
554
  headers=self._build_idempotency_headers(idempotency_key),
555
555
  )
556
556
 
557
- def _delete(self, path: str, *, params: dict[str, Any] | None = None) -> httpx.Response:
557
+ def _delete(self, path: str, *, params: dict[str, Any] | None = None) -> httpx2.Response:
558
558
  """Make a DELETE request.
559
559
 
560
560
  Args:
@@ -568,7 +568,7 @@ class SyncBaseService:
568
568
 
569
569
  def _post_file(
570
570
  self, path: str, *, file: tuple[str, bytes, str], idempotency_key: str | None = None
571
- ) -> httpx.Response:
571
+ ) -> httpx2.Response:
572
572
  """Make a POST request with a file upload (multipart/form-data).
573
573
 
574
574
  Args:
@@ -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
+ ) -> httpx2.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
 
@@ -1,7 +1,7 @@
1
1
  # AUTO-GENERATED — do not edit. Run 'uv run duty generate_sync' to regenerate.
2
2
  """Fake async API client for testing downstream applications.
3
3
 
4
- FakeSyncVClient is a drop-in replacement for SyncVClient that uses httpx.MockTransport
4
+ FakeSyncVClient is a drop-in replacement for SyncVClient that uses httpx2.MockTransport
5
5
  instead of real HTTP. All real service classes work unmodified.
6
6
  """
7
7
 
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  import secrets
11
11
  from typing import TYPE_CHECKING, Any
12
12
 
13
- import httpx
13
+ import httpx2
14
14
  from pydantic import BaseModel
15
15
 
16
16
  from vclient._sync.client import SyncVClient
@@ -65,10 +65,10 @@ class SyncFakeVClient(SyncVClient):
65
65
  **kwargs,
66
66
  )
67
67
 
68
- def _create_http_client(self) -> httpx.Client:
68
+ def _create_http_client(self) -> httpx2.Client:
69
69
  """Create an HTTP client backed by the fake router."""
70
- return httpx.Client(
71
- transport=httpx.MockTransport(self._router.handle),
70
+ return httpx2.Client(
71
+ transport=httpx2.MockTransport(self._router.handle),
72
72
  base_url="https://fake.valentina-api.test",
73
73
  )
74
74
 
@@ -5,7 +5,7 @@ import platform
5
5
  from types import TracebackType
6
6
  from typing import TYPE_CHECKING, Self
7
7
 
8
- import httpx
8
+ import httpx2
9
9
  from loguru import logger
10
10
 
11
11
  from vclient.config import _APIConfig
@@ -147,7 +147,7 @@ class VClient:
147
147
  headers=headers or {},
148
148
  )
149
149
 
150
- self._http: httpx.AsyncClient = self._create_http_client()
150
+ self._http: httpx2.AsyncClient = self._create_http_client()
151
151
  self._companies: CompaniesService | None = None
152
152
  self._developer: DeveloperService | None = None
153
153
  self._global_admin: GlobalAdminService | None = None
@@ -165,7 +165,7 @@ class VClient:
165
165
  max_retries=self._config.max_retries,
166
166
  ).info("Initialize VClient")
167
167
 
168
- def _create_http_client(self) -> httpx.AsyncClient:
168
+ def _create_http_client(self) -> httpx2.AsyncClient:
169
169
  """Create and configure the HTTP client."""
170
170
  from vclient import __version__
171
171
 
@@ -180,10 +180,10 @@ class VClient:
180
180
  if self._config.api_key:
181
181
  headers[API_KEY_HEADER] = self._config.api_key
182
182
 
183
- return httpx.AsyncClient(
183
+ return httpx2.AsyncClient(
184
184
  base_url=self._config.base_url,
185
185
  headers=headers,
186
- timeout=httpx.Timeout(self._config.timeout),
186
+ timeout=httpx2.Timeout(self._config.timeout),
187
187
  )
188
188
 
189
189
  async def __aenter__(self) -> Self:
@@ -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