virtuagym 1.0.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.
- virtuagym/__init__.py +16 -0
- virtuagym/_types.py +17 -0
- virtuagym/exceptions.py +31 -0
- virtuagym/py.typed +0 -0
- virtuagym/v1/__init__.py +0 -0
- virtuagym/v1/_core.py +170 -0
- virtuagym/v1/async_client.py +489 -0
- virtuagym/v1/client.py +581 -0
- virtuagym/v1/models.py +490 -0
- virtuagym/v3/__init__.py +0 -0
- virtuagym/v3/_core.py +70 -0
- virtuagym/v3/async_client.py +246 -0
- virtuagym/v3/client.py +294 -0
- virtuagym/v3/models.py +264 -0
- virtuagym-1.0.0.dist-info/METADATA +176 -0
- virtuagym-1.0.0.dist-info/RECORD +18 -0
- virtuagym-1.0.0.dist-info/WHEEL +4 -0
- virtuagym-1.0.0.dist-info/licenses/LICENSE +21 -0
virtuagym/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Typed Python client for the Virtuagym API (v1 + v3), sync + async."""
|
|
2
|
+
|
|
3
|
+
from virtuagym.exceptions import VirtuaGymApiError, VirtuaGymV3ApiError
|
|
4
|
+
from virtuagym.v1.async_client import AsyncVirtuaGymClientV1
|
|
5
|
+
from virtuagym.v1.client import VirtuaGymClientV1
|
|
6
|
+
from virtuagym.v3.async_client import AsyncVirtuaGymClientV3
|
|
7
|
+
from virtuagym.v3.client import VirtuaGymClientV3
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AsyncVirtuaGymClientV1",
|
|
11
|
+
"AsyncVirtuaGymClientV3",
|
|
12
|
+
"VirtuaGymApiError",
|
|
13
|
+
"VirtuaGymClientV1",
|
|
14
|
+
"VirtuaGymClientV3",
|
|
15
|
+
"VirtuaGymV3ApiError",
|
|
16
|
+
]
|
virtuagym/_types.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Shared type helpers for the wire format's loose typing."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BeforeValidator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _to_str(value: Any) -> Any:
|
|
9
|
+
if isinstance(value, (int, float)):
|
|
10
|
+
return str(value)
|
|
11
|
+
return value
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# The API returns some ids as int in one place and string in another (see
|
|
15
|
+
# API-FINDINGS in gold-development/virtuagym-node); coerce to string for a
|
|
16
|
+
# stable type.
|
|
17
|
+
CoercedStr = Annotated[str, BeforeValidator(_to_str)]
|
virtuagym/exceptions.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Exceptions raised by the Virtuagym clients."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class VirtuaGymApiError(Exception):
|
|
7
|
+
"""An error reported by the Virtuagym v1 API itself.
|
|
8
|
+
|
|
9
|
+
Note these can arrive with HTTP 200 — the v1 API reports errors
|
|
10
|
+
in-band. Schema-validation failures also surface as this error.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, statuscode: int, statusmessage: str, errors: Any = None) -> None:
|
|
14
|
+
super().__init__(f"Virtuagym API error {statuscode}: {statusmessage}")
|
|
15
|
+
self.statuscode = statuscode
|
|
16
|
+
self.statusmessage = statusmessage
|
|
17
|
+
#: Validation error details, when the endpoint provides them.
|
|
18
|
+
self.errors = errors
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VirtuaGymV3ApiError(Exception):
|
|
22
|
+
"""An error reported by the Virtuagym v3 API.
|
|
23
|
+
|
|
24
|
+
Unlike v1, the v3 endpoints use real HTTP status codes for errors.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, http_status: int, message: str, fields: list[str] | None = None) -> None:
|
|
28
|
+
super().__init__(f"Virtuagym API v3 error {http_status}: {message}")
|
|
29
|
+
self.http_status = http_status
|
|
30
|
+
#: Invalid fields, when the endpoint reports them.
|
|
31
|
+
self.fields = fields
|
virtuagym/py.typed
ADDED
|
File without changes
|
virtuagym/v1/__init__.py
ADDED
|
File without changes
|
virtuagym/v1/_core.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Sans-IO helpers shared by the sync and async v1 clients.
|
|
2
|
+
|
|
3
|
+
The v1 API reports errors in-band with HTTP 200 in two shapes (flat
|
|
4
|
+
``{statuscode, …}`` or nested ``{status: {…}, errors?}``); some endpoints
|
|
5
|
+
(e.g. bodymetrics) use real HTTP status codes and a FLAT success envelope
|
|
6
|
+
instead. Pagination cursors differ per endpoint — every rule below was
|
|
7
|
+
verified against the live API (see API-FINDINGS.md in
|
|
8
|
+
gold-development/virtuagym-node).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import parse_qs
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from virtuagym.exceptions import VirtuaGymApiError
|
|
18
|
+
|
|
19
|
+
BASE_URL = "https://api.virtuagym.com/api/v1"
|
|
20
|
+
|
|
21
|
+
Status = dict[str, Any]
|
|
22
|
+
Params = dict[str, Any]
|
|
23
|
+
#: (status, last_item_raw, params) -> params for the next page, or None to stop.
|
|
24
|
+
Advance = Callable[[Status, Any, Params], Params | None]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_envelope(response: httpx.Response) -> tuple[Status, Any]:
|
|
28
|
+
"""Normalizes the three envelope shapes and raises on API errors."""
|
|
29
|
+
try:
|
|
30
|
+
data = response.json()
|
|
31
|
+
except ValueError as error: # pragma: no cover - defensive
|
|
32
|
+
raise VirtuaGymApiError(0, "The response body was not valid JSON") from error
|
|
33
|
+
if not isinstance(data, dict):
|
|
34
|
+
raise VirtuaGymApiError(0, "The response body was not a JSON object")
|
|
35
|
+
|
|
36
|
+
# Flat error envelope: {statuscode, statusmessage, ...} — used both
|
|
37
|
+
# in-band with HTTP 200 and with real HTTP error codes.
|
|
38
|
+
if "statuscode" in data and not _is_success(int(data["statuscode"])):
|
|
39
|
+
raise VirtuaGymApiError(
|
|
40
|
+
int(data["statuscode"]), str(data.get("statusmessage", "")), data.get("errors")
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
status = data.get("status")
|
|
44
|
+
if isinstance(status, dict) and "statuscode" in status:
|
|
45
|
+
if not _is_success(int(status["statuscode"])):
|
|
46
|
+
raise VirtuaGymApiError(
|
|
47
|
+
int(status["statuscode"]), str(status.get("statusmessage", "")), data.get("errors")
|
|
48
|
+
)
|
|
49
|
+
return status, data.get("result")
|
|
50
|
+
|
|
51
|
+
# Flat success envelope (e.g. bodymetrics): status fields at top level.
|
|
52
|
+
if "statuscode" in data:
|
|
53
|
+
return data, data.get("result")
|
|
54
|
+
|
|
55
|
+
raise VirtuaGymApiError(0, "The response had no status envelope")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_success(statuscode: int) -> bool:
|
|
59
|
+
return 200 <= statuscode < 300
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_next_page(next_page: Any) -> Params:
|
|
63
|
+
"""Parses the undocumented server-computed next_page cursor
|
|
64
|
+
("sync_from=1784035004986", optionally with from_id)."""
|
|
65
|
+
if not isinstance(next_page, str) or next_page == "":
|
|
66
|
+
return {}
|
|
67
|
+
values = parse_qs(next_page)
|
|
68
|
+
parsed: Params = {}
|
|
69
|
+
for key in ("sync_from", "from_id"):
|
|
70
|
+
raw = values.get(key, [None])[0]
|
|
71
|
+
if raw is not None and raw.lstrip("-").isdigit():
|
|
72
|
+
parsed[key] = int(raw)
|
|
73
|
+
return parsed
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def wrap_list(result: Any) -> list[Any]:
|
|
77
|
+
"""Single-resource GETs return one-element arrays, but the docs show
|
|
78
|
+
bare objects; accept both."""
|
|
79
|
+
if isinstance(result, dict):
|
|
80
|
+
return [result]
|
|
81
|
+
return result if isinstance(result, list) else []
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def remaining(status: Status) -> int:
|
|
85
|
+
value = status.get("results_remaining")
|
|
86
|
+
return int(value) if isinstance(value, (int, float)) else 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def advance_member_cursor(status: Status, last: Any, params: Params) -> Params | None:
|
|
90
|
+
"""Employees/members: prefer next_page; fall back to the last row's
|
|
91
|
+
timestamp_edit + member_id."""
|
|
92
|
+
if remaining(status) <= 0 or last is None:
|
|
93
|
+
return None
|
|
94
|
+
nxt = dict(params)
|
|
95
|
+
cursor = parse_next_page(status.get("next_page"))
|
|
96
|
+
if cursor:
|
|
97
|
+
nxt["sync_from"] = cursor.get("sync_from", nxt.get("sync_from", 0))
|
|
98
|
+
nxt.pop("from_id", None)
|
|
99
|
+
if "from_id" in cursor:
|
|
100
|
+
nxt["from_id"] = cursor["from_id"]
|
|
101
|
+
else:
|
|
102
|
+
nxt["sync_from"] = last["timestamp_edit"]
|
|
103
|
+
nxt["from_id"] = last["member_id"]
|
|
104
|
+
return nxt
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def advance_instance_cursor(status: Status, last: Any, params: Params) -> Params | None:
|
|
108
|
+
"""Membership instances: results are ordered by instance_id and from_id
|
|
109
|
+
is INCLUSIVE, while the next_page sync_from cursor duplicates rows on
|
|
110
|
+
timestamp ties — so page on instance_id + 1."""
|
|
111
|
+
if remaining(status) <= 0 or last is None:
|
|
112
|
+
return None
|
|
113
|
+
return {**params, "from_id": last["instance_id"] + 1}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def advance_page_param(status: Status, last: Any, params: Params) -> Params | None:
|
|
117
|
+
"""Membership definitions / invoices: the page parameter paginates
|
|
118
|
+
exactly (25/page resp. 500/page)."""
|
|
119
|
+
if remaining(status) <= 0 or last is None:
|
|
120
|
+
return None
|
|
121
|
+
return {**params, "page": params.get("page", 1) + 1}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def make_sync_from_advancer(fallback_field: str, *, require_progress: bool = False) -> Advance:
|
|
125
|
+
"""Events/participants/visits/credits: no documented cursor; prefer the
|
|
126
|
+
server-computed next_page and guard against a non-advancing fallback."""
|
|
127
|
+
|
|
128
|
+
def advance(status: Status, last: Any, params: Params) -> Params | None:
|
|
129
|
+
if remaining(status) <= 0 or last is None:
|
|
130
|
+
return None
|
|
131
|
+
sync_from = params.get("sync_from", 0)
|
|
132
|
+
cursor = parse_next_page(status.get("next_page"))
|
|
133
|
+
if "sync_from" in cursor and (not require_progress or cursor["sync_from"] != sync_from):
|
|
134
|
+
return {**params, "sync_from": cursor["sync_from"]}
|
|
135
|
+
fallback = last.get(fallback_field) if isinstance(last, dict) else None
|
|
136
|
+
if isinstance(fallback, int) and fallback > sync_from:
|
|
137
|
+
return {**params, "sync_from": fallback}
|
|
138
|
+
# status.timestamp keeps the events endpoint moving when no
|
|
139
|
+
# per-item cursor exists.
|
|
140
|
+
timestamp = status.get("timestamp")
|
|
141
|
+
if fallback_field == "" and isinstance(timestamp, int) and timestamp > sync_from:
|
|
142
|
+
return {**params, "sync_from": timestamp}
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
return advance
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
#: Club events advance on next_page or the response's status.timestamp.
|
|
149
|
+
advance_event_cursor = make_sync_from_advancer("")
|
|
150
|
+
advance_participant_cursor = make_sync_from_advancer("timestamp_edit")
|
|
151
|
+
advance_visit_cursor = make_sync_from_advancer("check_in_timestamp")
|
|
152
|
+
advance_credit_cursor = make_sync_from_advancer("timestamp_edited", require_progress=True)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def drop_query_none(params: Params) -> Params:
|
|
156
|
+
return {key: value for key, value in params.items() if value is not None}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def error_from_http_status(response: httpx.Response) -> VirtuaGymApiError | None:
|
|
160
|
+
"""Maps real-HTTP-status errors (bodymetrics style) to the same
|
|
161
|
+
exception as in-band errors."""
|
|
162
|
+
try:
|
|
163
|
+
data = response.json()
|
|
164
|
+
except ValueError:
|
|
165
|
+
return None
|
|
166
|
+
if isinstance(data, dict) and "statuscode" in data and "statusmessage" in data:
|
|
167
|
+
return VirtuaGymApiError(
|
|
168
|
+
int(data["statuscode"]), str(data["statusmessage"]), data.get("errors")
|
|
169
|
+
)
|
|
170
|
+
return None
|