valentina-python-client 2.1.0__tar.gz → 2.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 (84) hide show
  1. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/PKG-INFO +1 -1
  2. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/pyproject.toml +1 -1
  3. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/__init__.py +1 -1
  4. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/base.py +9 -2
  5. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/global_admin.py +80 -1
  6. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/constants.py +7 -1
  7. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/endpoints.py +2 -0
  8. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/__init__.py +4 -0
  9. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/characters.py +6 -2
  10. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/global_admin.py +33 -1
  11. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/base.py +3 -1
  12. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/global_admin.py +79 -0
  13. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/testing/__init__.py +2 -0
  14. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/testing/_factories.py +7 -0
  15. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/testing/_router.py +12 -2
  16. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/testing/_routes.py +6 -2
  17. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/LICENSE +0 -0
  18. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/README.md +0 -0
  19. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_codegen.py +0 -0
  20. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/__init__.py +0 -0
  21. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/client.py +0 -0
  22. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/registry.py +0 -0
  23. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/__init__.py +0 -0
  24. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/_audit_params.py +0 -0
  25. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/campaign_book_chapters.py +0 -0
  26. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/campaign_books.py +0 -0
  27. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/campaigns.py +0 -0
  28. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/character_autogen.py +0 -0
  29. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/character_blueprint.py +0 -0
  30. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/character_traits.py +0 -0
  31. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/characters.py +0 -0
  32. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/companies.py +0 -0
  33. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/developers.py +0 -0
  34. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/dicerolls.py +0 -0
  35. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/dictionary.py +0 -0
  36. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/options.py +0 -0
  37. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/system.py +0 -0
  38. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/user_lookup.py +0 -0
  39. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/user_self_registration.py +0 -0
  40. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/services/users.py +0 -0
  41. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/testing/__init__.py +0 -0
  42. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/_sync/testing/_client.py +0 -0
  43. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/client.py +0 -0
  44. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/config.py +0 -0
  45. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/exceptions.py +0 -0
  46. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/audit_logs.py +0 -0
  47. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/books.py +0 -0
  48. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/campaigns.py +0 -0
  49. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/chapters.py +0 -0
  50. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/character_autogen.py +0 -0
  51. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/character_blueprint.py +0 -0
  52. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/character_trait.py +0 -0
  53. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/companies.py +0 -0
  54. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/developers.py +0 -0
  55. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/diceroll.py +0 -0
  56. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/dictionary.py +0 -0
  57. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/full_sheet.py +0 -0
  58. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/pagination.py +0 -0
  59. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/shared.py +0 -0
  60. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/system.py +0 -0
  61. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/user_lookup.py +0 -0
  62. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/models/users.py +0 -0
  63. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/py.typed +0 -0
  64. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/registry.py +0 -0
  65. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/__init__.py +0 -0
  66. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/_audit_params.py +0 -0
  67. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/campaign_book_chapters.py +0 -0
  68. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/campaign_books.py +0 -0
  69. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/campaigns.py +0 -0
  70. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/character_autogen.py +0 -0
  71. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/character_blueprint.py +0 -0
  72. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/character_traits.py +0 -0
  73. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/characters.py +0 -0
  74. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/companies.py +0 -0
  75. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/developers.py +0 -0
  76. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/dicerolls.py +0 -0
  77. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/dictionary.py +0 -0
  78. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/options.py +0 -0
  79. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/system.py +0 -0
  80. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/user_lookup.py +0 -0
  81. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/user_self_registration.py +0 -0
  82. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/services/users.py +0 -0
  83. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/testing/_client.py +0 -0
  84. {valentina_python_client-2.1.0 → valentina_python_client-2.2.0}/src/vclient/validate_constants.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: valentina-python-client
3
- Version: 2.1.0
3
+ Version: 2.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>
@@ -16,7 +16,7 @@
16
16
  name = "valentina-python-client"
17
17
  readme = "README.md"
18
18
  requires-python = ">=3.13"
19
- version = "2.1.0"
19
+ version = "2.2.0"
20
20
 
21
21
  [project.optional-dependencies]
22
22
  testing = ["polyfactory>=3.3.0"]
@@ -113,4 +113,4 @@ __all__ = (
113
113
  "users_service",
114
114
  )
115
115
 
116
- __version__ = "2.1.0"
116
+ __version__ = "2.2.0"
@@ -381,17 +381,24 @@ class SyncBaseService:
381
381
  return None
382
382
  return SyncBaseService._parse_rate_limit_header_value(rate_limit_header, "r")
383
383
 
384
- def _get(self, path: str, *, params: dict[str, Any] | None = None) -> httpx.Response:
384
+ def _get(
385
+ self,
386
+ path: str,
387
+ *,
388
+ params: dict[str, Any] | None = None,
389
+ headers: dict[str, str] | None = None,
390
+ ) -> httpx.Response:
385
391
  """Make a GET request.
386
392
 
387
393
  Args:
388
394
  path: API endpoint path.
389
395
  params: Query parameters.
396
+ headers: Additional headers (e.g. an Accept override for binary downloads).
390
397
 
391
398
  Returns:
392
399
  The HTTP response.
393
400
  """
394
- return self._request("GET", path, params=params)
401
+ return self._request("GET", path, params=params, headers=headers)
395
402
 
396
403
  def _merge_on_behalf_of_header(self, headers: dict[str, str] | None) -> dict[str, str] | None:
397
404
  """Merge the On-Behalf-Of header into headers when _on_behalf_of is set.
@@ -1,11 +1,21 @@
1
1
  # AUTO-GENERATED — do not edit. Run 'uv run duty generate_sync' to regenerate.
2
2
  """Service for interacting with the Global Admin API."""
3
3
 
4
+ import re
4
5
  from collections.abc import Iterator, Sequence
5
6
  from datetime import datetime
6
7
 
7
8
  from vclient._sync.services.base import SyncBaseService
8
- from vclient.constants import DEFAULT_PAGE_LIMIT, AuditEntityType, AuditLogInclude, AuditOperation
9
+ from vclient.constants import (
10
+ DEFAULT_LOG_TAIL_LIMIT,
11
+ DEFAULT_PAGE_LIMIT,
12
+ MAX_LOG_TAIL_LIMIT,
13
+ MIN_LOG_TAIL_LIMIT,
14
+ AuditEntityType,
15
+ AuditLogInclude,
16
+ AuditOperation,
17
+ LogLevel,
18
+ )
9
19
  from vclient.endpoints import Endpoints
10
20
  from vclient.models import (
11
21
  AuditLog,
@@ -15,9 +25,34 @@ from vclient.models import (
15
25
  DeveloperUpdate,
16
26
  DeveloperWithApiKey,
17
27
  PaginatedResponse,
28
+ ServerLogArchive,
29
+ ServerLogEntry,
18
30
  )
19
31
  from vclient.services._audit_params import _build_audit_params
20
32
 
33
+ _CONTENT_DISPOSITION_FILENAME = re.compile('filename=(?:"([^"]+)"|([^;]+))', re.IGNORECASE)
34
+
35
+
36
+ def _filename_from_content_disposition(header: str | None, *, fallback: str) -> str:
37
+ """Extract the attachment filename from a Content-Disposition header.
38
+
39
+ Return ``fallback`` when the header is absent or contains no filename so callers
40
+ always get a usable name for the downloaded archive.
41
+
42
+ Args:
43
+ header: The raw Content-Disposition header value, or None.
44
+ fallback: Filename to return when none can be parsed.
45
+
46
+ Returns:
47
+ The parsed filename, or the fallback.
48
+ """
49
+ if not header:
50
+ return fallback
51
+ match = _CONTENT_DISPOSITION_FILENAME.search(header)
52
+ if not match:
53
+ return fallback
54
+ return (match.group(1) or match.group(2)).strip()
55
+
21
56
 
22
57
  class SyncGlobalAdminService(SyncBaseService):
23
58
  """Service for global admin operations in the Valentina API.
@@ -404,3 +439,47 @@ class SyncGlobalAdminService(SyncBaseService):
404
439
  params=params,
405
440
  ):
406
441
  yield model.model_validate(item)
442
+
443
+ def tail_logs(
444
+ self, *, level: LogLevel | None = None, limit: int = DEFAULT_LOG_TAIL_LIMIT
445
+ ) -> list[ServerLogEntry]:
446
+ """Tail the most recent server log entries, newest first.
447
+
448
+ Inspect on-disk server logs without shelling into the host. Requires global
449
+ admin privileges and that file logging is enabled on the server.
450
+
451
+ Args:
452
+ level: Minimum log level to include. Defaults to the server's configured
453
+ level when omitted.
454
+ limit: Maximum number of entries to return. Clamped to 1-500 (default 100).
455
+
456
+ Returns:
457
+ A list of ServerLogEntry objects, newest first.
458
+
459
+ Raises:
460
+ AuthorizationError: If you don't have global admin privileges.
461
+ ConflictError: If file logging is not enabled on the server.
462
+ """
463
+ clamped_limit = min(max(limit, MIN_LOG_TAIL_LIMIT), MAX_LOG_TAIL_LIMIT)
464
+ params = self._build_params(level=level, limit=clamped_limit)
465
+ response = self._get(Endpoints.ADMIN_LOGS, params=params)
466
+ return [ServerLogEntry.model_validate(item) for item in response.json()]
467
+
468
+ def download_logs(self) -> ServerLogArchive:
469
+ """Download a zip archive of the server log files.
470
+
471
+ Stream the active log file plus rotated backups as a single zip. Requires
472
+ global admin privileges and that file logging is enabled on the server.
473
+
474
+ Returns:
475
+ A ServerLogArchive with the server-provided filename and raw zip bytes.
476
+
477
+ Raises:
478
+ AuthorizationError: If you don't have global admin privileges.
479
+ ConflictError: If file logging is not enabled or no log files exist.
480
+ """
481
+ response = self._get(Endpoints.ADMIN_LOGS_DOWNLOAD, headers={"Accept": "application/zip"})
482
+ filename = _filename_from_content_disposition(
483
+ response.headers.get("Content-Disposition"), fallback="vapi-logs.zip"
484
+ )
485
+ return ServerLogArchive(filename=filename, content=response.content)
@@ -21,6 +21,11 @@ IDEMPOTENT_HTTP_METHODS: frozenset[str] = frozenset({"GET", "PUT", "DELETE"})
21
21
  DEFAULT_PAGE_LIMIT = 10
22
22
  MAX_PAGE_LIMIT = 100
23
23
 
24
+ # Server log tail defaults
25
+ DEFAULT_LOG_TAIL_LIMIT = 100
26
+ MIN_LOG_TAIL_LIMIT = 1
27
+ MAX_LOG_TAIL_LIMIT = 500
28
+
24
29
  # HTTP Status Code Ranges (5xx Server Errors)
25
30
  HTTP_500_INTERNAL_SERVER_ERROR = 500
26
31
  HTTP_600_UPPER_BOUND = 600
@@ -70,13 +75,14 @@ CharacterInventoryType = Literal[
70
75
  "WEAPON",
71
76
  ]
72
77
  CharacterStatus = Literal["ALIVE", "DEAD"]
73
- CharacterType = Literal["PLAYER", "NPC", "STORYTELLER", "DEVELOPER"]
78
+ CharacterType = Literal["PLAYER", "NPC", "STORYTELLER"]
74
79
  DiceSize = Literal[4, 6, 8, 10, 20, 100]
75
80
  FreeTraitChangesPermission = Literal["UNRESTRICTED", "WITHIN_24_HOURS", "STORYTELLER"]
76
81
  GameVersion = Literal["V4", "V5"]
77
82
  GrantXPPermission = Literal["UNRESTRICTED", "PLAYER", "STORYTELLER"]
78
83
  HunterCreed = Literal["ENTREPRENEURIAL", "FAITHFUL", "INQUISITIVE", "MARTIAL", "UNDERGROUND"]
79
84
  HunterEdgeType = Literal["ASSETS", "APTITUDES", "ENDOWMENTS"]
85
+ LogLevel = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
80
86
  ManageCampaignPermission = Literal["UNRESTRICTED", "STORYTELLER"]
81
87
  PermissionLevel = Literal["USER", "ADMIN", "OWNER", "REVOKE"]
82
88
  RecoupXPPermission = Literal["UNRESTRICTED", "DENIED", "WITHIN_SESSION"]
@@ -27,6 +27,8 @@ class Endpoints:
27
27
  ADMIN_DEVELOPER = f"{_BASE}/admin/developers/{{developer_id}}"
28
28
  ADMIN_DEVELOPER_NEW_KEY = f"{_BASE}/admin/developers/{{developer_id}}/new-key"
29
29
  ADMIN_DEVELOPER_AUDIT_LOGS = f"{ADMIN_DEVELOPER}/audit-logs"
30
+ ADMIN_LOGS = f"{_BASE}/admin/logs"
31
+ ADMIN_LOGS_DOWNLOAD = f"{ADMIN_LOGS}/download"
30
32
 
31
33
  # Developer endpoints (self-service)
32
34
  DEVELOPER_ME = f"{_BASE}/developers/me"
@@ -107,6 +107,8 @@ from .global_admin import (
107
107
  DeveloperCreate,
108
108
  DeveloperUpdate,
109
109
  DeveloperWithApiKey,
110
+ ServerLogArchive,
111
+ ServerLogEntry,
110
112
  )
111
113
  from .pagination import PaginatedResponse
112
114
  from .shared import (
@@ -222,6 +224,8 @@ __all__ = [
222
224
  "QuickrollCreate",
223
225
  "QuickrollUpdate",
224
226
  "RollStatistics",
227
+ "ServerLogArchive",
228
+ "ServerLogEntry",
225
229
  "SheetSection",
226
230
  "SystemHealth",
227
231
  "Trait",
@@ -190,8 +190,12 @@ class Character(BaseModel):
190
190
  concept_name: str | None = Field(default=None, description="Name of the character concept.")
191
191
 
192
192
  # Relationships
193
- user_creator_id: str = Field(..., description="ID of the user who created the character.")
194
- user_player_id: str = Field(..., description="ID of the user who plays the character.")
193
+ user_creator_id: str | None = Field(
194
+ default=None, description="ID of the user who created the character."
195
+ )
196
+ user_player_id: str | None = Field(
197
+ default=None, description="ID of the user who plays the character."
198
+ )
195
199
  company_id: str = Field(..., description="ID of the company.")
196
200
  campaign_id: str = Field(..., description="ID of the campaign.")
197
201
 
@@ -1,8 +1,10 @@
1
1
  """Pydantic models for Global Admin API responses."""
2
2
 
3
+ from dataclasses import dataclass
3
4
  from datetime import datetime
5
+ from typing import Any
4
6
 
5
- from pydantic import BaseModel
7
+ from pydantic import BaseModel, Field
6
8
 
7
9
  from vclient.constants import PermissionLevel
8
10
 
@@ -70,10 +72,40 @@ class DeveloperUpdate(BaseModel):
70
72
  is_global_admin: bool | None = None
71
73
 
72
74
 
75
+ class ServerLogEntry(BaseModel):
76
+ """A single parsed server log entry from the admin logs tail endpoint.
77
+
78
+ Every field is nullable because individual log lines may omit values or fail
79
+ to parse as structured JSON (in which case ``raw`` holds the original line).
80
+ """
81
+
82
+ timestamp: str | None = None
83
+ level: str | None = None
84
+ name: str | None = None
85
+ message: str | None = None
86
+ exception: str | None = None
87
+ extra: dict[str, Any] = Field(default_factory=dict)
88
+ raw: str | None = None
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class ServerLogArchive:
93
+ """A downloaded server-log zip archive.
94
+
95
+ Pairs the server-provided ``Content-Disposition`` filename with the raw zip
96
+ bytes so callers can write the archive straight to disk.
97
+ """
98
+
99
+ filename: str
100
+ content: bytes
101
+
102
+
73
103
  __all__ = [
74
104
  "Developer",
75
105
  "DeveloperCompanyPermission",
76
106
  "DeveloperCreate",
77
107
  "DeveloperUpdate",
78
108
  "DeveloperWithApiKey",
109
+ "ServerLogArchive",
110
+ "ServerLogEntry",
79
111
  ]
@@ -430,17 +430,19 @@ class BaseService:
430
430
  path: str,
431
431
  *,
432
432
  params: dict[str, Any] | None = None,
433
+ headers: dict[str, str] | None = None,
433
434
  ) -> httpx.Response:
434
435
  """Make a GET request.
435
436
 
436
437
  Args:
437
438
  path: API endpoint path.
438
439
  params: Query parameters.
440
+ headers: Additional headers (e.g. an Accept override for binary downloads).
439
441
 
440
442
  Returns:
441
443
  The HTTP response.
442
444
  """
443
- return await self._request("GET", path, params=params)
445
+ return await self._request("GET", path, params=params, headers=headers)
444
446
 
445
447
  def _merge_on_behalf_of_header(
446
448
  self,
@@ -1,13 +1,18 @@
1
1
  """Service for interacting with the Global Admin API."""
2
2
 
3
+ import re
3
4
  from collections.abc import AsyncIterator, Sequence
4
5
  from datetime import datetime
5
6
 
6
7
  from vclient.constants import (
8
+ DEFAULT_LOG_TAIL_LIMIT,
7
9
  DEFAULT_PAGE_LIMIT,
10
+ MAX_LOG_TAIL_LIMIT,
11
+ MIN_LOG_TAIL_LIMIT,
8
12
  AuditEntityType,
9
13
  AuditLogInclude,
10
14
  AuditOperation,
15
+ LogLevel,
11
16
  )
12
17
  from vclient.endpoints import Endpoints
13
18
  from vclient.models import (
@@ -18,10 +23,35 @@ from vclient.models import (
18
23
  DeveloperUpdate,
19
24
  DeveloperWithApiKey,
20
25
  PaginatedResponse,
26
+ ServerLogArchive,
27
+ ServerLogEntry,
21
28
  )
22
29
  from vclient.services._audit_params import _build_audit_params
23
30
  from vclient.services.base import BaseService
24
31
 
32
+ _CONTENT_DISPOSITION_FILENAME = re.compile(r'filename=(?:"([^"]+)"|([^;]+))', re.IGNORECASE)
33
+
34
+
35
+ def _filename_from_content_disposition(header: str | None, *, fallback: str) -> str:
36
+ """Extract the attachment filename from a Content-Disposition header.
37
+
38
+ Return ``fallback`` when the header is absent or contains no filename so callers
39
+ always get a usable name for the downloaded archive.
40
+
41
+ Args:
42
+ header: The raw Content-Disposition header value, or None.
43
+ fallback: Filename to return when none can be parsed.
44
+
45
+ Returns:
46
+ The parsed filename, or the fallback.
47
+ """
48
+ if not header:
49
+ return fallback
50
+ match = _CONTENT_DISPOSITION_FILENAME.search(header)
51
+ if not match:
52
+ return fallback
53
+ return (match.group(1) or match.group(2)).strip()
54
+
25
55
 
26
56
  class GlobalAdminService(BaseService):
27
57
  """Service for global admin operations in the Valentina API.
@@ -427,3 +457,52 @@ class GlobalAdminService(BaseService):
427
457
  params=params,
428
458
  ):
429
459
  yield model.model_validate(item)
460
+
461
+ async def tail_logs(
462
+ self,
463
+ *,
464
+ level: LogLevel | None = None,
465
+ limit: int = DEFAULT_LOG_TAIL_LIMIT,
466
+ ) -> list[ServerLogEntry]:
467
+ """Tail the most recent server log entries, newest first.
468
+
469
+ Inspect on-disk server logs without shelling into the host. Requires global
470
+ admin privileges and that file logging is enabled on the server.
471
+
472
+ Args:
473
+ level: Minimum log level to include. Defaults to the server's configured
474
+ level when omitted.
475
+ limit: Maximum number of entries to return. Clamped to 1-500 (default 100).
476
+
477
+ Returns:
478
+ A list of ServerLogEntry objects, newest first.
479
+
480
+ Raises:
481
+ AuthorizationError: If you don't have global admin privileges.
482
+ ConflictError: If file logging is not enabled on the server.
483
+ """
484
+ clamped_limit = min(max(limit, MIN_LOG_TAIL_LIMIT), MAX_LOG_TAIL_LIMIT)
485
+ params = self._build_params(level=level, limit=clamped_limit)
486
+ response = await self._get(Endpoints.ADMIN_LOGS, params=params)
487
+ return [ServerLogEntry.model_validate(item) for item in response.json()]
488
+
489
+ async def download_logs(self) -> ServerLogArchive:
490
+ """Download a zip archive of the server log files.
491
+
492
+ Stream the active log file plus rotated backups as a single zip. Requires
493
+ global admin privileges and that file logging is enabled on the server.
494
+
495
+ Returns:
496
+ A ServerLogArchive with the server-provided filename and raw zip bytes.
497
+
498
+ Raises:
499
+ AuthorizationError: If you don't have global admin privileges.
500
+ ConflictError: If file logging is not enabled or no log files exist.
501
+ """
502
+ response = await self._get(
503
+ Endpoints.ADMIN_LOGS_DOWNLOAD, headers={"Accept": "application/zip"}
504
+ )
505
+ filename = _filename_from_content_disposition(
506
+ response.headers.get("Content-Disposition"), fallback="vapi-logs.zip"
507
+ )
508
+ return ServerLogArchive(filename=filename, content=response.content)
@@ -52,6 +52,7 @@ from vclient.testing._factories import (
52
52
  NoteFactory,
53
53
  QuickrollFactory,
54
54
  RollStatisticsFactory,
55
+ ServerLogEntryFactory,
55
56
  SheetSectionFactory,
56
57
  SystemHealthFactory,
57
58
  TraitCategoryFactory,
@@ -107,6 +108,7 @@ __all__ = [
107
108
  "RollStatisticsFactory",
108
109
  "RouteSpec",
109
110
  "Routes",
111
+ "ServerLogEntryFactory",
110
112
  "SheetSectionFactory",
111
113
  "SyncFakeVClient",
112
114
  "SystemHealthFactory",
@@ -38,6 +38,7 @@ from vclient.models import (
38
38
  Note,
39
39
  Quickroll,
40
40
  RollStatistics,
41
+ ServerLogEntry,
41
42
  SheetSection,
42
43
  SystemHealth,
43
44
  Trait,
@@ -241,6 +242,11 @@ class RollStatisticsFactory(ModelFactory[RollStatistics]):
241
242
  __use_defaults__ = True
242
243
 
243
244
 
245
+ class ServerLogEntryFactory(ModelFactory[ServerLogEntry]):
246
+ __model__ = ServerLogEntry
247
+ __use_defaults__ = True
248
+
249
+
244
250
  class SheetSectionFactory(ModelFactory[SheetSection]):
245
251
  __model__ = SheetSection
246
252
  __use_defaults__ = True
@@ -337,6 +343,7 @@ __all__ = [
337
343
  "NoteFactory",
338
344
  "QuickrollFactory",
339
345
  "RollStatisticsFactory",
346
+ "ServerLogEntryFactory",
340
347
  "SheetSectionFactory",
341
348
  "SystemHealthFactory",
342
349
  "TraitCategoryFactory",
@@ -43,6 +43,7 @@ from vclient.models import (
43
43
  Note,
44
44
  Quickroll,
45
45
  RollStatistics,
46
+ ServerLogEntry,
46
47
  SheetSection,
47
48
  SystemHealth,
48
49
  Trait,
@@ -89,6 +90,7 @@ from vclient.testing._factories import (
89
90
  NoteFactory,
90
91
  QuickrollFactory,
91
92
  RollStatisticsFactory,
93
+ ServerLogEntryFactory,
92
94
  SheetSectionFactory,
93
95
  SystemHealthFactory,
94
96
  TraitCategoryFactory,
@@ -101,7 +103,7 @@ from vclient.testing._factories import (
101
103
  WerewolfAuspiceFactory,
102
104
  WerewolfTribeFactory,
103
105
  )
104
- from vclient.testing._routes import LIST, NO_CONTENT, PAGINATED, RAW_JSON, Routes, RouteSpec
106
+ from vclient.testing._routes import BYTES, LIST, NO_CONTENT, PAGINATED, RAW_JSON, Routes, RouteSpec
105
107
 
106
108
  _FACTORY_MAP: dict[type, type[ModelFactory]] = {
107
109
  AuditLog: AuditLogFactory,
@@ -135,6 +137,7 @@ _FACTORY_MAP: dict[type, type[ModelFactory]] = {
135
137
  Note: NoteFactory,
136
138
  Quickroll: QuickrollFactory,
137
139
  RollStatistics: RollStatisticsFactory,
140
+ ServerLogEntry: ServerLogEntryFactory,
138
141
  SheetSection: SheetSectionFactory,
139
142
  SystemHealth: SystemHealthFactory,
140
143
  Trait: TraitFactory,
@@ -208,7 +211,7 @@ class _Route:
208
211
 
209
212
  return True
210
213
 
211
- def respond(self) -> httpx.Response:
214
+ def respond(self) -> httpx.Response: # noqa: PLR0911
212
215
  """Generate an httpx.Response for this route."""
213
216
  if self.override_json is not None:
214
217
  return httpx.Response(
@@ -222,6 +225,13 @@ class _Route:
222
225
  if self.style == RAW_JSON:
223
226
  return httpx.Response(status_code=200, json={})
224
227
 
228
+ if self.style == BYTES:
229
+ return httpx.Response(
230
+ status_code=200,
231
+ content=b"PK\x03\x04fake-log-archive",
232
+ headers={"Content-Disposition": 'attachment; filename="vapi-logs-fake.zip"'},
233
+ )
234
+
225
235
  if self.model_class is None:
226
236
  msg = f"model_class required for style {self.style}"
227
237
  raise RuntimeError(msg)
@@ -34,6 +34,7 @@ from vclient.models import (
34
34
  Note,
35
35
  Quickroll,
36
36
  RollStatistics,
37
+ ServerLogEntry,
37
38
  SheetSection,
38
39
  SystemHealth,
39
40
  Trait,
@@ -55,6 +56,7 @@ SINGLE = "single"
55
56
  NO_CONTENT = "no_content"
56
57
  RAW_JSON = "raw_json"
57
58
  LIST = "list"
59
+ BYTES = "bytes"
58
60
 
59
61
 
60
62
  class RouteSpec(NamedTuple):
@@ -63,9 +65,9 @@ class RouteSpec(NamedTuple):
63
65
  Attributes:
64
66
  method: HTTP method (GET, POST, PATCH, PUT, DELETE).
65
67
  pattern: Endpoint URL pattern from the Endpoints class.
66
- style: Response style one of PAGINATED, SINGLE, LIST, NO_CONTENT, or RAW_JSON.
68
+ style: Response style: one of PAGINATED, SINGLE, LIST, NO_CONTENT, RAW_JSON, or BYTES.
67
69
  model_class: The Pydantic model class used for auto-generating responses,
68
- or None for NO_CONTENT and RAW_JSON routes.
70
+ or None for NO_CONTENT, RAW_JSON, and BYTES routes.
69
71
  """
70
72
 
71
73
  method: str
@@ -107,6 +109,8 @@ class Routes:
107
109
  ADMIN_DEVELOPER_AUDIT_LOGS_LIST = RouteSpec(
108
110
  "GET", Endpoints.ADMIN_DEVELOPER_AUDIT_LOGS, PAGINATED, AuditLog
109
111
  )
112
+ ADMIN_LOGS_TAIL = RouteSpec("GET", Endpoints.ADMIN_LOGS, LIST, ServerLogEntry)
113
+ ADMIN_LOGS_DOWNLOAD = RouteSpec("GET", Endpoints.ADMIN_LOGS_DOWNLOAD, BYTES, None)
110
114
 
111
115
  # Developer self-service
112
116
  DEVELOPERS_ME_GET = RouteSpec("GET", Endpoints.DEVELOPER_ME, SINGLE, MeDeveloper)