valentina-python-client 3.2.0__py3-none-any.whl → 3.4.0__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: valentina-python-client
3
- Version: 3.2.0
3
+ Version: 3.4.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>
@@ -15,11 +15,12 @@ License: MIT License
15
15
  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Programming Language :: Python :: 3.14
18
- Requires-Dist: anyio>=4.14.1,<5.0.0
19
- Requires-Dist: httpx2>=2.5.0
18
+ Classifier: Programming Language :: Python :: 3.15
19
+ Requires-Dist: anyio>=4.15.1,<5.0.0
20
+ Requires-Dist: httpx2>=2.13.1
20
21
  Requires-Dist: loguru>=0.7.3
21
22
  Requires-Dist: polyfactory>=3.3.0,<4.0.0
22
- Requires-Dist: pydantic[email]>=2.13.4,<3.0.0
23
+ Requires-Dist: pydantic[email]>=2.14.1,<3.0.0
23
24
  Requires-Dist: polyfactory>=3.3.0 ; extra == 'testing'
24
25
  Requires-Python: >=3.13
25
26
  Project-URL: Homepage, https://docs.valentina-noir.com/python-api-client/
@@ -1,4 +1,4 @@
1
- vclient/__init__.py,sha256=hydi_5yJBNxfLX9_jWescrBQ8jjbSKt9qQyxPfXpvNI,3325
1
+ vclient/__init__.py,sha256=aAvo9n1_iOStq-E0rETj69xA_DkZarCuyhDtPImAr3A,3361
2
2
  vclient/_codegen.py,sha256=iTw09vcpmpP9Uh7byQY2T9xHQ5Je9kSf9eaGCgm_EMA,15763
3
3
  vclient/_sync/__init__.py,sha256=Ewgxs5-wl73I3nieKaaPmoXwlE76gh65G6OMrx6UUkY,1408
4
4
  vclient/_sync/client.py,sha256=HTkYwLJizmAxpaJ5MOvQ0tTtlXLRbuP1UT19uqFa6ck,24031
@@ -8,18 +8,18 @@ vclient/_sync/services/_audit_params.py,sha256=qWAC7ZBmdXsiDPqAGnY56RoAvMHbHoZpI
8
8
  vclient/_sync/services/base.py,sha256=WMXOih9-PS1sus8nWVH2hxqbjXJ2mdGdGgGGiOdUOL8,27582
9
9
  vclient/_sync/services/campaign_book_chapters.py,sha256=_h_g5HFnbtdHZAmEsBQWcPCewVyQIu-os5atmRbkJuk,17931
10
10
  vclient/_sync/services/campaign_books.py,sha256=BAXYhStX8OvYewTjSBrFN0X0ivecd1BYOEfqQ0qMc-c,20094
11
- vclient/_sync/services/campaigns.py,sha256=IQaN5HLcRkRno8-X3SFIZAdT2eAKn8EsMt2isveJFBA,19833
11
+ vclient/_sync/services/campaigns.py,sha256=OIO5n3DlSuPESRctrvFvZPfy0yJijGado0J7Y_58iaA,20148
12
12
  vclient/_sync/services/character_autogen.py,sha256=D2-jdCwJQznh38s_8nwNty0Rm32YS_lvrfRr-gy9C-0,5522
13
13
  vclient/_sync/services/character_blueprint.py,sha256=yb3biCtFq_kEKJqnNvT47VNAAzush8kgzsVz3il-rr0,18129
14
- vclient/_sync/services/character_traits.py,sha256=RtzGO9ZnpoYPZZjRFFw-8R7XtkuDa4RvBX5r4WzvFoI,10804
15
- vclient/_sync/services/characters.py,sha256=w6avY5Zby0ozpCbqxmOSQ0tkHq83g0n7aPxbRtqaf-w,34403
14
+ vclient/_sync/services/character_traits.py,sha256=H2yrvyGffbZsGMRXp_QKtVj2uAEWguBzrSRgR_q30jk,20827
15
+ vclient/_sync/services/characters.py,sha256=CU5ncQ4rayEderauWBCafwbvRFteQUtS3C8QIgvGiXc,38647
16
16
  vclient/_sync/services/companies.py,sha256=z5OQ9dz8Xyt7ak85SCPYb8ipThyH9fvV_ArV_Y9sVc8,16796
17
17
  vclient/_sync/services/developers.py,sha256=PGYiXiMIwgFdhcMlHYnR2hbm8bAGu-uJbD3jagUFAzw,3152
18
18
  vclient/_sync/services/dicerolls.py,sha256=ONCxGjjxqtq_vri2azX3xdEL4w1A2wn5CKj0odSe1Ds,6658
19
19
  vclient/_sync/services/dictionary.py,sha256=hJZ4r_JbYh22uWdzYCgdD-9EWAdWy5KbLIltAI9IclA,4710
20
20
  vclient/_sync/services/global_admin.py,sha256=ZUzJirPCumQ-1DEjzyvtFkJ5AGupV238HA8Tnlm8fp8,25506
21
21
  vclient/_sync/services/identity.py,sha256=sG7VhuwciCLBY6C3dpCtOHjlw1ejx8dy5vYlAJD31l8,3640
22
- vclient/_sync/services/options.py,sha256=UWNiUlDCweKuiOEioAaBbvwC6KJuIdZREl7_HsPTAmc,1526
22
+ vclient/_sync/services/options.py,sha256=tUbyfJWp32gUWx7S9UhI1ykYNHw500C67_d2DEoYMmw,1848
23
23
  vclient/_sync/services/system.py,sha256=PWeXYGHu8CP8Q-mru20vtUWwadwi5d-cVC-d-o5QHyk,1426
24
24
  vclient/_sync/services/user_lookup.py,sha256=M9IE2l9HmksaCJu5a2rZ6LP96tDuIkFWyIAQ9oWCVNw,3114
25
25
  vclient/_sync/services/users.py,sha256=Axr6FINzVBYwJ88e5u2V6Elcwd7ydEQECM-SGI3UrLA,42877
@@ -27,26 +27,26 @@ vclient/_sync/testing/__init__.py,sha256=86UI6uFy62uUfpnBz7uEDsUHCXXhRqaOrkELeoH
27
27
  vclient/_sync/testing/_client.py,sha256=aV59HR9J4vmih4wgIRujaGGx9puXC6wtpqasDtch4kE,7817
28
28
  vclient/client.py,sha256=Tqb2lJI8BUvu_H9LwIFsLUF4GAsDnx4ousq-MXfUbvY,23715
29
29
  vclient/config.py,sha256=T9XcF6cYRVkAvSSgX2XxJ05zK1774WZ6C1vAYo96oZ4,1601
30
- vclient/constants.py,sha256=OIbTJKkstpXfqoyrnit6jLyLx2KeC6lxLUPShjp0lsU,3890
31
- vclient/endpoints.py,sha256=wVTtnTb_ne1IN7ROt632vfaztyBIvAzIljeJiGBMdTE,6736
32
- vclient/exceptions.py,sha256=XYY2njp3nNX7wqrUcRuODcgVtI5D44FbF2fOmlQbqFI,9516
33
- vclient/models/__init__.py,sha256=hKwFfPK4YnHFn1XbOsaJZcRZTbjeQw7OCvQUxKani0w,5804
30
+ vclient/constants.py,sha256=jX4BV2mburh-uc7CfUzAo8yS_IflQvAUgKULa7V3RZ4,4168
31
+ vclient/endpoints.py,sha256=xVa6Q_CRD-RJKwoPKEaL99LKulL1ronDVPinf-vUxZY,7125
32
+ vclient/exceptions.py,sha256=XZ7UtwwyGXePDeTRQegA5We9pITwBdo-cs-941-Jj6A,9632
33
+ vclient/models/__init__.py,sha256=Py83i-3iab4Au6XS4EGwDl2EarMyfyYX47B6GziEZpA,6420
34
34
  vclient/models/audit_logs.py,sha256=97kQdH2r8yzRnQotBtk43dDyEROVdwfZktBWGA7tdRE,1437
35
35
  vclient/models/books.py,sha256=9Iz1sRuvHkWxoR4Ac9C4ZIbVKwF6oBLJtpvIovWqGZo,3412
36
- vclient/models/campaigns.py,sha256=m8e9BPNncw6Pulg9tfMH68esq58lYs1Wq9mmufCphQ8,2080
36
+ vclient/models/campaigns.py,sha256=dbc3itxCm14b4NENG4oF27ccU3Bo3zNNRiWsfDwXA_k,2197
37
37
  vclient/models/chapters.py,sha256=vCOZ_LSsNhBCairWRQ5Q3jVd70C-RXlQs5uWzPqZMgI,3137
38
38
  vclient/models/character_autogen.py,sha256=Zt1e3qZ33w5vt3FofjrXlhoXbno9WlwoVDA0iOYQcAM,1051
39
39
  vclient/models/character_blueprint.py,sha256=ijrpQj2505pfJUnI-kIjkzEBIZQf1Ep52rv-O07hJIk,3655
40
- vclient/models/character_trait.py,sha256=4ntW-6a3M1X_o6yEeOii3QBcknX_-kEREdcj2DYvvSQ,2854
41
- vclient/models/characters.py,sha256=BdfmB4qOqwc2syZislyiVewz5IEvJJYKW6WDzELqzPg,14839
42
- vclient/models/companies.py,sha256=Y4Ao2-aM8hh0808evLdQ5RPMj9bng3w17_w_ISWpTtM,4631
40
+ vclient/models/character_trait.py,sha256=dOj7h97FOVZ-_BtiXNLHPtvrxsjGLjTLQH6X780QDQg,8412
41
+ vclient/models/characters.py,sha256=Qwf38c4Zo5aZgkRVgX3yC3AiC6NHOgmZDaEH6btyn0I,15471
42
+ vclient/models/companies.py,sha256=NQy8-_3wMx22PEVr-W9fzqd9B3okfBZN769nvkdptnE,4775
43
43
  vclient/models/developers.py,sha256=epmOZ6Lhds_VHGHPr3YJ1LXySTqANTfojHYw5y7VD9A,1743
44
44
  vclient/models/diceroll.py,sha256=hpKb-kE5rb9SE3mU7UHxZBrNBbiZyE4Qjw_vfvCCdpI,1962
45
- vclient/models/dictionary.py,sha256=eC8S_bXJMHUnZKUyogc89WdR3oB0pNMPseuKSB2C-yE,1064
45
+ vclient/models/dictionary.py,sha256=E8MHZ4CIibtc51E3KnXqNZQJxi8mXAB-x5XCIy46tSQ,1248
46
46
  vclient/models/full_sheet.py,sha256=PcPsQLxZxU-8bLZATzfNSt0Ab1qu5ILRbhYgo-NnHrs,1705
47
47
  vclient/models/global_admin.py,sha256=JtLklUvbG9OsSmEGaQSCSBDIs6dv2Ka85i0WA8kmZ-A,2920
48
48
  vclient/models/pagination.py,sha256=WNfAhu56psLItpza-vM9dMHsZNoSQ4LqbgVb7Ve1BKQ,1682
49
- vclient/models/shared.py,sha256=1QW9UPcr2BrSUXAvXUgEB2vZnnLNg1a-TFtV-6VWoAE,5417
49
+ vclient/models/shared.py,sha256=fAqPcBaTCOIb-vX5I7mJIi3d-Nhw04YrF0aE1TOsDjI,6416
50
50
  vclient/models/system.py,sha256=khQCz3ehEVvZGDuMUp5Oel3hl8fWd_643ugB6A9e7rc,476
51
51
  vclient/models/user_lookup.py,sha256=lyT78qy8Dg8gibqTQhw9lzpUPha58-oaN86jZPy-lC4,430
52
52
  vclient/models/users.py,sha256=Zg-1YKqp9PfFS4WjytO-dySD-3-jycp2vGvztNonHlU,10054
@@ -57,27 +57,27 @@ vclient/services/_audit_params.py,sha256=I91LYf6qIdXHtd5XqwsrRyIF4JUDq8z5UmDNgSZ
57
57
  vclient/services/base.py,sha256=9Jvmc5RUo28dDXc0VCHcLxl8waJkvc6Y-J4qLcyaC9c,29476
58
58
  vclient/services/campaign_book_chapters.py,sha256=JP0wKY2z9AQGx6CQB7WZNA4jQFqDvaZrSO_XR4PJmq0,19232
59
59
  vclient/services/campaign_books.py,sha256=ysx18zp0GRoeHA_ItDj13nt6vC73QLmnWpjzy7Mc-VY,21504
60
- vclient/services/campaigns.py,sha256=bicqHG6yo9xQlUD24iUD1GbBc_taRQooMv7wS13Byo4,21275
60
+ vclient/services/campaigns.py,sha256=whSiQm2O8mnZpPGNCTSVymLWGccCf9PxHr3vHuXXBGg,21801
61
61
  vclient/services/character_autogen.py,sha256=S0nbVXQx18df0sz23Hu0nUHaVSH0G0qsQL780pDjWPU,5727
62
62
  vclient/services/character_blueprint.py,sha256=GbWIvdPHndsLdRj7pYTMtHdvX1S8rEWXDNqwn0eK8Ps,19571
63
- vclient/services/character_traits.py,sha256=V1GfNOIC08Zg0KHMY0loiVxUkbjogAC5WNd6Cdqq6hM,11241
64
- vclient/services/characters.py,sha256=FoWZ0l8WydJZ7RHTbObJUPnVO2ESopgMHY4QGI6_0uU,35681
63
+ vclient/services/character_traits.py,sha256=u8Jt9inMbhc0h1qLqS-pVm8-p0-3nrt9LjKFEE1IFW4,21508
64
+ vclient/services/characters.py,sha256=6rUMxkwOZJqG5OzPzW1nG6AWOT9yKgQ8Qhy8j4WTu7s,40380
65
65
  vclient/services/companies.py,sha256=rSgNP52pFSNvTzjvrUnBQnoYRCK7OrNCLT-Kve0kM2Q,17125
66
66
  vclient/services/developers.py,sha256=74RZ61TfY-HffJw643OvtzolyNtpjVo8_j9JNLQ1fsk,3133
67
67
  vclient/services/dicerolls.py,sha256=bp1r8FHc3vyrMjR2JM7TsXRVTG6EfVTa5j-EQPgWpFg,6886
68
68
  vclient/services/dictionary.py,sha256=4S9M8iISccrVZpQ1Di_4eS58q6Brl_bZT1eFPj3L2x8,4980
69
69
  vclient/services/global_admin.py,sha256=CVTtvGf0zvJea6hcSpoDk2F_1mJG9QESprc5dRd5HFE,26080
70
70
  vclient/services/identity.py,sha256=nrBT2-55yCgs8O5xnQfI-7IyZRSBfliFsgmQux0H_64,3562
71
- vclient/services/options.py,sha256=MIodZC4MlLNJ1ocWPIuBbK7fjsLshNg6rx6UyB2t5jc,1607
71
+ vclient/services/options.py,sha256=Zdq_HQVTNL8_P3F6sGaBIMZ1KHrem0rMh9ZD5vW7JRg,1929
72
72
  vclient/services/system.py,sha256=2tgOwrKGVyzbBDHHDUWDWGrA1s2jSO20E6L9YjY8yIc,1335
73
73
  vclient/services/user_lookup.py,sha256=KQL3kFTQ192rs_Jags-LilQKWbAw5Qq5m2FqmB9Ci34,3071
74
74
  vclient/services/users.py,sha256=NYyInIyNZcwbZZbXRQA3CGS22fxSIH5AhNmOgHtoCmI,45075
75
- vclient/testing/__init__.py,sha256=9TvE1vuPijiA7UVoYm6mVL3KlBsoBxi508CJ2xevMrQ,3607
75
+ vclient/testing/__init__.py,sha256=LJAld9JHT6ujPST_1s8S0Xp7Ix7Jva1iI8dauMCtwY8,3861
76
76
  vclient/testing/_client.py,sha256=8LaIH7D1pOlLwxzRgFCONln7VfGxI46W05g3_3EnAm4,7710
77
- vclient/testing/_factories.py,sha256=MRtxqzjkAwJH_3-OsEQ1jnOqAvq017BDNRM7g2xYTCg,9248
78
- vclient/testing/_router.py,sha256=RSoZ0wxn675NjQ-AHB-c-NwmelFaZdh5uGl5eJZy8gk,12221
79
- vclient/testing/_routes.py,sha256=cviYiArdl1DpcoO_WUyIQq2d_5QXQgLQyOlKJ1JT7RI,19145
80
- vclient/validate_constants.py,sha256=ntSEfjGkhrRBnXNhvavxgLoRFXQz4tAhc0XkhERE1t8,10464
81
- valentina_python_client-3.2.0.dist-info/WHEEL,sha256=CoDSoyhtC_eO_tlxRYzsTraPv1fPJRXFx91k6ISeAvA,81
82
- valentina_python_client-3.2.0.dist-info/METADATA,sha256=w4hnE3z2wfrSxZzMckhjDJTZC1RS7r6BsUeKWtUnOZk,4239
83
- valentina_python_client-3.2.0.dist-info/RECORD,,
77
+ vclient/testing/_factories.py,sha256=4z7e1_vF5yW6hecbXzuWDklH8vgzP6H88g4gCs8olo4,14253
78
+ vclient/testing/_router.py,sha256=TfQ8oUKrUjK4ysMuakrwDzJTODEkdMwIY8_T2uUyKf8,12619
79
+ vclient/testing/_routes.py,sha256=piUShA0w-Fl0YoPk6FRNLS1kMXIQbQxCVQH7y-zOhIg,20348
80
+ vclient/validate_constants.py,sha256=V1E23tagOrxzxdJwljOx5IQh-tj-yHYjdaJmq7kt4-o,10661
81
+ valentina_python_client-3.4.0.dist-info/WHEEL,sha256=si2p8vW03RnhyBnhWl4tj-ObJa78SpE-c9HsEtblGR0,80
82
+ valentina_python_client-3.4.0.dist-info/METADATA,sha256=UmlyzpsvRTElN2L1nFIPyFTxCjSaG5OcQFanO6PGsOI,4291
83
+ valentina_python_client-3.4.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.11.28
2
+ Generator: uv 0.13.5
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
vclient/__init__.py CHANGED
@@ -31,7 +31,7 @@ _logger.add(
31
31
  # Forward TRACE too (e.g. the "Send request" line) and let the consumer's
32
32
  # stdlib logging level decide what to show; the sink must not pre-filter it.
33
33
  level="TRACE",
34
- filter=lambda record: record["name"].startswith("vclient"),
34
+ filter=lambda record: record["name"].startswith("vclient"), # ty: ignore[unresolved-attribute]
35
35
  )
36
36
 
37
37
  _logger.disable("vclient")
@@ -116,4 +116,4 @@ __all__ = (
116
116
  "users_service",
117
117
  )
118
118
 
119
- __version__ = "3.2.0"
119
+ __version__ = "3.4.0"
@@ -127,6 +127,7 @@ class SyncCampaignsService(SyncBaseService):
127
127
  request: A CampaignCreate model, OR pass fields as keyword arguments.
128
128
  **kwargs: Fields for CampaignCreate if request is not provided.
129
129
  Accepts: name (str, required), description (str | None),
130
+ in_game_date (datetime.date | None, ISO 8601 calendar date),
130
131
  desperation (int, default 0), danger (int, default 0).
131
132
 
132
133
  Returns:
@@ -154,7 +155,8 @@ class SyncCampaignsService(SyncBaseService):
154
155
  request: A CampaignUpdate model, OR pass fields as keyword arguments.
155
156
  **kwargs: Fields for CampaignUpdate if request is not provided.
156
157
  Accepts: name (str | None), description (str | None),
157
- desperation (int | None), danger (int | None).
158
+ in_game_date (datetime.date | None, ISO 8601 calendar date;
159
+ pass None to clear it), desperation (int | None), danger (int | None).
158
160
 
159
161
  Returns:
160
162
  The updated Campaign object.
@@ -166,9 +168,11 @@ class SyncCampaignsService(SyncBaseService):
166
168
  ValidationError: If the request data is invalid.
167
169
  """
168
170
  body = request if request is not None else self._validate_request(CampaignUpdate, **kwargs)
171
+ payload = body.model_dump(exclude_none=True, exclude_unset=True, mode="json")
172
+ if "in_game_date" in body.model_fields_set and body.in_game_date is None:
173
+ payload["in_game_date"] = None
169
174
  response = self._patch(
170
- self._format_endpoint(Endpoints.CAMPAIGN, campaign_id=campaign_id),
171
- json=body.model_dump(exclude_none=True, exclude_unset=True, mode="json"),
175
+ self._format_endpoint(Endpoints.CAMPAIGN, campaign_id=campaign_id), json=payload
172
176
  )
173
177
  return Campaign.model_validate(response.json())
174
178
 
@@ -5,15 +5,21 @@ from collections.abc import Iterator
5
5
  from typing import TYPE_CHECKING
6
6
 
7
7
  from vclient._sync.services.base import SyncBaseService
8
- from vclient.constants import DEFAULT_PAGE_LIMIT, TraitModifyCurrency
8
+ from vclient.constants import DEFAULT_PAGE_LIMIT, DamageType, TrackerAction, TraitModifyCurrency
9
9
  from vclient.endpoints import Endpoints
10
10
  from vclient.models import (
11
+ _TRACKER_ADAPTER,
11
12
  BulkAssignTraitResponse,
12
13
  CharacterTrait,
13
14
  CharacterTraitAdd,
15
+ CharacterTraitSpecialtyCreate,
16
+ CharacterTraitSpecialtyUpdate,
14
17
  CharacterTraitValueOptionsResponse,
15
18
  PaginatedResponse,
19
+ Tracker,
20
+ TrackerStateSet,
16
21
  TraitCreate,
22
+ _TrackerAction,
17
23
  _TraitModify,
18
24
  )
19
25
 
@@ -140,6 +146,8 @@ class SyncCharacterTraitsService(SyncBaseService):
140
146
  def delete(self, character_trait_id: str, currency: TraitModifyCurrency | None = None) -> None:
141
147
  """Delete a character trait.
142
148
 
149
+ Any specialties on the trait are removed with it and the cost of each is refunded.
150
+
143
151
  Args:
144
152
  character_trait_id: The ID of the trait to delete.
145
153
  currency: The currency to use to recoup the cost of the trait.
@@ -152,6 +160,8 @@ class SyncCharacterTraitsService(SyncBaseService):
152
160
  AuthorizationError: If you don't have access to the character.
153
161
  RequestValidationError: If the input parameters fail client-side validation.
154
162
  ValidationError: If the request data is invalid.
163
+ ConflictError: If recouping ``XP`` or ``STARTING_POINTS`` and another request
164
+ changed the trait's value first. Refetch the trait and retry.
155
165
  """
156
166
  params: dict[str, str | int] = {}
157
167
  if currency is not None:
@@ -177,6 +187,8 @@ class SyncCharacterTraitsService(SyncBaseService):
177
187
  AuthorizationError: If you don't have access to the character.
178
188
  RequestValidationError: If the input parameters fail client-side validation.
179
189
  ValidationError: If the request data is invalid.
190
+ ConflictError: If paying with ``XP`` or ``STARTING_POINTS`` and another request
191
+ changed the trait's value first. Refetch the trait and retry.
180
192
  """
181
193
  body = self._validate_request(
182
194
  CharacterTraitAdd, trait_id=trait_id, value=value, currency=currency
@@ -222,8 +234,16 @@ class SyncCharacterTraitsService(SyncBaseService):
222
234
  **kwargs: Fields for TraitCreate if request is not provided.
223
235
  Required: name (str), category_id (str), currency (TraitModifyCurrency).
224
236
  Optional: description (str | None), max_value (int), min_value (int),
225
- show_when_zero (bool), initial_cost (int | None),
226
- upgrade_cost (int | None).
237
+ show_when_zero (bool), is_rollable (bool, default True),
238
+ initial_cost (int | None), upgrade_cost (int | None),
239
+ count_based_cost_multiplier (int | None).
240
+
241
+ Returns:
242
+ The newly created CharacterTrait object.
243
+
244
+ Raises:
245
+ ConflictError: If paying with ``XP`` or ``STARTING_POINTS`` and another request
246
+ changed the trait's value first. Refetch the trait and retry.
227
247
  """
228
248
  body = request if request is not None else self._validate_request(TraitCreate, **kwargs)
229
249
  response = self._post(
@@ -259,10 +279,20 @@ class SyncCharacterTraitsService(SyncBaseService):
259
279
  ) -> CharacterTrait:
260
280
  """Change the value of a character trait.
261
281
 
282
+ Lowering an ability to ``0`` also removes its specialties and refunds the cost of
283
+ each. The value change and the refunds are applied together or not at all.
284
+
262
285
  Args:
263
286
  character_trait_id: The ID of the trait to change the value of.
264
287
  new_value: The new value of the trait.
265
288
  currency: The currency to use for the modification.
289
+
290
+ Returns:
291
+ The updated CharacterTrait object.
292
+
293
+ Raises:
294
+ ConflictError: If another request changed the trait's value after this request
295
+ read it. Nothing is spent or refunded. Refetch the trait and retry.
266
296
  """
267
297
  body = self._validate_request(_TraitModify, target_value=new_value, currency=currency)
268
298
  response = self._put(
@@ -272,3 +302,197 @@ class SyncCharacterTraitsService(SyncBaseService):
272
302
  json=body.model_dump(exclude_none=True, exclude_unset=True, mode="json"),
273
303
  )
274
304
  return CharacterTrait.model_validate(response.json())
305
+
306
+ def add_specialty(
307
+ self, character_trait_id: str, name: str, currency: TraitModifyCurrency = "NO_COST"
308
+ ) -> CharacterTrait:
309
+ """Add a specialty to an ability trait.
310
+
311
+ Specialties are only available on abilities with a value of 1 or higher. When the
312
+ company's ``specialty_xp_cost_enabled`` setting is on, a specialty paid with ``XP``
313
+ or ``STARTING_POINTS`` costs 3 points, and ``NO_COST`` follows the company's
314
+ ``permission_free_trait_changes`` setting. When the setting is off, specialties are free.
315
+
316
+ Args:
317
+ character_trait_id: The ID of the ability trait.
318
+ name: The specialty name (3-50 characters), unique on the trait ignoring case.
319
+ currency: The currency to use to pay for the specialty.
320
+
321
+ Returns:
322
+ The updated CharacterTrait object, including its specialties.
323
+
324
+ Raises:
325
+ NotFoundError: If the trait does not exist.
326
+ AuthorizationError: If you don't have access to the character.
327
+ RequestValidationError: If the input parameters fail client-side validation.
328
+ ValidationError: If the trait is not an ability, has a value of 0, already has a
329
+ specialty with this name, the character cannot afford the cost, or an
330
+ ``NPC`` or ``STORYTELLER`` character pays with anything but ``NO_COST``.
331
+ """
332
+ body = self._validate_request(CharacterTraitSpecialtyCreate, name=name, currency=currency)
333
+ response = self._post(
334
+ self._format_endpoint(
335
+ Endpoints.CHARACTER_TRAIT_SPECIALTIES, character_trait_id=character_trait_id
336
+ ),
337
+ json=body.model_dump(exclude_none=True, mode="json"),
338
+ )
339
+ return CharacterTrait.model_validate(response.json())
340
+
341
+ def update_specialty(
342
+ self, character_trait_id: str, specialty_id: str, name: str
343
+ ) -> CharacterTrait:
344
+ """Rename a specialty on an ability trait.
345
+
346
+ Renaming never changes the cost paid or the currency used.
347
+
348
+ Args:
349
+ character_trait_id: The ID of the ability trait.
350
+ specialty_id: The ID of the specialty to rename.
351
+ name: The new specialty name (3-50 characters), unique on the trait ignoring case.
352
+
353
+ Returns:
354
+ The updated CharacterTrait object, including its specialties.
355
+
356
+ Raises:
357
+ NotFoundError: If the trait or specialty does not exist, or the specialty
358
+ belongs to a different trait.
359
+ AuthorizationError: If you don't have access to the character.
360
+ RequestValidationError: If the input parameters fail client-side validation.
361
+ ValidationError: If the trait already has a specialty with this name.
362
+ """
363
+ body = self._validate_request(CharacterTraitSpecialtyUpdate, name=name)
364
+ response = self._patch(
365
+ self._format_endpoint(
366
+ Endpoints.CHARACTER_TRAIT_SPECIALTY,
367
+ character_trait_id=character_trait_id,
368
+ specialty_id=specialty_id,
369
+ ),
370
+ json=body.model_dump(exclude_none=True, mode="json"),
371
+ )
372
+ return CharacterTrait.model_validate(response.json())
373
+
374
+ def delete_specialty(self, character_trait_id: str, specialty_id: str) -> None:
375
+ """Remove a specialty from an ability trait.
376
+
377
+ The cost paid is refunded in the currency used to buy it. An ``XP`` refund follows
378
+ the company's ``permission_recoup_xp`` setting; starting points are always refunded.
379
+
380
+ Args:
381
+ character_trait_id: The ID of the ability trait.
382
+ specialty_id: The ID of the specialty to remove.
383
+
384
+ Returns:
385
+ None
386
+
387
+ Raises:
388
+ NotFoundError: If the trait or specialty does not exist, or the specialty
389
+ belongs to a different trait.
390
+ AuthorizationError: If you don't have access to the character.
391
+ """
392
+ self._delete(
393
+ self._format_endpoint(
394
+ Endpoints.CHARACTER_TRAIT_SPECIALTY,
395
+ character_trait_id=character_trait_id,
396
+ specialty_id=specialty_id,
397
+ )
398
+ )
399
+
400
+ def get_tracker(self, character_trait_id: str) -> Tracker:
401
+ """Get the in-play tracker state of a character trait.
402
+
403
+ The same object appears as ``CharacterTrait.tracker``. Use this to refresh one
404
+ tracker without fetching the whole trait.
405
+
406
+ Args:
407
+ character_trait_id: The ID of the character trait.
408
+
409
+ Returns:
410
+ A CounterTracker, DamageTracker, or StainsTracker. Switch on ``type``.
411
+
412
+ Raises:
413
+ NotFoundError: If the trait has no tracker for the character's game version.
414
+ AuthorizationError: If you don't have access to the character.
415
+ """
416
+ response = self._get(
417
+ self._format_endpoint(
418
+ Endpoints.CHARACTER_TRAIT_TRACKER, character_trait_id=character_trait_id
419
+ )
420
+ )
421
+ return _TRACKER_ADAPTER.validate_python(response.json())
422
+
423
+ def set_tracker(
424
+ self, character_trait_id: str, request: TrackerStateSet | None = None, **kwargs
425
+ ) -> Tracker:
426
+ """Replace the full state of a trait tracker, such as to correct a mistake.
427
+
428
+ Send only the fields for the tracker's type: ``current`` for counters (required),
429
+ ``superficial`` and ``aggravated`` for damage tracks, ``stains`` for stains. Omitted
430
+ fields reset to 0, so an empty request clears a damage or stains tracker. Use
431
+ ``apply_tracker_action`` for in-play changes instead.
432
+
433
+ Args:
434
+ character_trait_id: The ID of the character trait.
435
+ request: A TrackerStateSet model, OR pass fields as keyword arguments.
436
+ **kwargs: Fields for TrackerStateSet if request is not provided.
437
+ Accepts: current, superficial, aggravated, stains (int >= 0).
438
+
439
+ Returns:
440
+ The updated tracker.
441
+
442
+ Raises:
443
+ NotFoundError: If the trait has no tracker for the character's game version.
444
+ AuthorizationError: If you are not the character's player or a storyteller.
445
+ RequestValidationError: If a value is negative.
446
+ ValidationError: If a field does not fit the tracker type, a counter has no
447
+ ``current``, or a value exceeds the tracker size.
448
+ """
449
+ body = request if request is not None else self._validate_request(TrackerStateSet, **kwargs)
450
+ response = self._put(
451
+ self._format_endpoint(
452
+ Endpoints.CHARACTER_TRAIT_TRACKER, character_trait_id=character_trait_id
453
+ ),
454
+ json=body.model_dump(exclude_none=True, mode="json"),
455
+ )
456
+ return _TRACKER_ADAPTER.validate_python(response.json())
457
+
458
+ def apply_tracker_action(
459
+ self,
460
+ character_trait_id: str,
461
+ action: TrackerAction,
462
+ *,
463
+ amount: int = 1,
464
+ damage_type: DamageType | None = None,
465
+ ) -> Tracker:
466
+ """Apply one in-play action to a trait tracker, such as spending Willpower.
467
+
468
+ The server applies the game rules for the character's game version. Which actions
469
+ and damage types fit each tracker is listed in ``characters.TrackerRules`` from
470
+ ``options.get_options()``. A STAINS response reports overflow into Willpower in
471
+ ``willpower_damage_applied``.
472
+
473
+ Args:
474
+ character_trait_id: The ID of the character trait.
475
+ action: The action to apply.
476
+ amount: How many points to apply. Must be at least 1.
477
+ damage_type: The damage type for ``damage`` and ``heal`` on damage tracks.
478
+
479
+ Returns:
480
+ The updated tracker.
481
+
482
+ Raises:
483
+ NotFoundError: If the trait has no tracker for the character's game version.
484
+ AuthorizationError: If you are not the character's player or a storyteller.
485
+ RequestValidationError: If ``amount`` is less than 1.
486
+ ValidationError: If the action or damage type does not fit the tracker, a
487
+ damage type is missing or not allowed, or a spend exceeds the current value.
488
+ """
489
+ body = self._validate_request(
490
+ _TrackerAction, action=action, amount=amount, damage_type=damage_type
491
+ )
492
+ response = self._post(
493
+ self._format_endpoint(
494
+ Endpoints.CHARACTER_TRAIT_TRACKER_ACTIONS, character_trait_id=character_trait_id
495
+ ),
496
+ json=body.model_dump(exclude_none=True, mode="json"),
497
+ )
498
+ return _TRACKER_ADAPTER.validate_python(response.json())
@@ -12,6 +12,8 @@ from vclient.constants import (
12
12
  CharacterInclude,
13
13
  CharacterStatus,
14
14
  CharacterType,
15
+ DamageType,
16
+ TrackerAction,
15
17
  )
16
18
  from vclient.endpoints import Endpoints
17
19
  from vclient.models import (
@@ -22,6 +24,8 @@ from vclient.models import (
22
24
  CharacterFullSheet,
23
25
  CharacterUpdate,
24
26
  FullSheetTraitCategory,
27
+ Health,
28
+ HealthStateSet,
25
29
  InventoryItem,
26
30
  InventoryItemCreate,
27
31
  InventoryItemUpdate,
@@ -30,6 +34,7 @@ from vclient.models import (
30
34
  NoteUpdate,
31
35
  PaginatedResponse,
32
36
  RollStatistics,
37
+ _TrackerAction,
33
38
  )
34
39
 
35
40
  if TYPE_CHECKING:
@@ -241,6 +246,7 @@ class SyncCharactersService(SyncBaseService):
241
246
  Required: campaign_id (str), character_class (CharacterClass),
242
247
  game_version (GameVersion), name_first (str), name_last (str).
243
248
  Optional: type (CharacterType), name_nick (str), age (int),
249
+ date_of_birth (datetime.date | None, ISO 8601 calendar date),
244
250
  biography (str), demeanor (str), nature (str), concept_id (str),
245
251
  user_player_id (str), traits (list), vampire_attributes, etc.
246
252
 
@@ -274,7 +280,8 @@ class SyncCharactersService(SyncBaseService):
274
280
  request: A CharacterUpdate model, OR pass fields as keyword arguments.
275
281
  **kwargs: Fields for CharacterUpdate if request is not provided.
276
282
  All fields are optional: character_class, type, game_version, status,
277
- name_first, name_last, name_nick, age, biography, demeanor, nature,
283
+ name_first, name_last, name_nick, age, date_of_birth (datetime.date | None,
284
+ ISO 8601 calendar date; pass None to clear it), biography, demeanor, nature,
278
285
  concept_id, user_player_id, vampire_attributes, werewolf_attributes, etc.
279
286
 
280
287
  Returns:
@@ -287,9 +294,11 @@ class SyncCharactersService(SyncBaseService):
287
294
  ValidationError: If the request data is invalid.
288
295
  """
289
296
  body = request if request is not None else self._validate_request(CharacterUpdate, **kwargs)
297
+ payload = body.model_dump(exclude_none=True, exclude_unset=True, mode="json")
298
+ if "date_of_birth" in body.model_fields_set and body.date_of_birth is None:
299
+ payload["date_of_birth"] = None
290
300
  response = self._patch(
291
- self._format_endpoint(Endpoints.CHARACTER, character_id=character_id),
292
- json=body.model_dump(exclude_none=True, exclude_unset=True, mode="json"),
301
+ self._format_endpoint(Endpoints.CHARACTER, character_id=character_id), json=payload
293
302
  )
294
303
  return Character.model_validate(response.json())
295
304
 
@@ -869,3 +878,94 @@ class SyncCharactersService(SyncBaseService):
869
878
  Endpoints.CHARACTER_INVENTORY_ITEM, character_id=character_id, item_id=item_id
870
879
  )
871
880
  )
881
+
882
+ def get_health(self, character_id: str) -> Health:
883
+ """Get a character's health track.
884
+
885
+ The same object appears as ``Character.health``. Re-fetch it after changing V5
886
+ Stamina, which sets the track size.
887
+
888
+ Args:
889
+ character_id: The ID of the character.
890
+
891
+ Returns:
892
+ The character's Health.
893
+
894
+ Raises:
895
+ NotFoundError: If the character does not exist or has no health track.
896
+ AuthorizationError: If you don't have access to the character.
897
+ """
898
+ response = self._get(
899
+ self._format_endpoint(Endpoints.CHARACTER_HEALTH, character_id=character_id)
900
+ )
901
+ return Health.model_validate(response.json())
902
+
903
+ def set_health(
904
+ self, character_id: str, request: HealthStateSet | None = None, **kwargs
905
+ ) -> Health:
906
+ """Replace a character's full health state, such as to correct a mistake.
907
+
908
+ Every field is sent, so omitted fields reset to 0. V4 uses ``bashing``,
909
+ ``lethal``, and ``aggravated``; V5 uses ``superficial`` and ``aggravated``.
910
+ ``extra_levels`` applies to both. Use ``apply_health_action`` for in-play changes.
911
+
912
+ Args:
913
+ character_id: The ID of the character.
914
+ request: A HealthStateSet model, OR pass fields as keyword arguments.
915
+ **kwargs: Fields for HealthStateSet if request is not provided.
916
+ Accepts: extra_levels, superficial, aggravated, bashing, lethal (int >= 0).
917
+
918
+ Returns:
919
+ The updated Health.
920
+
921
+ Raises:
922
+ NotFoundError: If the character does not exist or has no health track.
923
+ AuthorizationError: If you are not the character's player or a storyteller.
924
+ RequestValidationError: If a value is negative.
925
+ ValidationError: If a nonzero field is not used by the game version, or the
926
+ marks exceed the track size.
927
+ """
928
+ body = request if request is not None else self._validate_request(HealthStateSet, **kwargs)
929
+ response = self._put(
930
+ self._format_endpoint(Endpoints.CHARACTER_HEALTH, character_id=character_id),
931
+ json=body.model_dump(mode="json"),
932
+ )
933
+ return Health.model_validate(response.json())
934
+
935
+ def apply_health_action(
936
+ self,
937
+ character_id: str,
938
+ action: TrackerAction,
939
+ *,
940
+ amount: int = 1,
941
+ damage_type: DamageType | None = None,
942
+ ) -> Health:
943
+ """Apply one in-play action to a character's health, such as taking damage.
944
+
945
+ Health accepts ``damage``, ``heal``, and ``reset``. V4 damage types are
946
+ ``BASHING``, ``LETHAL``, and ``AGGRAVATED``; V5 damage types are ``SUPERFICIAL``
947
+ and ``AGGRAVATED``. The server applies overflow and upgrade rules.
948
+
949
+ Args:
950
+ character_id: The ID of the character.
951
+ action: The action to apply.
952
+ amount: How many points to apply. Must be at least 1.
953
+ damage_type: The damage type for ``damage`` and ``heal``.
954
+
955
+ Returns:
956
+ The updated Health.
957
+
958
+ Raises:
959
+ NotFoundError: If the character does not exist or has no health track.
960
+ AuthorizationError: If you are not the character's player or a storyteller.
961
+ RequestValidationError: If ``amount`` is less than 1.
962
+ ValidationError: If the action or damage type does not fit the game version.
963
+ """
964
+ body = self._validate_request(
965
+ _TrackerAction, action=action, amount=amount, damage_type=damage_type
966
+ )
967
+ response = self._post(
968
+ self._format_endpoint(Endpoints.CHARACTER_HEALTH_ACTIONS, character_id=character_id),
969
+ json=body.model_dump(exclude_none=True, mode="json"),
970
+ )
971
+ return Health.model_validate(response.json())
@@ -2,7 +2,7 @@
2
2
  """Options and enumerations service."""
3
3
 
4
4
  import json
5
- from typing import TYPE_CHECKING
5
+ from typing import TYPE_CHECKING, Any
6
6
 
7
7
  from vclient._sync.services.base import SyncBaseService
8
8
  from vclient.endpoints import Endpoints
@@ -32,9 +32,14 @@ class SyncOptionsService(SyncBaseService):
32
32
  """Format an endpoint with the scoped company_id plus any extra params."""
33
33
  return endpoint.format(company_id=self._company_id, **kwargs)
34
34
 
35
- def get_options(self) -> dict[str, dict[str, list[str] | dict[str, str]]]:
35
+ def get_options(self) -> dict[str, dict[str, Any]]:
36
36
  """Retrieve all options and enumerations for the api.
37
37
 
38
+ Most values are lists of enum values. ``characters.TrackerRules`` is a nested map
39
+ ``{game_version: {tracker_key: {"actions": [...], "damage_types": [...]}}}`` where
40
+ ``tracker_key`` is a tracker type or ``HEALTH``. Use it to choose which tracker
41
+ actions and damage types to offer instead of hardcoding them.
42
+
38
43
  Returns:
39
44
  A dictionary of options and enumerations for the api.
40
45
  """