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 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)]
@@ -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
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