pawpado 0.1.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.
@@ -0,0 +1,66 @@
1
+ # Dependencies
2
+ node_modules/
3
+ .pnp
4
+ .pnp.js
5
+
6
+ # Testing
7
+ /coverage
8
+ test-results/
9
+ playwright-report/
10
+
11
+ # Next.js
12
+ .next/
13
+ out/
14
+
15
+ # Production builds
16
+ build/
17
+ # ...but NOT the "Build your machine" app route, which lives at
18
+ # src/app/(dashboard)/dashboard/build/ and is real source, not an artifact.
19
+ !frontend/src/app/**/build/
20
+ dist/
21
+ *.tsbuildinfo
22
+ next-env.d.ts
23
+
24
+ # Python (control plane)
25
+ __pycache__/
26
+ *.py[cod]
27
+ *.egg-info/
28
+ .venv/
29
+ venv/
30
+ .pytest_cache/
31
+ .mypy_cache/
32
+ .ruff_cache/
33
+
34
+ # Environment + secrets — NEVER commit
35
+ .env
36
+ .env.local
37
+ .env.development.local
38
+ .env.test.local
39
+ .env.production.local
40
+ secrets.ini
41
+ config/secrets.ini
42
+ config/secrets.*.ini
43
+ !config/secrets.example.ini
44
+
45
+ # IDE
46
+ .vscode/
47
+ .idea/
48
+ *.swp
49
+ *.swo
50
+
51
+ # OS
52
+ .DS_Store
53
+ Thumbs.db
54
+
55
+ # Logs
56
+ *.log
57
+ npm-debug.log*
58
+ yarn-debug.log*
59
+ yarn-error.log*
60
+
61
+ # Misc
62
+ .cache/
63
+ *.pid
64
+
65
+ # DB
66
+ frontend/data/
pawpado-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: pawpado
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for Pawpado — single-user AWS GPU gaming portal. Sessions (start/stop/poll/pair/connect), credits, billing, settings, admin. Bearer auth via Huudis OIDC device flow.
5
+ Project-URL: Homepage, https://pawpado.com
6
+ Project-URL: Source, https://github.com/hachimi-cat/saas-pawpado/tree/main/sdk/python
7
+ Project-URL: Issues, https://github.com/hachimi-cat/saas-pawpado/issues
8
+ Author-email: Forjio <hello@pawpado.com>
9
+ License: MIT
10
+ Keywords: aws,forjio,gaming,gpu,huudis,pawpado
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Internet :: WWW/HTTP
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.9
18
+ Requires-Dist: httpx>=0.27.0
19
+ Requires-Dist: pyjwt[crypto]>=2.9.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8.0; extra == 'dev'
22
+ Requires-Dist: respx>=0.21; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # pawpado (Python)
26
+
27
+ Official Python SDK for [Pawpado](https://pawpado.com) — the single-user AWS
28
+ GPU gaming portal. Mirrors the Node SDK (`@forjio/pawpado-node`) 1:1.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install pawpado
34
+ ```
35
+
36
+ ## Quickstart
37
+
38
+ ### Static API key (simplest)
39
+
40
+ ```python
41
+ from pawpado import PawpadoClient
42
+
43
+ with PawpadoClient(api_key="...") as client:
44
+ print(client.health()) # public, no auth
45
+ print(client.sessions.poll()) # GET /api/v1/sessions/poll
46
+ print(client.sessions.start()) # POST /api/v1/sessions/start
47
+ print(client.credits.me())
48
+ client.sessions.stop({"force": True})
49
+ ```
50
+
51
+ ### Huudis OIDC device flow (CLI-style)
52
+
53
+ ```python
54
+ from pawpado import PawpadoClient, Session
55
+
56
+ session = Session(brand="pawpado", profile="default")
57
+ session.load() # reads ~/.pawpado/credentials
58
+
59
+ with PawpadoClient(session=session) as client:
60
+ print(client.account.session())
61
+ ```
62
+
63
+ The ``Session`` refresh path is single-flight — concurrent callers share the
64
+ same refresh and avoid burning the Huudis refresh-token family (Huudis revokes
65
+ the entire family on reuse).
66
+
67
+ ## Surface
68
+
69
+ | Namespace | Methods |
70
+ |---|---|
71
+ | ``sessions`` | ``start`` / ``stop`` / ``poll`` / ``pair`` / ``connect`` / ``tailscale_key`` |
72
+ | ``credits`` | ``me`` / ``topup`` |
73
+ | ``billing`` | ``storage`` |
74
+ | ``settings`` | ``get`` / ``update`` |
75
+ | ``account`` | ``session`` / ``delete`` |
76
+ | ``admin`` | ``reconcile`` / ``orphans`` |
77
+ | ``health()`` | top-level, no auth |
78
+
79
+ Every method takes an optional ``auth_token=`` to override the client-level
80
+ bearer for one call.
81
+
82
+ ## Errors
83
+
84
+ - ``PawpadoError`` (alias: ``ApiError``) — enveloped API errors, carries
85
+ ``.code``, ``.message``, ``.status``, ``.request_id``, ``.details``.
86
+ - ``PawpadoAuthError`` — credentials / session lookup failures.
87
+ - ``RefreshError`` — OIDC refresh failure.
88
+ - ``NetworkError`` — transport failure (DNS, TLS, etc.).
89
+
90
+ ## License
91
+
92
+ MIT
@@ -0,0 +1,68 @@
1
+ # pawpado (Python)
2
+
3
+ Official Python SDK for [Pawpado](https://pawpado.com) — the single-user AWS
4
+ GPU gaming portal. Mirrors the Node SDK (`@forjio/pawpado-node`) 1:1.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install pawpado
10
+ ```
11
+
12
+ ## Quickstart
13
+
14
+ ### Static API key (simplest)
15
+
16
+ ```python
17
+ from pawpado import PawpadoClient
18
+
19
+ with PawpadoClient(api_key="...") as client:
20
+ print(client.health()) # public, no auth
21
+ print(client.sessions.poll()) # GET /api/v1/sessions/poll
22
+ print(client.sessions.start()) # POST /api/v1/sessions/start
23
+ print(client.credits.me())
24
+ client.sessions.stop({"force": True})
25
+ ```
26
+
27
+ ### Huudis OIDC device flow (CLI-style)
28
+
29
+ ```python
30
+ from pawpado import PawpadoClient, Session
31
+
32
+ session = Session(brand="pawpado", profile="default")
33
+ session.load() # reads ~/.pawpado/credentials
34
+
35
+ with PawpadoClient(session=session) as client:
36
+ print(client.account.session())
37
+ ```
38
+
39
+ The ``Session`` refresh path is single-flight — concurrent callers share the
40
+ same refresh and avoid burning the Huudis refresh-token family (Huudis revokes
41
+ the entire family on reuse).
42
+
43
+ ## Surface
44
+
45
+ | Namespace | Methods |
46
+ |---|---|
47
+ | ``sessions`` | ``start`` / ``stop`` / ``poll`` / ``pair`` / ``connect`` / ``tailscale_key`` |
48
+ | ``credits`` | ``me`` / ``topup`` |
49
+ | ``billing`` | ``storage`` |
50
+ | ``settings`` | ``get`` / ``update`` |
51
+ | ``account`` | ``session`` / ``delete`` |
52
+ | ``admin`` | ``reconcile`` / ``orphans`` |
53
+ | ``health()`` | top-level, no auth |
54
+
55
+ Every method takes an optional ``auth_token=`` to override the client-level
56
+ bearer for one call.
57
+
58
+ ## Errors
59
+
60
+ - ``PawpadoError`` (alias: ``ApiError``) — enveloped API errors, carries
61
+ ``.code``, ``.message``, ``.status``, ``.request_id``, ``.details``.
62
+ - ``PawpadoAuthError`` — credentials / session lookup failures.
63
+ - ``RefreshError`` — OIDC refresh failure.
64
+ - ``NetworkError`` — transport failure (DNS, TLS, etc.).
65
+
66
+ ## License
67
+
68
+ MIT
@@ -0,0 +1,57 @@
1
+ """Official Python SDK for Pawpado.
2
+
3
+ Mirrors the Node SDK (`@forjio/pawpado-node`):
4
+
5
+ - :class:`PawpadoClient` — sessions / credits / billing / settings / account /
6
+ admin / health, with Bearer auth via either a :class:`Session` (Huudis OIDC
7
+ device flow + refresh) or a static ``api_key``.
8
+ - :class:`Session` — multi-profile credentials store with single-flight refresh,
9
+ the Python parity of ``@forjio/sdk``'s Session.
10
+ - :class:`ApiClient` — typed HTTP client with envelope unwrap, proactive +
11
+ reactive token refresh, and auto-pagination.
12
+ """
13
+
14
+ from .client import PawpadoClient
15
+ from .errors import (
16
+ ApiError,
17
+ NetworkError,
18
+ PawpadoAuthError,
19
+ PawpadoError,
20
+ RefreshError,
21
+ )
22
+ from .resources import (
23
+ AccountResources,
24
+ AdminResources,
25
+ ApiClient,
26
+ BillingResources,
27
+ CreditsResources,
28
+ SessionsResources,
29
+ SettingsResources,
30
+ build_resources,
31
+ )
32
+ from .session import ProfileData, Session
33
+
34
+ __all__ = [
35
+ # client
36
+ "PawpadoClient",
37
+ # errors
38
+ "ApiError",
39
+ "NetworkError",
40
+ "PawpadoAuthError",
41
+ "PawpadoError",
42
+ "RefreshError",
43
+ # resources
44
+ "ApiClient",
45
+ "AccountResources",
46
+ "AdminResources",
47
+ "BillingResources",
48
+ "CreditsResources",
49
+ "SessionsResources",
50
+ "SettingsResources",
51
+ "build_resources",
52
+ # session
53
+ "ProfileData",
54
+ "Session",
55
+ ]
56
+
57
+ __version__ = "0.1.0"
@@ -0,0 +1,95 @@
1
+ """High-level Pawpado client mirroring the Node SDK's PawpadoClient surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ import httpx
8
+
9
+ from .resources import (
10
+ AccountResources,
11
+ AdminResources,
12
+ ApiClient,
13
+ BillingResources,
14
+ CreditsResources,
15
+ SessionsResources,
16
+ SettingsResources,
17
+ build_resources,
18
+ )
19
+ from .session import Session
20
+
21
+
22
+ class _ApiKeyApiClient(ApiClient):
23
+ """ApiClient variant that injects a static API key as the default Bearer.
24
+
25
+ Mirrors the Node SDK's ``a(token)`` helper: when no per-call ``auth_token``
26
+ is given, fall back to the configured static api_key.
27
+ """
28
+
29
+ def __init__(self, *, api_key: str, **kwargs: Any) -> None:
30
+ super().__init__(**kwargs)
31
+ self._api_key = api_key
32
+
33
+ def _request(self, method, path, body, *, query, headers, auth_token): # type: ignore[override]
34
+ if auth_token is None:
35
+ auth_token = self._api_key
36
+ return super()._request(
37
+ method, path, body, query=query, headers=headers, auth_token=auth_token,
38
+ )
39
+
40
+
41
+ class PawpadoClient:
42
+ """Talk to Pawpado.
43
+
44
+ Auth options (pick one):
45
+ - ``session=Session(...)`` — Bearer via Huudis device flow w/ refresh.
46
+ - ``api_key="..."`` — static access token (mirrors Node ``apiKey``).
47
+ - neither — only ``health()`` is usable; per-call ``auth_token=`` works.
48
+ """
49
+
50
+ # IDE hints — populated in __init__.
51
+ sessions: SessionsResources
52
+ credits: CreditsResources
53
+ billing: BillingResources
54
+ settings: SettingsResources
55
+ account: AccountResources
56
+ admin: AdminResources
57
+
58
+ def __init__(
59
+ self,
60
+ *,
61
+ base_url: str = "https://pawpado.com",
62
+ session: Optional[Session] = None,
63
+ api_key: Optional[str] = None,
64
+ http: Optional[httpx.Client] = None,
65
+ ) -> None:
66
+ self.base_url = base_url.rstrip("/")
67
+ self._http = http or httpx.Client(timeout=10.0)
68
+ self._owns_http = http is None
69
+ if api_key is not None and session is None:
70
+ self.api: ApiClient = _ApiKeyApiClient(
71
+ api_key=api_key, base_url=self.base_url, http=self._http,
72
+ )
73
+ else:
74
+ self.api = ApiClient(base_url=self.base_url, session=session, http=self._http)
75
+ resources = build_resources(self.api)
76
+ self.sessions = resources["sessions"] # type: ignore[assignment]
77
+ self.credits = resources["credits"] # type: ignore[assignment]
78
+ self.billing = resources["billing"] # type: ignore[assignment]
79
+ self.settings = resources["settings"] # type: ignore[assignment]
80
+ self.account = resources["account"] # type: ignore[assignment]
81
+ self.admin = resources["admin"] # type: ignore[assignment]
82
+
83
+ def close(self) -> None:
84
+ if self._owns_http:
85
+ self._http.close()
86
+
87
+ def __enter__(self) -> "PawpadoClient":
88
+ return self
89
+
90
+ def __exit__(self, *_: Any) -> None:
91
+ self.close()
92
+
93
+ # ─── Health (no auth) ───────────────────────────────────────────────
94
+ def health(self) -> Any:
95
+ return self.api.get("/api/v1/health")
@@ -0,0 +1,69 @@
1
+ """Typed error classes for the Pawpado SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+
7
+
8
+ class PawpadoAuthError(Exception):
9
+ """Raised on Pawpado auth / OIDC failures."""
10
+
11
+ def __init__(self, code: str, message: str) -> None:
12
+ super().__init__(message)
13
+ self.code = code
14
+ self.message = message
15
+
16
+ def __repr__(self) -> str:
17
+ return f"PawpadoAuthError({self.code!r}, {self.message!r})"
18
+
19
+
20
+ class RefreshError(Exception):
21
+ """Raised when refreshing an OIDC access token fails."""
22
+
23
+ def __init__(self, code: str, message: str) -> None:
24
+ super().__init__(message)
25
+ self.code = code
26
+ self.message = message
27
+
28
+ def __repr__(self) -> str:
29
+ return f"RefreshError({self.code!r}, {self.message!r})"
30
+
31
+
32
+ class NetworkError(Exception):
33
+ """Raised on transport failures (DNS, connect refused, TLS, etc.)."""
34
+
35
+ def __init__(self, message: str) -> None:
36
+ super().__init__(message)
37
+ self.message = message
38
+
39
+
40
+ class PawpadoError(Exception):
41
+ """Raised on enveloped errors (data=null, error.code/message)
42
+ or non-2xx HTTP responses from Pawpado APIs.
43
+
44
+ Aliased as ``ApiError`` for parity with the Node SDK's
45
+ ``ApiError as PawpadoError`` re-export.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ code: str,
51
+ message: str,
52
+ status: int,
53
+ *,
54
+ request_id: Optional[str] = None,
55
+ details: Optional[Dict[str, Any]] = None,
56
+ ) -> None:
57
+ super().__init__(message)
58
+ self.code = code
59
+ self.message = message
60
+ self.status = status
61
+ self.request_id = request_id
62
+ self.details = details
63
+
64
+ def __repr__(self) -> str:
65
+ return f"PawpadoError({self.code!r}, {self.message!r}, status={self.status})"
66
+
67
+
68
+ # Alias to match Node SDK naming.
69
+ ApiError = PawpadoError
@@ -0,0 +1,303 @@
1
+ """Typed ApiClient + resource namespaces for PawpadoClient.
2
+
3
+ Bundled into one module because pawpado's surface is small enough that splitting
4
+ ``http_client.py`` + ``resources.py`` (the huudis layout) is overkill — but the
5
+ class shapes match huudis 1:1 so they can be teased apart later.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, Iterator, Optional
11
+ from urllib.parse import urlparse
12
+
13
+ import httpx
14
+
15
+ from .errors import ApiError, NetworkError
16
+ from .session import Session
17
+
18
+
19
+ class ApiClient:
20
+ """Bearer-auth HTTP client with envelope unwrap + proactive/reactive
21
+ refresh. Mirrors @forjio/sdk's ApiClient."""
22
+
23
+ def __init__(
24
+ self,
25
+ *,
26
+ base_url: str,
27
+ session: Optional[Session] = None,
28
+ http: Optional[httpx.Client] = None,
29
+ refresh_buffer_sec: int = 300,
30
+ retry_on_5xx: int = 1,
31
+ default_headers: Optional[Dict[str, str]] = None,
32
+ default_query: Optional[Dict[str, str]] = None,
33
+ ) -> None:
34
+ self.base_url = base_url.rstrip("/")
35
+ self.session = session
36
+ self._http = http or httpx.Client(timeout=10.0)
37
+ self._owns_http = http is None
38
+ self.refresh_buffer_sec = refresh_buffer_sec
39
+ self.retry_on_5xx = retry_on_5xx
40
+ self.default_headers = default_headers or {}
41
+ self.default_query = default_query or {}
42
+
43
+ def close(self) -> None:
44
+ if self._owns_http:
45
+ self._http.close()
46
+
47
+ def __enter__(self) -> "ApiClient":
48
+ return self
49
+
50
+ def __exit__(self, *_: Any) -> None:
51
+ self.close()
52
+
53
+ # ---- public methods -------------------------------------------------
54
+
55
+ def get(self, path: str, *, query: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, auth_token: Optional[str] = None) -> Any:
56
+ return self._request("GET", path, None, query=query, headers=headers, auth_token=auth_token)
57
+
58
+ def post(self, path: str, body: Any = None, *, query: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, auth_token: Optional[str] = None) -> Any:
59
+ return self._request("POST", path, body, query=query, headers=headers, auth_token=auth_token)
60
+
61
+ def patch(self, path: str, body: Any = None, *, query: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, auth_token: Optional[str] = None) -> Any:
62
+ return self._request("PATCH", path, body, query=query, headers=headers, auth_token=auth_token)
63
+
64
+ def put(self, path: str, body: Any = None, *, query: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, auth_token: Optional[str] = None) -> Any:
65
+ return self._request("PUT", path, body, query=query, headers=headers, auth_token=auth_token)
66
+
67
+ def delete(self, path: str, *, query: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, auth_token: Optional[str] = None) -> Any:
68
+ return self._request("DELETE", path, None, query=query, headers=headers, auth_token=auth_token)
69
+
70
+ def paginate(
71
+ self,
72
+ path: str,
73
+ *,
74
+ query: Optional[Dict[str, Any]] = None,
75
+ cursor_param: str = "cursor",
76
+ auth_token: Optional[str] = None,
77
+ ) -> Iterator[Any]:
78
+ cursor: Optional[str] = None
79
+ while True:
80
+ q = dict(query or {})
81
+ if cursor:
82
+ q[cursor_param] = cursor
83
+ page = self.get(path, query=q, auth_token=auth_token)
84
+ for item in _extract_items(page):
85
+ yield item
86
+ cursor = _extract_cursor(page)
87
+ if not cursor:
88
+ return
89
+
90
+ # ---- internals ------------------------------------------------------
91
+
92
+ def _request(
93
+ self,
94
+ method: str,
95
+ path: str,
96
+ body: Any,
97
+ *,
98
+ query: Optional[Dict[str, Any]],
99
+ headers: Optional[Dict[str, str]],
100
+ auth_token: Optional[str],
101
+ ) -> Any:
102
+ # Proactive refresh (only when we own the bearer via session)
103
+ if (
104
+ self.session is not None
105
+ and auth_token is None
106
+ and self.session.will_expire_soon(self.refresh_buffer_sec)
107
+ ):
108
+ try:
109
+ self.session.refresh()
110
+ except Exception:
111
+ pass
112
+ res = self._send(method, path, body, query=query, headers=headers, auth_token=auth_token)
113
+ # Reactive single 401 retry
114
+ if res.status_code == 401 and self.session is not None and auth_token is None:
115
+ try:
116
+ self.session.refresh()
117
+ res = self._send(method, path, body, query=query, headers=headers, auth_token=auth_token)
118
+ except Exception:
119
+ pass
120
+ # 5xx retry
121
+ retries_left = self.retry_on_5xx
122
+ while res.status_code >= 500 and retries_left > 0:
123
+ retries_left -= 1
124
+ res = self._send(method, path, body, query=query, headers=headers, auth_token=auth_token)
125
+ return self._unwrap(res)
126
+
127
+ def _send(
128
+ self,
129
+ method: str,
130
+ path: str,
131
+ body: Any,
132
+ *,
133
+ query: Optional[Dict[str, Any]],
134
+ headers: Optional[Dict[str, str]],
135
+ auth_token: Optional[str],
136
+ ) -> httpx.Response:
137
+ if urlparse(path).scheme:
138
+ url = path
139
+ else:
140
+ url = f"{self.base_url}{path if path.startswith('/') else '/' + path}"
141
+ merged_q: Dict[str, Any] = dict(self.default_query)
142
+ if query:
143
+ merged_q.update({k: v for k, v in query.items() if v is not None})
144
+ h: Dict[str, str] = dict(self.default_headers)
145
+ h["accept"] = h.get("accept", "application/json")
146
+ if headers:
147
+ h.update(headers)
148
+ token = auth_token or (
149
+ self.session.data.access_token if (self.session and self.session.data) else None
150
+ )
151
+ if token:
152
+ h["authorization"] = f"Bearer {token}"
153
+ kwargs: Dict[str, Any] = {"params": merged_q or None, "headers": h}
154
+ if body is not None:
155
+ h.setdefault("content-type", "application/json")
156
+ kwargs["json"] = body
157
+ try:
158
+ return self._http.request(method, url, **kwargs)
159
+ except httpx.HTTPError as e:
160
+ raise NetworkError(str(e)) from e
161
+
162
+ def _unwrap(self, res: httpx.Response) -> Any:
163
+ text = res.text or ""
164
+ parsed: Any = None
165
+ if text:
166
+ try:
167
+ parsed = res.json()
168
+ except Exception:
169
+ if res.status_code >= 400:
170
+ raise ApiError("NON_JSON_ERROR", text or res.reason_phrase, res.status_code)
171
+ raise ApiError("INVALID_RESPONSE", "non-JSON response", res.status_code)
172
+ if isinstance(parsed, dict) and "data" in parsed and "error" in parsed and "meta" in parsed:
173
+ if parsed.get("error"):
174
+ error = parsed["error"]
175
+ raise ApiError(
176
+ error.get("code", "UNKNOWN"),
177
+ error.get("message", ""),
178
+ res.status_code,
179
+ request_id=(parsed.get("meta") or {}).get("requestId"),
180
+ details=error.get("details"),
181
+ )
182
+ return parsed.get("data")
183
+ if res.status_code >= 400:
184
+ err_obj = parsed.get("error") if isinstance(parsed, dict) else parsed
185
+ code = (err_obj or {}).get("code", "HTTP_ERROR") if isinstance(err_obj, dict) else "HTTP_ERROR"
186
+ message = (
187
+ (err_obj or {}).get("message", res.reason_phrase)
188
+ if isinstance(err_obj, dict)
189
+ else res.reason_phrase
190
+ )
191
+ raise ApiError(
192
+ code,
193
+ message,
194
+ res.status_code,
195
+ details=parsed if isinstance(parsed, dict) else None,
196
+ )
197
+ return parsed
198
+
199
+
200
+ def _extract_items(page: Any) -> Iterator[Any]:
201
+ if isinstance(page, list):
202
+ yield from page
203
+ return
204
+ if isinstance(page, dict):
205
+ for key in ("items", "data", "results"):
206
+ v = page.get(key)
207
+ if isinstance(v, list):
208
+ yield from v
209
+ return
210
+
211
+
212
+ def _extract_cursor(page: Any) -> Optional[str]:
213
+ if not isinstance(page, dict):
214
+ return None
215
+ nc = page.get("nextCursor")
216
+ if isinstance(nc, str) and nc:
217
+ return nc
218
+ meta = page.get("meta")
219
+ if isinstance(meta, dict):
220
+ mc = meta.get("nextCursor")
221
+ if isinstance(mc, str) and mc:
222
+ return mc
223
+ return None
224
+
225
+
226
+ # ─── Resource namespaces ────────────────────────────────────────────────
227
+
228
+
229
+ class _Namespace:
230
+ def __init__(self, api: ApiClient) -> None:
231
+ self.api = api
232
+
233
+
234
+ def _opts(auth_token: Optional[str]) -> Dict[str, Any]:
235
+ return {"auth_token": auth_token} if auth_token else {}
236
+
237
+
238
+ class SessionsResources(_Namespace):
239
+ def start(self, *, auth_token: Optional[str] = None):
240
+ return self.api.post("/api/v1/sessions/start", None, **_opts(auth_token))
241
+
242
+ def stop(self, body: Optional[Dict[str, Any]] = None, *, auth_token: Optional[str] = None):
243
+ return self.api.post("/api/v1/sessions/stop", body or {}, **_opts(auth_token))
244
+
245
+ def poll(self, *, auth_token: Optional[str] = None):
246
+ return self.api.get("/api/v1/sessions/poll", **_opts(auth_token))
247
+
248
+ def pair(self, *, auth_token: Optional[str] = None):
249
+ return self.api.post("/api/v1/sessions/pair", None, **_opts(auth_token))
250
+
251
+ def connect(self, *, auth_token: Optional[str] = None):
252
+ return self.api.get("/api/v1/sessions/connect", **_opts(auth_token))
253
+
254
+ def tailscale_key(self, *, auth_token: Optional[str] = None):
255
+ return self.api.post("/api/v1/sessions/tailscale-key", None, **_opts(auth_token))
256
+
257
+
258
+ class CreditsResources(_Namespace):
259
+ def me(self, *, auth_token: Optional[str] = None):
260
+ return self.api.get("/api/v1/credits/me", **_opts(auth_token))
261
+
262
+ def topup(self, body: Dict[str, Any], *, auth_token: Optional[str] = None):
263
+ return self.api.post("/api/v1/credits/topup", body, **_opts(auth_token))
264
+
265
+
266
+ class BillingResources(_Namespace):
267
+ def storage(self, *, auth_token: Optional[str] = None):
268
+ return self.api.get("/api/v1/billing/storage", **_opts(auth_token))
269
+
270
+
271
+ class SettingsResources(_Namespace):
272
+ def get(self, *, auth_token: Optional[str] = None):
273
+ return self.api.get("/api/v1/settings", **_opts(auth_token))
274
+
275
+ def update(self, patch: Dict[str, Any], *, auth_token: Optional[str] = None):
276
+ return self.api.patch("/api/v1/settings", patch, **_opts(auth_token))
277
+
278
+
279
+ class AccountResources(_Namespace):
280
+ def session(self, *, auth_token: Optional[str] = None):
281
+ return self.api.get("/api/v1/session", **_opts(auth_token))
282
+
283
+ def delete(self, *, auth_token: Optional[str] = None):
284
+ return self.api.delete("/api/v1/account/delete", **_opts(auth_token))
285
+
286
+
287
+ class AdminResources(_Namespace):
288
+ def reconcile(self, *, auth_token: Optional[str] = None):
289
+ return self.api.post("/api/v1/admin/reconcile", None, **_opts(auth_token))
290
+
291
+ def orphans(self, *, auth_token: Optional[str] = None):
292
+ return self.api.get("/api/v1/admin/orphans", **_opts(auth_token))
293
+
294
+
295
+ def build_resources(api: ApiClient) -> Dict[str, _Namespace]:
296
+ return {
297
+ "sessions": SessionsResources(api),
298
+ "credits": CreditsResources(api),
299
+ "billing": BillingResources(api),
300
+ "settings": SettingsResources(api),
301
+ "account": AccountResources(api),
302
+ "admin": AdminResources(api),
303
+ }
@@ -0,0 +1,262 @@
1
+ """Multi-profile credentials store mirroring @forjio/sdk's Session.
2
+
3
+ INI-format ``~/.pawpado/credentials``, one section per profile. Single-flight
4
+ refresh guard prevents concurrent calls from burning the Huudis refresh-token
5
+ family (Huudis revokes the entire family on reuse — see the
6
+ ``feedback_huudis_refresh_singleflight`` rule).
7
+
8
+ This is the Python parity of the Node ``Session`` from ``@forjio/sdk``. If a
9
+ central ``forjio-sdk-py`` is later extracted, this module moves there
10
+ unchanged.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import configparser
16
+ import os
17
+ import stat
18
+ import tempfile
19
+ import threading
20
+ import time
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any, Callable, Dict, Optional
24
+ from urllib.parse import urlencode
25
+
26
+ import httpx
27
+
28
+ from .errors import PawpadoAuthError, RefreshError
29
+
30
+
31
+ @dataclass
32
+ class ProfileData:
33
+ access_token: str
34
+ expires_at: int
35
+ issuer: str
36
+ client_id: str
37
+ refresh_token: Optional[str] = None
38
+ scope: Optional[str] = None
39
+ account_id: Optional[str] = None
40
+
41
+
42
+ # Default refresh implementation — hits the OIDC token endpoint at
43
+ # ``{issuer}/api/v1/oidc/token``. Tests inject their own via the
44
+ # ``refresh_impl`` constructor arg.
45
+ def _default_refresh(
46
+ *,
47
+ issuer: str,
48
+ client_id: str,
49
+ refresh_token: str,
50
+ scope: Optional[str] = None,
51
+ http: Optional[httpx.Client] = None,
52
+ ) -> Dict[str, Any]:
53
+ body = {
54
+ "grant_type": "refresh_token",
55
+ "refresh_token": refresh_token,
56
+ "client_id": client_id,
57
+ }
58
+ if scope:
59
+ body["scope"] = scope
60
+ client = http or httpx.Client(timeout=10.0)
61
+ owns = http is None
62
+ try:
63
+ res = client.post(
64
+ f"{issuer.rstrip('/')}/api/v1/oidc/token",
65
+ headers={"content-type": "application/x-www-form-urlencoded"},
66
+ content=urlencode(body),
67
+ )
68
+ except httpx.HTTPError as e:
69
+ raise RefreshError("NETWORK_ERROR", str(e)) from e
70
+ finally:
71
+ if owns:
72
+ client.close()
73
+ try:
74
+ payload = res.json()
75
+ except Exception:
76
+ payload = {}
77
+ if res.status_code >= 400:
78
+ code = (payload.get("error") if isinstance(payload, dict) else None) or "refresh_failed"
79
+ desc = (payload.get("error_description") if isinstance(payload, dict) else None) or "refresh failed"
80
+ raise RefreshError(str(code).upper(), str(desc))
81
+ return payload
82
+
83
+
84
+ class Session:
85
+ def __init__(
86
+ self,
87
+ *,
88
+ brand: str = "pawpado",
89
+ profile: Optional[str] = None,
90
+ credentials_path: Optional[str] = None,
91
+ http: Optional[httpx.Client] = None,
92
+ refresh_impl: Optional[Callable[..., Dict[str, Any]]] = None,
93
+ ) -> None:
94
+ self.brand = brand
95
+ env_key = f"{brand.upper()}_PROFILE"
96
+ self.profile = profile or os.environ.get(env_key, "default")
97
+ self.credentials_path = Path(
98
+ credentials_path or os.path.expanduser(f"~/.{brand}/credentials")
99
+ )
100
+ self.http = http
101
+ self._refresh_impl = refresh_impl or _default_refresh
102
+ self._data: Optional[ProfileData] = None
103
+ self._refresh_lock = threading.Lock()
104
+ self._refresh_in_flight: Optional[threading.Event] = None
105
+ self._refresh_error: Optional[BaseException] = None
106
+
107
+ @property
108
+ def data(self) -> Optional[ProfileData]:
109
+ return self._data
110
+
111
+ def set_data(self, data: ProfileData) -> None:
112
+ """In-memory only — useful for tests and ad-hoc use."""
113
+ self._data = data
114
+
115
+ def load(self) -> ProfileData:
116
+ if not self.credentials_path.exists():
117
+ raise PawpadoAuthError(
118
+ "SESSION_NOT_FOUND",
119
+ f"credentials file not found at {self.credentials_path}",
120
+ )
121
+ parser = configparser.ConfigParser()
122
+ parser.read(self.credentials_path)
123
+ if not parser.has_section(self.profile):
124
+ raise PawpadoAuthError(
125
+ "PROFILE_NOT_FOUND",
126
+ f"profile [{self.profile}] not in {self.credentials_path}",
127
+ )
128
+ section = parser[self.profile]
129
+ for required in ("access_token", "issuer", "client_id"):
130
+ if required not in section:
131
+ raise PawpadoAuthError(
132
+ "SESSION_INCOMPLETE",
133
+ f"profile [{self.profile}] missing {required}",
134
+ )
135
+ self._data = ProfileData(
136
+ access_token=section["access_token"],
137
+ expires_at=int(section.get("expires_at", "0") or "0"),
138
+ issuer=section["issuer"],
139
+ client_id=section["client_id"],
140
+ refresh_token=section.get("refresh_token") or None,
141
+ scope=section.get("scope") or None,
142
+ account_id=section.get("account_id") or None,
143
+ )
144
+ return self._data
145
+
146
+ def save(self, data: Optional[ProfileData] = None) -> None:
147
+ if data is not None:
148
+ self._data = data
149
+ if self._data is None:
150
+ raise RuntimeError("Session.save called with no data")
151
+ parser = configparser.ConfigParser()
152
+ if self.credentials_path.exists():
153
+ parser.read(self.credentials_path)
154
+ parser[self.profile] = {
155
+ "access_token": self._data.access_token,
156
+ "expires_at": str(self._data.expires_at),
157
+ "issuer": self._data.issuer,
158
+ "client_id": self._data.client_id,
159
+ }
160
+ if self._data.refresh_token:
161
+ parser[self.profile]["refresh_token"] = self._data.refresh_token
162
+ if self._data.scope:
163
+ parser[self.profile]["scope"] = self._data.scope
164
+ if self._data.account_id:
165
+ parser[self.profile]["account_id"] = self._data.account_id
166
+ self.credentials_path.parent.mkdir(parents=True, exist_ok=True)
167
+ fd, tmp = tempfile.mkstemp(prefix=".credentials.", dir=str(self.credentials_path.parent))
168
+ try:
169
+ with os.fdopen(fd, "w") as f:
170
+ parser.write(f)
171
+ os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
172
+ os.replace(tmp, self.credentials_path)
173
+ except Exception:
174
+ if os.path.exists(tmp):
175
+ os.unlink(tmp)
176
+ raise
177
+
178
+ def clear(self) -> None:
179
+ if not self.credentials_path.exists():
180
+ self._data = None
181
+ return
182
+ parser = configparser.ConfigParser()
183
+ parser.read(self.credentials_path)
184
+ if parser.has_section(self.profile):
185
+ parser.remove_section(self.profile)
186
+ self._data = None
187
+ if not parser.sections():
188
+ self.credentials_path.unlink(missing_ok=True)
189
+ return
190
+ fd, tmp = tempfile.mkstemp(prefix=".credentials.", dir=str(self.credentials_path.parent))
191
+ try:
192
+ with os.fdopen(fd, "w") as f:
193
+ parser.write(f)
194
+ os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
195
+ os.replace(tmp, self.credentials_path)
196
+ except Exception:
197
+ if os.path.exists(tmp):
198
+ os.unlink(tmp)
199
+ raise
200
+
201
+ def is_expired(self) -> bool:
202
+ if self._data is None:
203
+ return True
204
+ return time.time() >= self._data.expires_at
205
+
206
+ def will_expire_soon(self, buffer_sec: int = 300) -> bool:
207
+ if self._data is None:
208
+ return True
209
+ return time.time() + buffer_sec >= self._data.expires_at
210
+
211
+ def refresh(self) -> None:
212
+ """Single-flight refresh. Concurrent callers share the same refresh."""
213
+ with self._refresh_lock:
214
+ if self._refresh_in_flight is not None:
215
+ event = self._refresh_in_flight
216
+ else:
217
+ self._refresh_in_flight = threading.Event()
218
+ self._refresh_error = None
219
+ event = self._refresh_in_flight
220
+ self._spawn_refresh()
221
+ event.wait()
222
+ if self._refresh_error is not None:
223
+ raise self._refresh_error
224
+
225
+ def _spawn_refresh(self) -> None:
226
+ try:
227
+ self._do_refresh()
228
+ except BaseException as e: # noqa: BLE001
229
+ self._refresh_error = e
230
+ finally:
231
+ event = self._refresh_in_flight
232
+ with self._refresh_lock:
233
+ self._refresh_in_flight = None
234
+ if event is not None:
235
+ event.set()
236
+
237
+ def _do_refresh(self) -> None:
238
+ if self._data is None:
239
+ raise RefreshError("NO_SESSION", "load() or set_data() before refresh()")
240
+ if not self._data.refresh_token:
241
+ raise RefreshError("NO_REFRESH_TOKEN", "session has no refresh_token")
242
+ payload = self._refresh_impl(
243
+ issuer=self._data.issuer,
244
+ client_id=self._data.client_id,
245
+ refresh_token=self._data.refresh_token,
246
+ scope=self._data.scope,
247
+ http=self.http,
248
+ )
249
+ expires_in = int(payload.get("expires_in", 3600))
250
+ self._data = ProfileData(
251
+ access_token=payload["access_token"],
252
+ expires_at=int(time.time()) + expires_in,
253
+ issuer=self._data.issuer,
254
+ client_id=self._data.client_id,
255
+ refresh_token=payload.get("refresh_token") or self._data.refresh_token,
256
+ scope=payload.get("scope") or self._data.scope,
257
+ account_id=self._data.account_id,
258
+ )
259
+ # Only persist if we have a credentials file already on disk; in-memory
260
+ # sessions stay in memory.
261
+ if self.credentials_path.exists():
262
+ self.save()
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pawpado"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for Pawpado — single-user AWS GPU gaming portal. Sessions (start/stop/poll/pair/connect), credits, billing, settings, admin. Bearer auth via Huudis OIDC device flow."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ keywords = ["pawpado", "forjio", "aws", "gpu", "gaming", "huudis"]
13
+ authors = [{ name = "Forjio", email = "hello@pawpado.com" }]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Topic :: Internet :: WWW/HTTP",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = [
23
+ "httpx>=0.27.0",
24
+ "PyJWT[crypto]>=2.9.0",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "pytest>=8.0",
30
+ "respx>=0.21",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://pawpado.com"
35
+ Source = "https://github.com/hachimi-cat/saas-pawpado/tree/main/sdk/python"
36
+ Issues = "https://github.com/hachimi-cat/saas-pawpado/issues"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["pawpado"]
File without changes
@@ -0,0 +1,230 @@
1
+ """Smoke tests for PawpadoClient — uses respx to intercept httpx.
2
+
3
+ Same testing strategy as the Node SDK's ``test/resources.test.ts``: spin up
4
+ a fake transport, fire each method once, assert path + method + body +
5
+ Bearer header.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from typing import Any
13
+
14
+ import httpx
15
+ import pytest
16
+ import respx
17
+
18
+ from pawpado import (
19
+ ApiError,
20
+ PawpadoClient,
21
+ PawpadoError,
22
+ ProfileData,
23
+ Session,
24
+ )
25
+
26
+ BASE = "https://pawpado.test"
27
+
28
+
29
+ def _envelope(data: Any) -> dict:
30
+ return {
31
+ "data": data,
32
+ "error": None,
33
+ "meta": {"requestId": "req_test", "timestamp": "now"},
34
+ }
35
+
36
+
37
+ @pytest.fixture
38
+ def mock():
39
+ with respx.mock(base_url=BASE, assert_all_called=False) as m:
40
+ yield m
41
+
42
+
43
+ def _ok_route(mock, method: str, path: str):
44
+ return getattr(mock, method.lower())(path).mock(
45
+ return_value=httpx.Response(200, json=_envelope({"ok": True}))
46
+ )
47
+
48
+
49
+ # ─── Construction ────────────────────────────────────────────────────────
50
+
51
+
52
+ def test_client_construction_defaults():
53
+ c = PawpadoClient()
54
+ assert c.base_url == "https://pawpado.com"
55
+ # All resource namespaces exist
56
+ assert c.sessions and c.credits and c.billing
57
+ assert c.settings and c.account and c.admin
58
+
59
+
60
+ # ─── Bearer auth via api_key ─────────────────────────────────────────────
61
+
62
+
63
+ def test_api_key_attaches_bearer(mock):
64
+ route = _ok_route(mock, "GET", "/api/v1/sessions/poll")
65
+ client = PawpadoClient(base_url=BASE, api_key="tok")
66
+ client.sessions.poll()
67
+ assert route.calls.last.request.headers["authorization"] == "Bearer tok"
68
+
69
+
70
+ def test_per_call_token_overrides_api_key(mock):
71
+ route = _ok_route(mock, "GET", "/api/v1/sessions/poll")
72
+ client = PawpadoClient(base_url=BASE, api_key="tok")
73
+ client.sessions.poll(auth_token="override")
74
+ assert route.calls.last.request.headers["authorization"] == "Bearer override"
75
+
76
+
77
+ def test_no_auth_header_when_no_token(mock):
78
+ route = _ok_route(mock, "GET", "/api/v1/sessions/poll")
79
+ client = PawpadoClient(base_url=BASE)
80
+ client.sessions.poll()
81
+ assert "authorization" not in route.calls.last.request.headers
82
+
83
+
84
+ # ─── Every route ─────────────────────────────────────────────────────────
85
+
86
+
87
+ def test_sessions_start_post(mock):
88
+ route = _ok_route(mock, "POST", "/api/v1/sessions/start")
89
+ PawpadoClient(base_url=BASE, api_key="t").sessions.start()
90
+ assert route.called
91
+ assert route.calls.last.request.method == "POST"
92
+
93
+
94
+ def test_sessions_stop_post_with_body(mock):
95
+ route = _ok_route(mock, "POST", "/api/v1/sessions/stop")
96
+ PawpadoClient(base_url=BASE, api_key="t").sessions.stop({"force": True})
97
+ assert json.loads(route.calls.last.request.content) == {"force": True}
98
+
99
+
100
+ def test_sessions_pair_post(mock):
101
+ route = _ok_route(mock, "POST", "/api/v1/sessions/pair")
102
+ PawpadoClient(base_url=BASE, api_key="t").sessions.pair()
103
+ assert route.called
104
+
105
+
106
+ def test_sessions_connect_get(mock):
107
+ route = _ok_route(mock, "GET", "/api/v1/sessions/connect")
108
+ PawpadoClient(base_url=BASE, api_key="t").sessions.connect()
109
+ assert route.called
110
+
111
+
112
+ def test_sessions_tailscale_key_post(mock):
113
+ route = _ok_route(mock, "POST", "/api/v1/sessions/tailscale-key")
114
+ PawpadoClient(base_url=BASE, api_key="t").sessions.tailscale_key()
115
+ assert route.called
116
+
117
+
118
+ def test_credits_me_get(mock):
119
+ route = _ok_route(mock, "GET", "/api/v1/credits/me")
120
+ PawpadoClient(base_url=BASE, api_key="t").credits.me()
121
+ assert route.called
122
+
123
+
124
+ def test_credits_topup_post(mock):
125
+ route = _ok_route(mock, "POST", "/api/v1/credits/topup")
126
+ PawpadoClient(base_url=BASE, api_key="t").credits.topup(
127
+ {"amountCents": 50000, "currency": "IDR"},
128
+ )
129
+ body = json.loads(route.calls.last.request.content)
130
+ assert body == {"amountCents": 50000, "currency": "IDR"}
131
+
132
+
133
+ def test_billing_storage_get(mock):
134
+ route = _ok_route(mock, "GET", "/api/v1/billing/storage")
135
+ PawpadoClient(base_url=BASE, api_key="t").billing.storage()
136
+ assert route.called
137
+
138
+
139
+ def test_settings_get(mock):
140
+ route = _ok_route(mock, "GET", "/api/v1/settings")
141
+ PawpadoClient(base_url=BASE, api_key="t").settings.get()
142
+ assert route.called
143
+
144
+
145
+ def test_settings_update_patches(mock):
146
+ route = _ok_route(mock, "PATCH", "/api/v1/settings")
147
+ PawpadoClient(base_url=BASE, api_key="t").settings.update({"autoStopMinutes": 30})
148
+ assert route.calls.last.request.method == "PATCH"
149
+ assert json.loads(route.calls.last.request.content) == {"autoStopMinutes": 30}
150
+
151
+
152
+ def test_account_session_get(mock):
153
+ route = _ok_route(mock, "GET", "/api/v1/session")
154
+ PawpadoClient(base_url=BASE, api_key="t").account.session()
155
+ assert route.called
156
+
157
+
158
+ def test_account_delete(mock):
159
+ route = _ok_route(mock, "DELETE", "/api/v1/account/delete")
160
+ PawpadoClient(base_url=BASE, api_key="t").account.delete()
161
+ assert route.calls.last.request.method == "DELETE"
162
+
163
+
164
+ def test_admin_reconcile_post(mock):
165
+ route = _ok_route(mock, "POST", "/api/v1/admin/reconcile")
166
+ PawpadoClient(base_url=BASE, api_key="t").admin.reconcile()
167
+ assert route.called
168
+
169
+
170
+ def test_admin_orphans_get(mock):
171
+ route = _ok_route(mock, "GET", "/api/v1/admin/orphans")
172
+ PawpadoClient(base_url=BASE, api_key="t").admin.orphans()
173
+ assert route.called
174
+
175
+
176
+ def test_health_no_auth(mock):
177
+ route = _ok_route(mock, "GET", "/api/v1/health")
178
+ PawpadoClient(base_url=BASE).health()
179
+ assert "authorization" not in route.calls.last.request.headers
180
+
181
+
182
+ # ─── Errors ──────────────────────────────────────────────────────────────
183
+
184
+
185
+ def test_enveloped_error_raises_pawpado_error(mock):
186
+ mock.get("/api/v1/sessions/poll").mock(
187
+ return_value=httpx.Response(
188
+ 400,
189
+ json={
190
+ "data": None,
191
+ "error": {"code": "BAD_STATE", "message": "session not running"},
192
+ "meta": {"requestId": "req_x", "timestamp": "now"},
193
+ },
194
+ ),
195
+ )
196
+ with pytest.raises(PawpadoError) as exc:
197
+ PawpadoClient(base_url=BASE, api_key="t").sessions.poll()
198
+ assert exc.value.code == "BAD_STATE"
199
+ assert exc.value.status == 400
200
+ assert exc.value.request_id == "req_x"
201
+ # Alias still works.
202
+ assert isinstance(exc.value, ApiError)
203
+
204
+
205
+ def test_non_enveloped_4xx_raises(mock):
206
+ mock.get("/api/v1/sessions/poll").mock(
207
+ return_value=httpx.Response(404, text="not found"),
208
+ )
209
+ with pytest.raises(PawpadoError) as exc:
210
+ PawpadoClient(base_url=BASE, api_key="t").sessions.poll()
211
+ assert exc.value.status == 404
212
+
213
+
214
+ # ─── Session-based auth (Bearer via Session) ─────────────────────────────
215
+
216
+
217
+ def test_session_bearer_used(mock):
218
+ route = _ok_route(mock, "GET", "/api/v1/credits/me")
219
+ session = Session(brand="pawpado", credentials_path="/tmp/__never_exists__")
220
+ session.set_data(
221
+ ProfileData(
222
+ access_token="sess_tok",
223
+ expires_at=int(time.time()) + 3600,
224
+ issuer="https://huudis.test",
225
+ client_id="oc_pawpado",
226
+ )
227
+ )
228
+ client = PawpadoClient(base_url=BASE, session=session)
229
+ client.credits.me()
230
+ assert route.calls.last.request.headers["authorization"] == "Bearer sess_tok"