valentina-python-client 1.2.1__tar.gz → 1.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/PKG-INFO +13 -12
  2. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/README.md +11 -11
  3. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/pyproject.toml +7 -27
  4. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/__init__.py +24 -2
  5. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/client.py +24 -0
  6. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/config.py +3 -0
  7. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/constants.py +2 -0
  8. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/models/campaigns.py +6 -5
  9. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/models/character_blueprint.py +1 -1
  10. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/models/characters.py +38 -27
  11. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/models/companies.py +4 -3
  12. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/models/diceroll.py +7 -7
  13. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/models/dictionary.py +2 -0
  14. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/models/shared.py +8 -5
  15. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/models/users.py +5 -4
  16. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/registry.py +15 -0
  17. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/services/base.py +123 -57
  18. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/LICENSE +0 -0
  19. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/endpoints.py +0 -0
  20. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/exceptions.py +0 -0
  21. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/models/__init__.py +0 -0
  22. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/models/books.py +0 -0
  23. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/models/chapters.py +0 -0
  24. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/models/character_autogen.py +0 -0
  25. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/models/character_trait.py +0 -0
  26. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/models/developers.py +0 -0
  27. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/models/global_admin.py +0 -0
  28. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/models/pagination.py +0 -0
  29. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/models/system.py +0 -0
  30. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/py.typed +0 -0
  31. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/services/__init__.py +0 -0
  32. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/services/campaign_book_chapters.py +0 -0
  33. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/services/campaign_books.py +0 -0
  34. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/services/campaigns.py +0 -0
  35. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/services/character_autogen.py +0 -0
  36. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/services/character_blueprint.py +0 -0
  37. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/services/character_traits.py +0 -0
  38. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/services/characters.py +0 -0
  39. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/services/companies.py +0 -0
  40. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/services/developers.py +0 -0
  41. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/services/dicerolls.py +0 -0
  42. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/services/dictionary.py +0 -0
  43. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/services/global_admin.py +0 -0
  44. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/services/options.py +0 -0
  45. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/services/system.py +0 -0
  46. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/services/users.py +0 -0
  47. {valentina_python_client-1.2.1 → valentina_python_client-1.3.1}/src/vclient/validate_constants.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: valentina-python-client
3
- Version: 1.2.1
3
+ Version: 1.3.1
4
4
  Summary: Async Python client library for the Valentina Noir API
5
5
  Author: Nate Landau
6
6
  Author-email: Nate Landau <github@natenate.org>
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Programming Language :: Python :: 3.14
18
18
  Requires-Dist: anyio>=4.12.1
19
19
  Requires-Dist: httpx>=0.28.1
20
+ Requires-Dist: loguru>=0.7.3
20
21
  Requires-Dist: pydantic[email]>=2.12.5
21
22
  Requires-Python: >=3.13
22
23
  Project-URL: Homepage, https://docs.valentina-noir.com/python-api-client/
@@ -29,13 +30,13 @@ Async Python client library for accessing the Valentina Noir API.
29
30
 
30
31
  ## Features
31
32
 
32
- - **Async-first design** - Built on httpx for efficient async HTTP operations
33
- - **Type-safe** - Full type hints with Pydantic models for request/response validation
34
- - **Convenient factory pattern** - Create a client once, access services from anywhere
35
- - **Automatic pagination** - Stream through large datasets with `iter_all()` or fetch everything with `list_all()`
36
- - **Robust error handling** - Specific exception types for different error conditions
37
- - **Idempotency support** - Optional automatic idempotency keys for safe retries
38
- - **Rate limit handling** - Built-in support for automatic rate limit retries
33
+ - **Async-first design** - Built on httpx for efficient async HTTP operations
34
+ - **Type-safe** - Full type hints with Pydantic models for request/response validation
35
+ - **Convenient factory pattern** - Create a client once, access services from anywhere
36
+ - **Automatic pagination** - Stream through large datasets with `iter_all()` or fetch everything with `list_all()`
37
+ - **Robust error handling** - Specific exception types for different error conditions
38
+ - **Idempotency support** - Optional automatic idempotency keys for safe retries
39
+ - **Rate limit handling** - Built-in support for automatic rate limit retries
39
40
 
40
41
  This client is a supported and up-to-date reference implementation for the Valentina Noir API. The full documentation for is available at https://docs.valentina-noir.com/python-api-client/.
41
42
 
@@ -61,12 +62,12 @@ The script reads configuration from (highest precedence first):
61
62
 
62
63
  1. CLI arguments (`--api-url`, `--api-key`, `--company-id`)
63
64
  2. System environment variables (`VALENTINA_CLIENT_BASE_URL`, `VALENTINA_CLIENT_API_KEY`, `VALENTINA_CLIENT_DEFAULT_COMPANY_ID`)
64
- 3. A `.env.secrets` file in the project root
65
+ 3. A `.env.secret` file in the project root
65
66
 
66
67
  Exit codes: `0` = all constants match, `1` = mismatches found, `2` = missing configuration.
67
68
 
68
69
  ## Resources
69
70
 
70
- - [Full Client Documentation](https://docs.valentina-noir.com/python-api-client/)
71
- - [API Concepts](https://docs.valentina-noir.com/concepts/)
72
- - [API Reference](https://api.valentina-noir.com/docs)
71
+ - [Full Client Documentation](https://docs.valentina-noir.com/python-api-client/)
72
+ - [API Concepts](https://docs.valentina-noir.com/concepts/)
73
+ - [API Reference](https://api.valentina-noir.com/docs)
@@ -4,13 +4,13 @@ Async Python client library for accessing the Valentina Noir API.
4
4
 
5
5
  ## Features
6
6
 
7
- - **Async-first design** - Built on httpx for efficient async HTTP operations
8
- - **Type-safe** - Full type hints with Pydantic models for request/response validation
9
- - **Convenient factory pattern** - Create a client once, access services from anywhere
10
- - **Automatic pagination** - Stream through large datasets with `iter_all()` or fetch everything with `list_all()`
11
- - **Robust error handling** - Specific exception types for different error conditions
12
- - **Idempotency support** - Optional automatic idempotency keys for safe retries
13
- - **Rate limit handling** - Built-in support for automatic rate limit retries
7
+ - **Async-first design** - Built on httpx for efficient async HTTP operations
8
+ - **Type-safe** - Full type hints with Pydantic models for request/response validation
9
+ - **Convenient factory pattern** - Create a client once, access services from anywhere
10
+ - **Automatic pagination** - Stream through large datasets with `iter_all()` or fetch everything with `list_all()`
11
+ - **Robust error handling** - Specific exception types for different error conditions
12
+ - **Idempotency support** - Optional automatic idempotency keys for safe retries
13
+ - **Rate limit handling** - Built-in support for automatic rate limit retries
14
14
 
15
15
  This client is a supported and up-to-date reference implementation for the Valentina Noir API. The full documentation for is available at https://docs.valentina-noir.com/python-api-client/.
16
16
 
@@ -36,12 +36,12 @@ The script reads configuration from (highest precedence first):
36
36
 
37
37
  1. CLI arguments (`--api-url`, `--api-key`, `--company-id`)
38
38
  2. System environment variables (`VALENTINA_CLIENT_BASE_URL`, `VALENTINA_CLIENT_API_KEY`, `VALENTINA_CLIENT_DEFAULT_COMPANY_ID`)
39
- 3. A `.env.secrets` file in the project root
39
+ 3. A `.env.secret` file in the project root
40
40
 
41
41
  Exit codes: `0` = all constants match, `1` = mismatches found, `2` = missing configuration.
42
42
 
43
43
  ## Resources
44
44
 
45
- - [Full Client Documentation](https://docs.valentina-noir.com/python-api-client/)
46
- - [API Concepts](https://docs.valentina-noir.com/concepts/)
47
- - [API Reference](https://api.valentina-noir.com/docs)
45
+ - [Full Client Documentation](https://docs.valentina-noir.com/python-api-client/)
46
+ - [API Concepts](https://docs.valentina-noir.com/concepts/)
47
+ - [API Reference](https://api.valentina-noir.com/docs)
@@ -4,13 +4,13 @@
4
4
  "Programming Language :: Python :: 3.13",
5
5
  "Programming Language :: Python :: 3.14",
6
6
  ]
7
- dependencies = ["anyio>=4.12.1", "httpx>=0.28.1", "pydantic[email]>=2.12.5"]
7
+ dependencies = ["anyio>=4.12.1", "httpx>=0.28.1", "loguru>=0.7.3", "pydantic[email]>=2.12.5"]
8
8
  description = "Async Python client library for the Valentina Noir API"
9
9
  license = { file = "LICENSE" }
10
10
  name = "valentina-python-client"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.13"
13
- version = "1.2.1"
13
+ version = "1.3.1"
14
14
 
15
15
  [project.urls]
16
16
  Homepage = "https://docs.valentina-noir.com/python-api-client/"
@@ -25,10 +25,10 @@
25
25
 
26
26
  [dependency-groups]
27
27
  dev = [
28
- "commitizen>=4.13.7",
28
+ "commitizen>=4.13.8",
29
29
  "coverage>=7.13.4",
30
30
  "duty>=1.9.0",
31
- "prek>=0.3.2",
31
+ "prek>=0.3.3",
32
32
  "pytest-anyio>=0.0.0",
33
33
  "pytest-clarity>=1.0.1",
34
34
  "pytest-cov>=7.0.0",
@@ -39,10 +39,10 @@
39
39
  "pytest-xdist>=3.8.0",
40
40
  "pytest>=9.0.2",
41
41
  "respx>=0.22.0",
42
- "ruff>=0.15.1",
42
+ "ruff>=0.15.2",
43
43
  "shellcheck-py>=0.11.0.1",
44
44
  "ty>=0.0.17",
45
- "typos>=1.43.4",
45
+ "typos>=1.43.5",
46
46
  "vulture>=2.14",
47
47
  "yamllint>=1.38.0",
48
48
  ]
@@ -52,7 +52,7 @@
52
52
  changelog_merge_prerelease = true
53
53
  tag_format = "v$version"
54
54
  update_changelog_on_bump = true
55
- version = "1.0.1"
55
+ version_files = ["src/vclient/__init__.py:__version__"]
56
56
  version_provider = "uv"
57
57
 
58
58
  [tool.coverage.report] # https://coverage.readthedocs.io/en/latest/config.html#report
@@ -80,26 +80,6 @@
80
80
  [tool.coverage.xml]
81
81
  output = ".cache/coverage.xml"
82
82
 
83
- [tool.mypy] # https://mypy.readthedocs.io/en/latest/config_file.html
84
- cache_dir = ".cache/mypy"
85
- disallow_any_unimported = false
86
- disallow_subclassing_any = false
87
- disallow_untyped_decorators = false
88
- disallow_untyped_defs = true
89
- exclude = ['duties.py', 'tests/']
90
- follow_imports = "normal"
91
- ignore_missing_imports = true
92
- junit_xml = ".cache/mypy.xml"
93
- no_implicit_optional = true
94
- pretty = false
95
- show_column_numbers = true
96
- show_error_codes = true
97
- show_error_context = true
98
- strict_optional = false
99
- warn_redundant_casts = true
100
- warn_unreachable = true
101
- warn_unused_ignores = true
102
-
103
83
  [tool.pytest.ini_options]
104
84
 
105
85
  addopts = "--color=yes --doctest-modules --strict-config --strict-markers -n auto --dist loadfile"
@@ -11,8 +11,28 @@ For models, use: from vclient.models import Character, Campaign, ...
11
11
  For service classes, use: from vclient.services import CharactersService, ...
12
12
  """
13
13
 
14
- from vclient.client import VClient
15
- from vclient.registry import (
14
+ import logging as _logging
15
+
16
+ from loguru import logger as _logger
17
+
18
+
19
+ class _PropagateHandler(_logging.Handler):
20
+ """Forward loguru messages to stdlib logging for caplog/handler compatibility."""
21
+
22
+ def emit(self, record: _logging.LogRecord) -> None:
23
+ _logging.getLogger(record.name).handle(record)
24
+
25
+
26
+ _logger.add(
27
+ _PropagateHandler(),
28
+ format="{message}",
29
+ filter=lambda record: record["name"].startswith("vclient"),
30
+ )
31
+
32
+ _logger.disable("vclient")
33
+
34
+ from vclient.client import VClient # noqa: E402
35
+ from vclient.registry import ( # noqa: E402
16
36
  books_service,
17
37
  campaigns_service,
18
38
  chapters_service,
@@ -50,3 +70,5 @@ __all__ = (
50
70
  "system_service",
51
71
  "users_service",
52
72
  )
73
+
74
+ __version__ = "1.3.1"
@@ -1,16 +1,19 @@
1
1
  """Main API client for Valentina."""
2
2
 
3
3
  import os
4
+ import platform
4
5
  from types import TracebackType
5
6
  from typing import TYPE_CHECKING, Self
6
7
 
7
8
  import httpx
9
+ from loguru import logger
8
10
 
9
11
  from vclient.config import _APIConfig
10
12
  from vclient.constants import (
11
13
  API_KEY_HEADER,
12
14
  DEFAULT_MAX_RETRIES,
13
15
  DEFAULT_RETRY_DELAY,
16
+ DEFAULT_RETRY_STATUSES,
14
17
  DEFAULT_TIMEOUT,
15
18
  ENV_API_KEY,
16
19
  ENV_BASE_URL,
@@ -76,6 +79,7 @@ class VClient:
76
79
  retry_delay: float = DEFAULT_RETRY_DELAY,
77
80
  auto_retry_rate_limit: bool = True,
78
81
  auto_idempotency_keys: bool = False,
82
+ retry_statuses: set[int] | frozenset[int] | None = None,
79
83
  default_company_id: str | None = None,
80
84
  headers: dict[str, str] | None = None,
81
85
  set_as_default: bool = True,
@@ -100,6 +104,8 @@ class VClient:
100
104
  auto_retry_rate_limit: Automatically retry requests that hit rate limits.
101
105
  auto_idempotency_keys: Automatically generate idempotency keys for
102
106
  POST/PUT/PATCH requests.
107
+ retry_statuses: HTTP status codes that trigger automatic retries.
108
+ Defaults to {429, 500, 502, 503, 504}.
103
109
  default_company_id: Default company ID to use when not explicitly provided
104
110
  to service factory methods. Falls back to
105
111
  VALENTINA_CLIENT_DEFAULT_COMPANY_ID.
@@ -132,6 +138,9 @@ class VClient:
132
138
  retry_delay=retry_delay,
133
139
  auto_retry_rate_limit=auto_retry_rate_limit,
134
140
  auto_idempotency_keys=auto_idempotency_keys,
141
+ retry_statuses=frozenset(retry_statuses)
142
+ if retry_statuses is not None
143
+ else DEFAULT_RETRY_STATUSES,
135
144
  default_company_id=resolved_company_id,
136
145
  headers=headers or {},
137
146
  )
@@ -147,11 +156,22 @@ class VClient:
147
156
 
148
157
  configure_default_client(self)
149
158
 
159
+ logger.bind(
160
+ base_url=self._config.base_url,
161
+ timeout=self._config.timeout,
162
+ max_retries=self._config.max_retries,
163
+ ).info("Initialize VClient")
164
+
150
165
  def _create_http_client(self) -> httpx.AsyncClient:
151
166
  """Create and configure the HTTP client."""
167
+ from vclient import __version__
168
+
169
+ user_agent = f"vclient/{__version__} Python/{platform.python_version()}"
170
+
152
171
  headers = {
153
172
  "Accept": "application/json",
154
173
  "Content-Type": "application/json",
174
+ "User-Agent": user_agent,
155
175
  **self._config.headers,
156
176
  }
157
177
 
@@ -179,7 +199,11 @@ class VClient:
179
199
 
180
200
  async def close(self) -> None:
181
201
  """Close the HTTP client and release resources."""
202
+ from vclient.registry import clear_default_client
203
+
204
+ logger.bind(base_url=self._config.base_url).info("Close VClient")
182
205
  await self._http.aclose()
206
+ clear_default_client(self)
183
207
 
184
208
  @property
185
209
  def is_closed(self) -> bool:
@@ -5,6 +5,7 @@ from dataclasses import dataclass, field
5
5
  from vclient.constants import (
6
6
  DEFAULT_MAX_RETRIES,
7
7
  DEFAULT_RETRY_DELAY,
8
+ DEFAULT_RETRY_STATUSES,
8
9
  DEFAULT_TIMEOUT,
9
10
  )
10
11
 
@@ -23,6 +24,7 @@ class _APIConfig:
23
24
  retry_delay: Base delay between retries in seconds.
24
25
  auto_retry_rate_limit: Automatically retry requests that hit rate limits (429).
25
26
  auto_idempotency_keys: Automatically generate idempotency keys for POST/PUT/PATCH.
27
+ retry_statuses: HTTP status codes that trigger automatic retries.
26
28
  default_company_id: Default company ID to use when not explicitly provided.
27
29
  """
28
30
 
@@ -33,6 +35,7 @@ class _APIConfig:
33
35
  retry_delay: float = DEFAULT_RETRY_DELAY
34
36
  auto_retry_rate_limit: bool = True
35
37
  auto_idempotency_keys: bool = False
38
+ retry_statuses: frozenset[int] = DEFAULT_RETRY_STATUSES
36
39
  default_company_id: str | None = None
37
40
  headers: dict[str, str] = field(default_factory=dict)
38
41
 
@@ -14,6 +14,8 @@ ENV_DEFAULT_COMPANY_ID = "VALENTINA_CLIENT_DEFAULT_COMPANY_ID"
14
14
  DEFAULT_TIMEOUT = 30.0
15
15
  DEFAULT_MAX_RETRIES = 3
16
16
  DEFAULT_RETRY_DELAY = 1.0
17
+ DEFAULT_RETRY_STATUSES: frozenset[int] = frozenset({429, 500, 502, 503, 504})
18
+ IDEMPOTENT_HTTP_METHODS: frozenset[str] = frozenset({"GET", "PUT", "DELETE"})
17
19
 
18
20
  # Pagination defaults
19
21
  DEFAULT_PAGE_LIMIT = 10
@@ -1,6 +1,7 @@
1
1
  """Pydantic models for Campaign API responses and requests."""
2
2
 
3
3
  from datetime import datetime
4
+ from typing import Annotated
4
5
 
5
6
  from pydantic import BaseModel, Field
6
7
 
@@ -39,7 +40,7 @@ class CampaignCreate(BaseModel):
39
40
  """
40
41
 
41
42
  name: str = Field(min_length=3, max_length=50)
42
- description: str | None = Field(default=None, min_length=3)
43
+ description: Annotated[str, Field(min_length=3)] | None = None
43
44
  desperation: int = Field(default=0, ge=0, le=5)
44
45
  danger: int = Field(default=0, ge=0, le=5)
45
46
 
@@ -50,10 +51,10 @@ class CampaignUpdate(BaseModel):
50
51
  Only include fields that need to be changed; omitted fields remain unchanged.
51
52
  """
52
53
 
53
- name: str | None = Field(default=None, min_length=3, max_length=50)
54
- description: str | None = Field(default=None, min_length=3)
55
- desperation: int | None = Field(default=None, ge=0, le=5)
56
- danger: int | None = Field(default=None, ge=0, le=5)
54
+ name: Annotated[str, Field(min_length=3, max_length=50)] | None = None
55
+ description: Annotated[str, Field(min_length=3)] | None = None
56
+ desperation: Annotated[int, Field(ge=0, le=5)] | None = None
57
+ danger: Annotated[int, Field(ge=0, le=5)] | None = None
57
58
 
58
59
 
59
60
  __all__ = [
@@ -123,7 +123,7 @@ class CharacterConcept(BaseModel):
123
123
 
124
124
  id: str
125
125
  name: str
126
- description: str | None = None
126
+ description: str
127
127
  date_created: datetime
128
128
  date_modified: datetime
129
129
  examples: list[str]
@@ -1,7 +1,7 @@
1
1
  """Pydantic models for Character API responses and requests."""
2
2
 
3
3
  from datetime import datetime
4
- from typing import Any
4
+ from typing import Annotated
5
5
 
6
6
  from pydantic import BaseModel, Field
7
7
 
@@ -29,8 +29,10 @@ class VampireAttributes(BaseModel):
29
29
  clan_name: str | None = Field(default=None, description="Name of the vampire clan.")
30
30
  generation: int | None = Field(default=None, description="Vampire generation.")
31
31
  sire: str | None = Field(default=None, description="Name of the vampire's sire.")
32
- bane: dict[str, Any] | None = Field(default=None, description="Clan bane details.")
33
- compulsion: dict[str, Any] | None = Field(default=None, description="Clan compulsion details.")
32
+ bane: NameDescriptionSubDocument | None = Field(default=None, description="Clan bane details.")
33
+ compulsion: NameDescriptionSubDocument | None = Field(
34
+ default=None, description="Clan compulsion details."
35
+ )
34
36
 
35
37
 
36
38
  class VampireAttributesCreate(BaseModel):
@@ -63,6 +65,7 @@ class WerewolfAttributes(BaseModel):
63
65
  pack_name: str | None = Field(default=None, description="Name of the werewolf's pack.")
64
66
  rite_ids: list[str] = Field(default_factory=list, description="List of werewolf rite IDs.")
65
67
  gift_ids: list[str] = Field(default_factory=list, description="List of werewolf gift IDs.")
68
+ total_renown: int = Field(default=0, description="Total renown.")
66
69
 
67
70
 
68
71
  class WerewolfAttributesCreate(BaseModel):
@@ -153,20 +156,22 @@ class Character(BaseModel):
153
156
  # Identity
154
157
  name_first: str = Field(..., min_length=3, description="Character's first name.")
155
158
  name_last: str = Field(..., min_length=3, description="Character's last name.")
156
- name_nick: str | None = Field(
157
- default=None, min_length=3, max_length=50, description="Character's nickname."
159
+ name_nick: Annotated[str, Field(min_length=3, max_length=50)] | None = Field(
160
+ default=None, description="Character's nickname."
158
161
  )
159
162
  name: str = Field(..., description="Character's display name.")
160
163
  name_full: str = Field(..., description="Character's full name.")
161
164
 
162
165
  # Biography
163
166
  age: int | None = Field(default=None, description="Character's age.")
164
- biography: str | None = Field(default=None, min_length=3, description="Character biography.")
165
- demeanor: str | None = Field(
166
- default=None, min_length=3, max_length=50, description="Character's demeanor."
167
+ biography: Annotated[str, Field(min_length=3)] | None = Field(
168
+ default=None, description="Character biography."
169
+ )
170
+ demeanor: Annotated[str, Field(min_length=3, max_length=50)] | None = Field(
171
+ default=None, description="Character's demeanor."
167
172
  )
168
- nature: str | None = Field(
169
- default=None, min_length=3, max_length=50, description="Character's nature."
173
+ nature: Annotated[str, Field(min_length=3, max_length=50)] | None = Field(
174
+ default=None, description="Character's nature."
170
175
  )
171
176
  concept_id: str | None = Field(default=None, description="ID of the character concept.")
172
177
 
@@ -218,16 +223,18 @@ class CharacterCreate(BaseModel):
218
223
 
219
224
  # Optional fields
220
225
  type: CharacterType | None = Field(default=None, description="Character type.")
221
- name_nick: str | None = Field(
222
- default=None, min_length=3, max_length=50, description="Character's nickname."
226
+ name_nick: Annotated[str, Field(min_length=3, max_length=50)] | None = Field(
227
+ default=None, description="Character's nickname."
223
228
  )
224
229
  age: int | None = Field(default=None, description="Character's age.")
225
- biography: str | None = Field(default=None, min_length=3, description="Character biography.")
226
- demeanor: str | None = Field(
227
- default=None, min_length=3, max_length=50, description="Character's demeanor."
230
+ biography: Annotated[str, Field(min_length=3)] | None = Field(
231
+ default=None, description="Character biography."
232
+ )
233
+ demeanor: Annotated[str, Field(min_length=3, max_length=50)] | None = Field(
234
+ default=None, description="Character's demeanor."
228
235
  )
229
- nature: str | None = Field(
230
- default=None, min_length=3, max_length=50, description="Character's nature."
236
+ nature: Annotated[str, Field(min_length=3, max_length=50)] | None = Field(
237
+ default=None, description="Character's nature."
231
238
  )
232
239
  concept_id: str | None = Field(default=None, description="ID of the character concept.")
233
240
  user_player_id: str | None = Field(
@@ -261,21 +268,25 @@ class CharacterUpdate(BaseModel):
261
268
  game_version: GameVersion | None = None
262
269
  status: CharacterStatus | None = None
263
270
 
264
- name_first: str | None = Field(
265
- default=None, min_length=3, description="Character's first name."
271
+ name_first: Annotated[str, Field(min_length=3)] | None = Field(
272
+ default=None, description="Character's first name."
266
273
  )
267
- name_last: str | None = Field(default=None, min_length=3, description="Character's last name.")
268
- name_nick: str | None = Field(
269
- default=None, min_length=3, max_length=50, description="Character's nickname."
274
+ name_last: Annotated[str, Field(min_length=3)] | None = Field(
275
+ default=None, description="Character's last name."
276
+ )
277
+ name_nick: Annotated[str, Field(min_length=3, max_length=50)] | None = Field(
278
+ default=None, description="Character's nickname."
270
279
  )
271
280
 
272
281
  age: int | None = None
273
- biography: str | None = Field(default=None, min_length=3, description="Character biography.")
274
- demeanor: str | None = Field(
275
- default=None, min_length=3, max_length=50, description="Character's demeanor."
282
+ biography: Annotated[str, Field(min_length=3)] | None = Field(
283
+ default=None, description="Character biography."
284
+ )
285
+ demeanor: Annotated[str, Field(min_length=3, max_length=50)] | None = Field(
286
+ default=None, description="Character's demeanor."
276
287
  )
277
- nature: str | None = Field(
278
- default=None, min_length=3, max_length=50, description="Character's nature."
288
+ nature: Annotated[str, Field(min_length=3, max_length=50)] | None = Field(
289
+ default=None, description="Character's nature."
279
290
  )
280
291
  concept_id: str | None = Field(default=None, description="ID of the character concept.")
281
292
 
@@ -1,6 +1,7 @@
1
1
  """Pydantic models for Company API responses."""
2
2
 
3
3
  from datetime import datetime
4
+ from typing import Annotated
4
5
 
5
6
  from pydantic import BaseModel, Field
6
7
 
@@ -77,7 +78,7 @@ class CompanyCreate(BaseModel):
77
78
 
78
79
  name: str = Field(min_length=3, max_length=50)
79
80
  email: str
80
- description: str | None = Field(default=None, min_length=3)
81
+ description: Annotated[str, Field(min_length=3)] | None = None
81
82
  settings: CompanySettings | None = None
82
83
 
83
84
 
@@ -87,9 +88,9 @@ class CompanyUpdate(BaseModel):
87
88
  Only include fields that need to be changed; omitted fields remain unchanged.
88
89
  """
89
90
 
90
- name: str | None = Field(default=None, min_length=3, max_length=50)
91
+ name: Annotated[str, Field(min_length=3, max_length=50)] | None = None
91
92
  email: str | None = None
92
- description: str | None = Field(default=None, min_length=3)
93
+ description: Annotated[str, Field(min_length=3)] | None = None
93
94
  settings: CompanySettings | None = None
94
95
 
95
96
 
@@ -12,16 +12,16 @@ class DiceRollResultSchema(BaseModel):
12
12
 
13
13
  total_result: int | None = None
14
14
  total_result_type: RollResultType
15
- total_result_humanized: str | None = None
15
+ total_result_humanized: str
16
16
  total_dice_roll: list[int] = Field(default_factory=list)
17
17
  player_roll: list[int] = Field(default_factory=list)
18
18
  desperation_roll: list[int] = Field(default_factory=list)
19
- total_dice_roll_emoji: str | None = None
20
- total_dice_roll_shortcode: str | None = None
21
- player_roll_emoji: str | None = None
22
- player_roll_shortcode: str | None = None
23
- desperation_roll_emoji: str | None = None
24
- desperation_roll_shortcode: str | None = None
19
+ total_dice_roll_emoji: str
20
+ total_dice_roll_shortcode: str
21
+ player_roll_emoji: str
22
+ player_roll_shortcode: str
23
+ desperation_roll_emoji: str
24
+ desperation_roll_shortcode: str
25
25
 
26
26
 
27
27
  class Diceroll(BaseModel):
@@ -15,6 +15,8 @@ class DictionaryTerm(BaseModel):
15
15
  synonyms: list[str] = Field(default_factory=list)
16
16
  date_created: datetime
17
17
  date_modified: datetime
18
+ is_global: bool = False
19
+ company_id: str | None = None
18
20
 
19
21
 
20
22
  class DictionaryTermCreate(BaseModel):
@@ -1,7 +1,7 @@
1
1
  """Shared Pydantic models used across multiple services."""
2
2
 
3
3
  from datetime import datetime
4
- from typing import Any
4
+ from typing import Annotated, Any
5
5
 
6
6
  from pydantic import BaseModel, Field
7
7
 
@@ -40,11 +40,14 @@ class S3Asset(BaseModel):
40
40
  id: str
41
41
  date_created: datetime
42
42
  date_modified: datetime
43
- file_type: S3AssetType
43
+ asset_type: S3AssetType
44
+ mime_type: str
44
45
  original_filename: str
45
46
  public_url: str
46
47
  uploaded_by: str
48
+ company_id: str
47
49
  parent_type: S3AssetParentType | None = None
50
+ parent_id: str | None = None
48
51
 
49
52
 
50
53
  # -----------------------------------------------------------------------------
@@ -81,8 +84,8 @@ class NoteUpdate(BaseModel):
81
84
  Only include fields that need to be changed; omitted fields remain unchanged.
82
85
  """
83
86
 
84
- title: str | None = Field(default=None, min_length=3, max_length=50)
85
- content: str | None = Field(default=None, min_length=3)
87
+ title: Annotated[str, Field(min_length=3, max_length=50)] | None = None
88
+ content: Annotated[str, Field(min_length=3)] | None = None
86
89
 
87
90
 
88
91
  # -----------------------------------------------------------------------------
@@ -190,7 +193,7 @@ class CharacterSpecialty(BaseModel):
190
193
 
191
194
  name: str
192
195
  type: SpecialtyType
193
- description: str | None = None
196
+ description: str
194
197
 
195
198
 
196
199
  __all__ = [
@@ -1,6 +1,7 @@
1
1
  """Pydantic models for User API responses and requests."""
2
2
 
3
3
  from datetime import datetime
4
+ from typing import Annotated
4
5
 
5
6
  from pydantic import BaseModel, Field
6
7
 
@@ -89,7 +90,7 @@ class UserUpdate(BaseModel):
89
90
  Only include fields that need to be changed; omitted fields remain unchanged.
90
91
  """
91
92
 
92
- name: str | None = Field(default=None, min_length=3, max_length=50)
93
+ name: Annotated[str, Field(min_length=3, max_length=50)] | None = None
93
94
  email: str | None = None
94
95
  role: UserRole | None = None
95
96
  discord_profile: DiscordProfile | None = None
@@ -124,7 +125,7 @@ class QuickrollCreate(BaseModel):
124
125
  """
125
126
 
126
127
  name: str = Field(min_length=3, max_length=50)
127
- description: str | None = Field(default=None, min_length=3)
128
+ description: Annotated[str, Field(min_length=3)] | None = None
128
129
  trait_ids: list[str] = Field(default_factory=list)
129
130
 
130
131
 
@@ -134,8 +135,8 @@ class QuickrollUpdate(BaseModel):
134
135
  Only include fields that need to be changed; omitted fields remain unchanged.
135
136
  """
136
137
 
137
- name: str | None = Field(default=None, min_length=3, max_length=50)
138
- description: str | None = Field(default=None, min_length=3)
138
+ name: Annotated[str, Field(min_length=3, max_length=50)] | None = None
139
+ description: Annotated[str, Field(min_length=3)] | None = None
139
140
  trait_ids: list[str] | None = None
140
141
 
141
142
 
@@ -64,6 +64,21 @@ def configure_default_client(client: "VClient") -> None:
64
64
  _default_client = client
65
65
 
66
66
 
67
+ def clear_default_client(client: "VClient") -> None:
68
+ """Clear the default client if it matches the given instance.
69
+
70
+ Called automatically by ``VClient.close()`` so that subsequent calls to
71
+ ``default_client()`` raise a clear ``RuntimeError`` instead of returning a
72
+ closed HTTP session.
73
+
74
+ Args:
75
+ client: The client instance to compare against the current default.
76
+ """
77
+ global _default_client # noqa: PLW0603
78
+ if _default_client is client:
79
+ _default_client = None
80
+
81
+
67
82
  def default_client() -> "VClient":
68
83
  """Retrieve the configured default client.
69
84
 
@@ -7,6 +7,7 @@ from collections.abc import AsyncIterator
7
7
  from typing import TYPE_CHECKING, Any, TypeVar
8
8
 
9
9
  import httpx
10
+ from loguru import logger
10
11
  from pydantic import BaseModel, ValidationError as PydanticValidationError
11
12
 
12
13
  from vclient.constants import (
@@ -14,6 +15,7 @@ from vclient.constants import (
14
15
  HTTP_500_INTERNAL_SERVER_ERROR,
15
16
  HTTP_600_UPPER_BOUND,
16
17
  IDEMPOTENCY_KEY_HEADER,
18
+ IDEMPOTENT_HTTP_METHODS,
17
19
  MAX_PAGE_LIMIT,
18
20
  RATE_LIMIT_HEADER,
19
21
  )
@@ -109,6 +111,25 @@ class BaseService:
109
111
  jitter = random.uniform(0, delay * 0.25)
110
112
  return delay + jitter
111
113
 
114
+ @staticmethod
115
+ def _is_retryable_method(method: str, headers: dict[str, str] | None) -> bool:
116
+ """Check if a request method is safe to retry.
117
+
118
+ Idempotent methods (GET, PUT, DELETE) are always safe. Non-idempotent
119
+ methods (POST, PATCH) are only safe if an idempotency key is present.
120
+
121
+ Args:
122
+ method: The HTTP method (uppercase).
123
+ headers: The request headers, or None.
124
+
125
+ Returns:
126
+ True if the request is safe to retry.
127
+ """
128
+ if method in IDEMPOTENT_HTTP_METHODS:
129
+ return True
130
+
131
+ return IDEMPOTENCY_KEY_HEADER in (headers or {})
132
+
112
133
  async def _request(
113
134
  self,
114
135
  method: str,
@@ -118,11 +139,15 @@ class BaseService:
118
139
  json: dict[str, Any] | None = None,
119
140
  data: dict[str, Any] | None = None,
120
141
  headers: dict[str, str] | None = None,
142
+ files: Any | None = None,
121
143
  ) -> httpx.Response:
122
- """Make an HTTP request with automatic retry on rate limits.
144
+ """Make an HTTP request with automatic retry on transient errors.
123
145
 
124
- When rate limited (429), automatically retries with exponential backoff
125
- if auto_retry_rate_limit is enabled in the config.
146
+ Retries on rate limits (429), server errors (5xx in retry_statuses),
147
+ and network errors (ConnectError, TimeoutException). Non-idempotent
148
+ methods (POST, PATCH) only retry on 5xx/network errors when an
149
+ idempotency key header is present. Rate limit retries (429) always
150
+ apply regardless of method.
126
151
 
127
152
  Args:
128
153
  method: HTTP method (GET, POST, PUT, DELETE, etc.).
@@ -131,56 +156,109 @@ class BaseService:
131
156
  json: JSON body data.
132
157
  data: Form data.
133
158
  headers: Additional headers to include in the request.
159
+ files: Files to upload (passed through to httpx).
134
160
 
135
161
  Returns:
136
162
  The HTTP response.
137
163
 
138
164
  Raises:
139
165
  RateLimitError: When rate limit is exceeded and max retries are exhausted.
166
+ ServerError: When server error occurs and max retries are exhausted.
167
+ httpx.ConnectError: When connection fails and max retries are exhausted.
168
+ httpx.TimeoutException: When request times out and max retries are exhausted.
140
169
  APIError: For other API error responses.
141
170
  """
142
171
  config = self._client._config # noqa: SLF001
143
172
  max_attempts = config.max_retries + 1 if config.auto_retry_rate_limit else 1
173
+ retry_statuses = config.retry_statuses
174
+ request_logger = logger.bind(method=method, url=path)
144
175
 
145
- last_error: RateLimitError | None = None
176
+ request_logger.debug("Send request")
177
+
178
+ last_error: RateLimitError | ServerError | None = None
146
179
 
147
180
  for attempt in range(max_attempts):
148
- response = await self._http.request(
149
- method=method,
150
- url=path,
151
- params=params,
152
- json=json,
153
- data=data,
154
- headers=headers,
155
- )
181
+ try:
182
+ response = await self._http.request(
183
+ method=method,
184
+ url=path,
185
+ params=params,
186
+ json=json,
187
+ data=data,
188
+ headers=headers,
189
+ files=files,
190
+ )
191
+ except (httpx.ConnectError, httpx.TimeoutException) as exc:
192
+ if not self._is_retryable_method(method, headers) or attempt >= max_attempts - 1:
193
+ raise
194
+
195
+ error_type = type(exc).__name__
196
+ delay = self._calculate_backoff_delay(attempt, retry_after=None)
197
+ request_logger.bind(
198
+ error_type=error_type,
199
+ attempt=attempt + 1,
200
+ max_attempts=max_attempts,
201
+ delay=delay,
202
+ ).warning("Retry after network error")
203
+ await asyncio.sleep(delay)
204
+ continue
156
205
 
157
206
  try:
158
- self._raise_for_status(response)
207
+ self._raise_for_status(response, method, path)
208
+
209
+ elapsed_ms = response.elapsed.total_seconds() * 1000
210
+ request_logger.bind(
211
+ status=response.status_code,
212
+ elapsed_ms=elapsed_ms,
213
+ ).debug("Receive response")
159
214
  return response # noqa: TRY300
160
215
  except RateLimitError as e:
161
216
  last_error = e
162
217
 
163
- # If this was the last attempt, don't wait
164
218
  if attempt >= max_attempts - 1:
165
219
  break
166
220
 
167
- # Calculate backoff and wait
168
221
  delay = self._calculate_backoff_delay(attempt, e.retry_after)
222
+ request_logger.bind(
223
+ attempt=attempt + 1,
224
+ max_attempts=max_attempts,
225
+ delay=delay,
226
+ ).warning("Retry after rate limit")
227
+ await asyncio.sleep(delay)
228
+ except ServerError as e:
229
+ if e.status_code not in retry_statuses or not self._is_retryable_method(
230
+ method, headers
231
+ ):
232
+ raise
233
+
234
+ last_error = e
235
+
236
+ if attempt >= max_attempts - 1:
237
+ break
238
+
239
+ delay = self._calculate_backoff_delay(attempt, retry_after=None)
240
+ request_logger.bind(
241
+ status=e.status_code,
242
+ attempt=attempt + 1,
243
+ max_attempts=max_attempts,
244
+ delay=delay,
245
+ ).warning("Retry after server error")
169
246
  await asyncio.sleep(delay)
170
247
 
171
- # Re-raise the last rate limit error
172
248
  if last_error is not None:
249
+ request_logger.bind(attempts=max_attempts).error("Exhaust retries")
173
250
  raise last_error
174
251
 
175
- # This should never happen, but satisfies type checker
176
252
  msg = "Unexpected state: no response or error"
177
253
  raise RuntimeError(msg)
178
254
 
179
- def _raise_for_status(self, response: httpx.Response) -> None:
255
+ def _raise_for_status(self, response: httpx.Response, method: str, url: str) -> None:
180
256
  """Raise appropriate exception for error responses.
181
257
 
182
258
  Args:
183
259
  response: The HTTP response to check.
260
+ method: The HTTP method of the request.
261
+ url: The URL path of the request.
184
262
 
185
263
  Raises:
186
264
  AuthenticationError: For 401 responses.
@@ -203,14 +281,7 @@ class BaseService:
203
281
  response_data = {}
204
282
  message = response.text or f"HTTP {status_code}"
205
283
 
206
- error_map: dict[int, type[APIError]] = {
207
- 400: ValidationError,
208
- 401: AuthenticationError,
209
- 403: AuthorizationError,
210
- 404: NotFoundError,
211
- 409: ConflictError,
212
- 429: RateLimitError,
213
- }
284
+ error_logger = logger.bind(method=method, url=url, status=status_code)
214
285
 
215
286
  if status_code == 429: # noqa: PLR2004
216
287
  retry_after = self._parse_retry_after(response)
@@ -219,8 +290,25 @@ class BaseService:
219
290
  message, status_code, response_data, retry_after=retry_after, remaining=remaining
220
291
  )
221
292
 
222
- if status_code in error_map:
223
- raise error_map[status_code](message, status_code, response_data)
293
+ if status_code == 401: # noqa: PLR2004
294
+ error_logger.error("Fail authentication")
295
+ raise AuthenticationError(message, status_code, response_data)
296
+
297
+ if status_code == 403: # noqa: PLR2004
298
+ error_logger.error("Deny authorization")
299
+ raise AuthorizationError(message, status_code, response_data)
300
+
301
+ if status_code == 404: # noqa: PLR2004
302
+ error_logger.debug("Return 404")
303
+ raise NotFoundError(message, status_code, response_data)
304
+
305
+ if status_code == 400: # noqa: PLR2004
306
+ error_logger.warning("Reject with validation error")
307
+ raise ValidationError(message, status_code, response_data)
308
+
309
+ if status_code == 409: # noqa: PLR2004
310
+ error_logger.warning("Return 409 conflict")
311
+ raise ConflictError(message, status_code, response_data)
224
312
 
225
313
  if HTTP_500_INTERNAL_SERVER_ERROR <= status_code < HTTP_600_UPPER_BOUND:
226
314
  raise ServerError(message, status_code, response_data)
@@ -480,40 +568,18 @@ class BaseService:
480
568
  The HTTP response.
481
569
 
482
570
  Raises:
571
+ ServerError: When server error occurs and max retries are exhausted.
483
572
  RateLimitError: When rate limit is exceeded and max retries are exhausted.
484
573
  APIError: For other API error responses.
485
574
  """
486
- config = self._client._config # noqa: SLF001
487
- max_attempts = config.max_retries + 1 if config.auto_retry_rate_limit else 1
488
-
489
- last_error: RateLimitError | None = None
490
- headers = self._build_idempotency_headers(idempotency_key)
491
575
  filename, content, content_type = file
492
576
 
493
- for attempt in range(max_attempts):
494
- response = await self._http.post(
495
- url=path,
496
- files={"file": (filename, content, content_type)},
497
- headers=headers,
498
- )
499
-
500
- try:
501
- self._raise_for_status(response)
502
- return response # noqa: TRY300
503
- except RateLimitError as e:
504
- last_error = e
505
-
506
- if attempt >= max_attempts - 1:
507
- break
508
-
509
- delay = self._calculate_backoff_delay(attempt, e.retry_after)
510
- await asyncio.sleep(delay)
511
-
512
- if last_error is not None:
513
- raise last_error
514
-
515
- msg = "Unexpected state: no response or error"
516
- raise RuntimeError(msg)
577
+ return await self._request(
578
+ "POST",
579
+ path,
580
+ files={"file": (filename, content, content_type)},
581
+ headers=self._build_idempotency_headers(idempotency_key),
582
+ )
517
583
 
518
584
  # -------------------------------------------------------------------------
519
585
  # Pagination Methods