valentina-python-client 1.1.1__tar.gz → 1.2.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 (47) hide show
  1. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/PKG-INFO +23 -1
  2. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/README.md +22 -0
  3. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/pyproject.toml +7 -2
  4. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/client.py +38 -8
  5. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/constants.py +4 -0
  6. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/endpoints.py +3 -3
  7. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/models/users.py +1 -1
  8. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/services/character_blueprint.py +1 -1
  9. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/services/character_traits.py +1 -1
  10. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/services/users.py +9 -3
  11. valentina_python_client-1.2.0/src/vclient/validate_constants.py +237 -0
  12. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/LICENSE +0 -0
  13. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/__init__.py +0 -0
  14. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/config.py +0 -0
  15. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/exceptions.py +0 -0
  16. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/models/__init__.py +0 -0
  17. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/models/books.py +0 -0
  18. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/models/campaigns.py +0 -0
  19. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/models/chapters.py +0 -0
  20. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/models/character_autogen.py +0 -0
  21. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/models/character_blueprint.py +0 -0
  22. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/models/character_trait.py +0 -0
  23. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/models/characters.py +0 -0
  24. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/models/companies.py +0 -0
  25. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/models/developers.py +0 -0
  26. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/models/diceroll.py +0 -0
  27. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/models/dictionary.py +0 -0
  28. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/models/global_admin.py +0 -0
  29. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/models/pagination.py +0 -0
  30. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/models/shared.py +0 -0
  31. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/models/system.py +0 -0
  32. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/py.typed +0 -0
  33. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/registry.py +0 -0
  34. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/services/__init__.py +0 -0
  35. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/services/base.py +0 -0
  36. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/services/campaign_book_chapters.py +0 -0
  37. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/services/campaign_books.py +0 -0
  38. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/services/campaigns.py +0 -0
  39. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/services/character_autogen.py +0 -0
  40. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/services/characters.py +0 -0
  41. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/services/companies.py +0 -0
  42. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/services/developers.py +0 -0
  43. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/services/dicerolls.py +0 -0
  44. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/services/dictionary.py +0 -0
  45. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/services/global_admin.py +0 -0
  46. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/services/options.py +0 -0
  47. {valentina_python_client-1.1.1 → valentina_python_client-1.2.0}/src/vclient/services/system.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: valentina-python-client
3
- Version: 1.1.1
3
+ Version: 1.2.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>
@@ -43,6 +43,28 @@ This client is a supported and up-to-date reference implementation for the Valen
43
43
 
44
44
  For complete documentation including configuration options, all available services, response models, and error handling, see the **[Full Documentation](https://docs.valentina-noir.com/python-api-client/)**.
45
45
 
46
+ ## Development Tools
47
+
48
+ ### Validate Constants
49
+
50
+ Verify that the `Literal` type constants in this package are in sync with the live API's `/options` endpoint. This catches drift between client and server before a release.
51
+
52
+ ```bash
53
+ # Via duty task
54
+ uv run duty validate_constants
55
+
56
+ # Via script directly
57
+ uv run python scripts/validate_constants.py --api-key <key> --company-id <id>
58
+ ```
59
+
60
+ The script reads configuration from (highest precedence first):
61
+
62
+ 1. CLI arguments (`--api-url`, `--api-key`, `--company-id`)
63
+ 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
+
66
+ Exit codes: `0` = all constants match, `1` = mismatches found, `2` = missing configuration.
67
+
46
68
  ## Resources
47
69
 
48
70
  - [Full Client Documentation](https://docs.valentina-noir.com/python-api-client/)
@@ -18,6 +18,28 @@ This client is a supported and up-to-date reference implementation for the Valen
18
18
 
19
19
  For complete documentation including configuration options, all available services, response models, and error handling, see the **[Full Documentation](https://docs.valentina-noir.com/python-api-client/)**.
20
20
 
21
+ ## Development Tools
22
+
23
+ ### Validate Constants
24
+
25
+ Verify that the `Literal` type constants in this package are in sync with the live API's `/options` endpoint. This catches drift between client and server before a release.
26
+
27
+ ```bash
28
+ # Via duty task
29
+ uv run duty validate_constants
30
+
31
+ # Via script directly
32
+ uv run python scripts/validate_constants.py --api-key <key> --company-id <id>
33
+ ```
34
+
35
+ The script reads configuration from (highest precedence first):
36
+
37
+ 1. CLI arguments (`--api-url`, `--api-key`, `--company-id`)
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
40
+
41
+ Exit codes: `0` = all constants match, `1` = mismatches found, `2` = missing configuration.
42
+
21
43
  ## Resources
22
44
 
23
45
  - [Full Client Documentation](https://docs.valentina-noir.com/python-api-client/)
@@ -10,7 +10,7 @@
10
10
  name = "valentina-python-client"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.13"
13
- version = "1.1.1"
13
+ version = "1.2.0"
14
14
 
15
15
  [project.urls]
16
16
  Homepage = "https://docs.valentina-noir.com/python-api-client/"
@@ -140,8 +140,13 @@
140
140
  "TD002", # Missing author in TODO
141
141
  "TD003", # Missing issue link on the line following this TODO
142
142
  ]
143
- per-file-ignores = { "src/vclient/services/*.py" = [
143
+ per-file-ignores = { "scripts/*.py" = [
144
+ "INP001", # Implicit namespace package (no __init__.py needed)
145
+ "T201", # print() is intentional for CLI output
146
+ ], "src/vclient/services/*.py" = [
144
147
  "PLR0913", # Too many arguments
148
+ ], "src/vclient/validate_constants.py" = [
149
+ "T201", # print() is intentional for report output
145
150
  ], "tests/**/*.py" = [
146
151
  "A002",
147
152
  "A003",
@@ -1,5 +1,6 @@
1
1
  """Main API client for Valentina."""
2
2
 
3
+ import os
3
4
  from types import TracebackType
4
5
  from typing import TYPE_CHECKING, Self
5
6
 
@@ -11,6 +12,9 @@ from vclient.constants import (
11
12
  DEFAULT_MAX_RETRIES,
12
13
  DEFAULT_RETRY_DELAY,
13
14
  DEFAULT_TIMEOUT,
15
+ ENV_API_KEY,
16
+ ENV_BASE_URL,
17
+ ENV_DEFAULT_COMPANY_ID,
14
18
  )
15
19
 
16
20
  if TYPE_CHECKING:
@@ -64,8 +68,8 @@ class VClient:
64
68
 
65
69
  def __init__( # noqa: PLR0913
66
70
  self,
67
- base_url: str,
68
- api_key: str,
71
+ base_url: str | None = None,
72
+ api_key: str | None = None,
69
73
  *,
70
74
  timeout: float = DEFAULT_TIMEOUT,
71
75
  max_retries: int = DEFAULT_MAX_RETRIES,
@@ -78,9 +82,18 @@ class VClient:
78
82
  ) -> None:
79
83
  """Initialize the API client.
80
84
 
85
+ Values for ``base_url``, ``api_key``, and ``default_company_id`` can be
86
+ provided as constructor arguments or via environment variables. Explicit
87
+ arguments always take precedence over environment variables.
88
+
89
+ Environment variables:
90
+ VALENTINA_CLIENT_BASE_URL: Base URL for the API.
91
+ VALENTINA_CLIENT_API_KEY: API key for authentication.
92
+ VALENTINA_CLIENT_DEFAULT_COMPANY_ID: Default company ID.
93
+
81
94
  Args:
82
- base_url: Base URL for the API.
83
- api_key: API key for authentication.
95
+ base_url: Base URL for the API. Falls back to VALENTINA_CLIENT_BASE_URL.
96
+ api_key: API key for authentication. Falls back to VALENTINA_CLIENT_API_KEY.
84
97
  timeout: Request timeout in seconds.
85
98
  max_retries: Maximum number of retry attempts for failed requests.
86
99
  retry_delay: Base delay between retries in seconds.
@@ -88,21 +101,38 @@ class VClient:
88
101
  auto_idempotency_keys: Automatically generate idempotency keys for
89
102
  POST/PUT/PATCH requests.
90
103
  default_company_id: Default company ID to use when not explicitly provided
91
- to service factory methods.
104
+ to service factory methods. Falls back to
105
+ VALENTINA_CLIENT_DEFAULT_COMPANY_ID.
92
106
  headers: Additional headers to include with all requests.
93
107
  set_as_default: If True, register this client as the default for factory
94
108
  functions. Set to False when creating multiple clients or when using
95
109
  the context manager pattern exclusively.
110
+
111
+ Raises:
112
+ ValueError: If base_url or api_key is not provided and the corresponding
113
+ environment variable is not set.
96
114
  """
115
+ resolved_base_url = base_url or os.environ.get(ENV_BASE_URL)
116
+ if resolved_base_url is None:
117
+ msg = "base_url is required (set it directly or via the VALENTINA_CLIENT_BASE_URL environment variable)"
118
+ raise ValueError(msg)
119
+
120
+ resolved_api_key = api_key or os.environ.get(ENV_API_KEY)
121
+ if resolved_api_key is None:
122
+ msg = "api_key is required (set it directly or via the VALENTINA_CLIENT_API_KEY environment variable)"
123
+ raise ValueError(msg)
124
+
125
+ resolved_company_id = default_company_id or os.environ.get(ENV_DEFAULT_COMPANY_ID)
126
+
97
127
  self._config = _APIConfig(
98
- base_url=base_url,
99
- api_key=api_key,
128
+ base_url=resolved_base_url,
129
+ api_key=resolved_api_key,
100
130
  timeout=timeout,
101
131
  max_retries=max_retries,
102
132
  retry_delay=retry_delay,
103
133
  auto_retry_rate_limit=auto_retry_rate_limit,
104
134
  auto_idempotency_keys=auto_idempotency_keys,
105
- default_company_id=default_company_id,
135
+ default_company_id=resolved_company_id,
106
136
  headers=headers or {},
107
137
  )
108
138
 
@@ -5,6 +5,10 @@ from typing import Literal
5
5
  # Authentication
6
6
  API_KEY_HEADER = "X-API-KEY"
7
7
 
8
+ # Environment variable names
9
+ ENV_BASE_URL = "VALENTINA_CLIENT_BASE_URL"
10
+ ENV_API_KEY = "VALENTINA_CLIENT_API_KEY"
11
+ ENV_DEFAULT_COMPANY_ID = "VALENTINA_CLIENT_DEFAULT_COMPANY_ID"
8
12
 
9
13
  # Request defaults
10
14
  DEFAULT_TIMEOUT = 30.0
@@ -124,12 +124,12 @@ class Endpoints:
124
124
  BLUEPRINT_TRAIT_DETAIL = f"{BLUEPRINT_TRAITS}/{{trait_id}}"
125
125
  CONCEPTS = f"{BLUEPRINT_BASE}/concepts"
126
126
  CONCEPT_DETAIL = f"{CONCEPTS}/{{concept_id}}"
127
- VAMPIRE_CLANS = f"{BLUEPRINT_BASE}/vampireclans"
127
+ VAMPIRE_CLANS = f"{BLUEPRINT_BASE}/vampire-clans"
128
128
  VAMPIRE_CLAN_DETAIL = f"{VAMPIRE_CLANS}/{{vampire_clan_id}}"
129
129
  WEREWOLF_TRIBES = f"{BLUEPRINT_BASE}/werewolf-tribes"
130
130
  WEREWOLF_TRIBE_DETAIL = f"{WEREWOLF_TRIBES}/{{werewolf_tribe_id}}"
131
131
  WEREWOLF_AUSPICES = f"{BLUEPRINT_BASE}/werewolf-auspices"
132
- WEREWOLF_AUSPIE_DETAIL = f"{WEREWOLF_AUSPICES}/{{werewolf_auspice_id}}"
132
+ WEREWOLF_AUSPICE_DETAIL = f"{WEREWOLF_AUSPICES}/{{werewolf_auspice_id}}"
133
133
  WEREWOLF_GIFTS = f"{BLUEPRINT_BASE}/werewolf-gifts"
134
134
  WEREWOLF_GIFT_DETAIL = f"{WEREWOLF_GIFTS}/{{werewolf_gift_id}}"
135
135
  WEREWOLF_RITES = f"{BLUEPRINT_BASE}/werewolf-rites"
@@ -140,7 +140,7 @@ class Endpoints:
140
140
  HUNTER_EDGE_PERK_DETAIL = f"{HUNTER_EDGE_PERKS}/{{hunter_edge_perk_id}}"
141
141
 
142
142
  # Dictionary endpoints
143
- DICTIONARY_TERMS = f"{COMPANY}/dictionary"
143
+ DICTIONARY_TERMS = f"{COMPANY}/dictionaries"
144
144
  DICTIONARY_TERM = f"{DICTIONARY_TERMS}/{{term_id}}"
145
145
 
146
146
  # Dice Rolls
@@ -151,8 +151,8 @@ class _ExperienceAddRemove(BaseModel):
151
151
  """
152
152
 
153
153
  amount: int
154
- user_id: str
155
154
  campaign_id: str
155
+ requesting_user_id: str
156
156
 
157
157
 
158
158
  __all__ = [
@@ -436,7 +436,7 @@ class CharacterBlueprintService(BaseService):
436
436
  """Get a werewolf auspice by ID."""
437
437
  response = await self._get(
438
438
  self._format_endpoint(
439
- Endpoints.WEREWOLF_AUSPIE_DETAIL, werewolf_auspice_id=werewolf_auspice_id
439
+ Endpoints.WEREWOLF_AUSPICE_DETAIL, werewolf_auspice_id=werewolf_auspice_id
440
440
  ),
441
441
  )
442
442
  return WerewolfAuspice.model_validate(response.json())
@@ -251,7 +251,7 @@ class CharacterTraitsService(BaseService):
251
251
  target_value=new_value,
252
252
  currency=currency,
253
253
  )
254
- response = await self._post(
254
+ response = await self._put(
255
255
  self._format_endpoint(
256
256
  Endpoints.CHARACTER_TRAIT_VALUE, character_trait_id=character_trait_id
257
257
  ),
@@ -413,6 +413,7 @@ class UsersService(BaseService):
413
413
  user_id: str,
414
414
  campaign_id: str,
415
415
  amount: int,
416
+ requesting_user_id: str,
416
417
  ) -> CampaignExperience:
417
418
  """Award experience points to a user for a specific campaign.
418
419
 
@@ -423,6 +424,7 @@ class UsersService(BaseService):
423
424
  user_id: The ID of the user to award XP to.
424
425
  campaign_id: The ID of the campaign to add XP for.
425
426
  amount: The amount of XP to add.
427
+ requesting_user_id: ID of the user making the request (for permissions).
426
428
 
427
429
  Returns:
428
430
  Updated CampaignExperience object.
@@ -435,8 +437,8 @@ class UsersService(BaseService):
435
437
  body = self._validate_request(
436
438
  _ExperienceAddRemove,
437
439
  amount=amount,
438
- user_id=user_id,
439
440
  campaign_id=campaign_id,
441
+ requesting_user_id=requesting_user_id,
440
442
  )
441
443
  response = await self._post(
442
444
  self._format_endpoint(Endpoints.USER_EXPERIENCE_XP_ADD, user_id=user_id),
@@ -449,6 +451,7 @@ class UsersService(BaseService):
449
451
  user_id: str,
450
452
  campaign_id: str,
451
453
  amount: int,
454
+ requesting_user_id: str,
452
455
  ) -> CampaignExperience:
453
456
  """Deduct experience points from a user's current XP pool.
454
457
 
@@ -458,6 +461,7 @@ class UsersService(BaseService):
458
461
  user_id: The ID of the user to remove XP from.
459
462
  campaign_id: The ID of the campaign to remove XP for.
460
463
  amount: The amount of XP to remove.
464
+ requesting_user_id: ID of the user making the request (for permissions).
461
465
 
462
466
  Returns:
463
467
  Updated CampaignExperience object.
@@ -471,8 +475,8 @@ class UsersService(BaseService):
471
475
  body = self._validate_request(
472
476
  _ExperienceAddRemove,
473
477
  amount=amount,
474
- user_id=user_id,
475
478
  campaign_id=campaign_id,
479
+ requesting_user_id=requesting_user_id,
476
480
  )
477
481
  response = await self._post(
478
482
  self._format_endpoint(Endpoints.USER_EXPERIENCE_XP_REMOVE, user_id=user_id),
@@ -485,6 +489,7 @@ class UsersService(BaseService):
485
489
  user_id: str,
486
490
  campaign_id: str,
487
491
  amount: int,
492
+ requesting_user_id: str,
488
493
  ) -> CampaignExperience:
489
494
  """Award cool points to a user for a specific campaign.
490
495
 
@@ -495,6 +500,7 @@ class UsersService(BaseService):
495
500
  user_id: The ID of the user to award cool points to.
496
501
  campaign_id: The ID of the campaign to add cool points for.
497
502
  amount: The amount of cool points to add.
503
+ requesting_user_id: ID of the user making the request (for permissions).
498
504
 
499
505
  Returns:
500
506
  Updated CampaignExperience object.
@@ -507,8 +513,8 @@ class UsersService(BaseService):
507
513
  body = self._validate_request(
508
514
  _ExperienceAddRemove,
509
515
  amount=amount,
510
- user_id=user_id,
511
516
  campaign_id=campaign_id,
517
+ requesting_user_id=requesting_user_id,
512
518
  )
513
519
  response = await self._post(
514
520
  self._format_endpoint(Endpoints.USER_EXPERIENCE_CP_ADD, user_id=user_id),
@@ -0,0 +1,237 @@
1
+ """Validate client constants against the API options endpoint.
2
+
3
+ Compare the Literal type constants defined in constants.py against the
4
+ values returned by the API's /options endpoint to detect drift between
5
+ client and server.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import typing
11
+ from dataclasses import dataclass, field
12
+
13
+ from vclient import constants
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ConstantMapping:
18
+ """Map a local constant name to its location in the API options response.
19
+
20
+ Args:
21
+ api_category: Top-level key in the options response (e.g., "characters").
22
+ api_option: Option name within that category (e.g., "CharacterClass").
23
+ """
24
+
25
+ api_category: str
26
+ api_option: str
27
+
28
+
29
+ @dataclass
30
+ class ConstantMismatch:
31
+ """Record of a single constant that differs between client and API.
32
+
33
+ Args:
34
+ constant_name: The local constant name in constants.py.
35
+ api_category: The API category this constant maps to.
36
+ api_option: The API option name this constant maps to.
37
+ missing_from_client: Values present in the API but not in the local Literal.
38
+ extra_in_client: Values present in the local Literal but not in the API.
39
+ """
40
+
41
+ constant_name: str
42
+ api_category: str
43
+ api_option: str
44
+ missing_from_client: set[str | int] = field(default_factory=set)
45
+ extra_in_client: set[str | int] = field(default_factory=set)
46
+
47
+
48
+ @dataclass
49
+ class ValidationResult:
50
+ """Result of validating client constants against the API.
51
+
52
+ Args:
53
+ is_valid: True if all mapped constants match and no unmapped API options exist.
54
+ mismatches: Constants with value differences between client and API.
55
+ unmapped_api_options: API option keys that have no corresponding local constant.
56
+ """
57
+
58
+ is_valid: bool
59
+ mismatches: list[ConstantMismatch] = field(default_factory=list)
60
+ unmapped_api_options: dict[str, list[str]] = field(default_factory=dict)
61
+
62
+
63
+ CONSTANT_MAP: dict[str, ConstantMapping] = {
64
+ "AbilityFocus": ConstantMapping("characters", "AbilityFocus"),
65
+ "AutoGenExperienceLevel": ConstantMapping("characters", "AutoGenExperienceLevel"),
66
+ "BlueprintTraitOrderBy": ConstantMapping("characters", "BlueprintTraitOrderBy"),
67
+ "CharacterClass": ConstantMapping("characters", "CharacterClass"),
68
+ "CharacterInventoryType": ConstantMapping("characters", "InventoryItemType"),
69
+ "CharacterStatus": ConstantMapping("characters", "CharacterStatus"),
70
+ "CharacterType": ConstantMapping("characters", "CharacterType"),
71
+ "DiceSize": ConstantMapping("gameplay", "DiceSize"),
72
+ "FreeTraitChangesPermission": ConstantMapping("companies", "PermissionsFreeTraitChanges"),
73
+ "GameVersion": ConstantMapping("characters", "GameVersion"),
74
+ "GrantXPPermission": ConstantMapping("companies", "PermissionsGrantXP"),
75
+ "HunterCreed": ConstantMapping("characters", "HunterCreed"),
76
+ "HunterEdgeType": ConstantMapping("characters", "HunterEdgeType"),
77
+ "ManageCampaignPermission": ConstantMapping("companies", "PermissionManageCampaign"),
78
+ "PermissionLevel": ConstantMapping("companies", "CompanyPermission"),
79
+ "RollResultType": ConstantMapping("gameplay", "RollResultType"),
80
+ "S3AssetParentType": ConstantMapping("assets", "AssetParentType"),
81
+ "S3AssetType": ConstantMapping("assets", "AssetType"),
82
+ "SpecialtyType": ConstantMapping("characters", "SpecialtyType"),
83
+ "TraitModifyCurrency": ConstantMapping("characters", "TraitModifyCurrency"),
84
+ "UserRole": ConstantMapping("users", "UserRole"),
85
+ "WerewolfRenown": ConstantMapping("characters", "WerewolfRenown"),
86
+ }
87
+
88
+
89
+ def validate(api_options: dict[str, dict[str, list | dict]]) -> ValidationResult:
90
+ """Compare local Literal constants against values from the API options endpoint.
91
+
92
+ Args:
93
+ api_options: The raw dictionary returned by OptionsService.get_options().
94
+
95
+ Returns:
96
+ ValidationResult with is_valid=True if all constants match, otherwise
97
+ populated with mismatches and unmapped API options.
98
+ """
99
+ mismatches: list[ConstantMismatch] = []
100
+ mapped_api_options: set[tuple[str, str]] = set()
101
+
102
+ for constant_name, mapping in CONSTANT_MAP.items():
103
+ mapped_api_options.add((mapping.api_category, mapping.api_option))
104
+
105
+ local_values = set(typing.get_args(getattr(constants, constant_name)))
106
+
107
+ category_data = api_options.get(mapping.api_category, {})
108
+ api_values_raw = category_data.get(mapping.api_option)
109
+ if api_values_raw is None:
110
+ mismatches.append(
111
+ ConstantMismatch(
112
+ constant_name=constant_name,
113
+ api_category=mapping.api_category,
114
+ api_option=mapping.api_option,
115
+ missing_from_client=set(),
116
+ extra_in_client=local_values,
117
+ )
118
+ )
119
+ continue
120
+
121
+ api_values = set(api_values_raw)
122
+ missing_from_client = api_values - local_values
123
+ extra_in_client = local_values - api_values
124
+
125
+ if missing_from_client or extra_in_client:
126
+ mismatches.append(
127
+ ConstantMismatch(
128
+ constant_name=constant_name,
129
+ api_category=mapping.api_category,
130
+ api_option=mapping.api_option,
131
+ missing_from_client=missing_from_client,
132
+ extra_in_client=extra_in_client,
133
+ )
134
+ )
135
+
136
+ unmapped_api_options: dict[str, list[str]] = {}
137
+ for category, options in api_options.items():
138
+ if not isinstance(options, dict):
139
+ continue
140
+ for option_name, option_values in options.items():
141
+ if option_name.startswith("_"):
142
+ continue
143
+ if not isinstance(option_values, list):
144
+ continue
145
+ if (category, option_name) not in mapped_api_options:
146
+ unmapped_api_options.setdefault(category, []).append(option_name)
147
+
148
+ is_valid = len(mismatches) == 0 and len(unmapped_api_options) == 0
149
+
150
+ return ValidationResult(
151
+ is_valid=is_valid,
152
+ mismatches=mismatches,
153
+ unmapped_api_options=unmapped_api_options,
154
+ )
155
+
156
+
157
+ def _print_status_lines(mismatched_names: set[str]) -> None:
158
+ """Print OK/FAIL status line for each constant in the mapping table."""
159
+ for constant_name in sorted(CONSTANT_MAP):
160
+ status = "FAIL" if constant_name in mismatched_names else "OK "
161
+ print(f" {status} {constant_name}")
162
+
163
+
164
+ def _print_mismatches(mismatches: list[ConstantMismatch]) -> None:
165
+ """Print detailed mismatch information for each failing constant."""
166
+ print()
167
+ print("-" * 60)
168
+ print("Mismatches:")
169
+ print("-" * 60)
170
+ for mismatch in mismatches:
171
+ print(f"\n {mismatch.constant_name}")
172
+ print(f" API: {mismatch.api_category}.{mismatch.api_option}")
173
+ if mismatch.missing_from_client:
174
+ print(f" Missing from client: {sorted(mismatch.missing_from_client)}")
175
+ if mismatch.extra_in_client:
176
+ print(f" Extra in client: {sorted(mismatch.extra_in_client)}")
177
+
178
+
179
+ def _print_unmapped(unmapped_api_options: dict[str, list[str]]) -> None:
180
+ """Print API options that have no corresponding local constant."""
181
+ print()
182
+ print("-" * 60)
183
+ print("Unmapped API options (no local constant):")
184
+ print("-" * 60)
185
+ for category, options in sorted(unmapped_api_options.items()):
186
+ for option in sorted(options):
187
+ print(f" {category}.{option}")
188
+
189
+
190
+ def _build_summary(
191
+ matched_count: int,
192
+ total: int,
193
+ mismatch_count: int,
194
+ unmapped_api_options: dict[str, list[str]],
195
+ ) -> str:
196
+ """Build the final summary line for the report."""
197
+ if mismatch_count == 0 and not unmapped_api_options:
198
+ return f" {matched_count}/{total} constants in sync"
199
+
200
+ summary_parts: list[str] = []
201
+ if mismatch_count:
202
+ summary_parts.append(f"{mismatch_count} mismatch(es)")
203
+ if unmapped_api_options:
204
+ unmapped_count = sum(len(v) for v in unmapped_api_options.values())
205
+ summary_parts.append(f"{unmapped_count} unmapped API option(s)")
206
+ return f" {matched_count}/{total} constants in sync, {', '.join(summary_parts)}"
207
+
208
+
209
+ def print_report(result: ValidationResult) -> None:
210
+ """Print a human-readable validation report to stdout.
211
+
212
+ Args:
213
+ result: The ValidationResult from validate().
214
+ """
215
+ total = len(CONSTANT_MAP)
216
+ mismatch_count = len(result.mismatches)
217
+ matched_count = total - mismatch_count
218
+ mismatched_names = {m.constant_name for m in result.mismatches}
219
+
220
+ print()
221
+ print("=" * 60)
222
+ print("Constants Validation Report")
223
+ print("=" * 60)
224
+
225
+ _print_status_lines(mismatched_names)
226
+
227
+ if result.mismatches:
228
+ _print_mismatches(result.mismatches)
229
+
230
+ if result.unmapped_api_options:
231
+ _print_unmapped(result.unmapped_api_options)
232
+
233
+ print()
234
+ print("=" * 60)
235
+ print(_build_summary(matched_count, total, mismatch_count, result.unmapped_api_options))
236
+ print("=" * 60)
237
+ print()