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.
- pawpado-0.1.0/.gitignore +66 -0
- pawpado-0.1.0/PKG-INFO +92 -0
- pawpado-0.1.0/README.md +68 -0
- pawpado-0.1.0/pawpado/__init__.py +57 -0
- pawpado-0.1.0/pawpado/client.py +95 -0
- pawpado-0.1.0/pawpado/errors.py +69 -0
- pawpado-0.1.0/pawpado/resources.py +303 -0
- pawpado-0.1.0/pawpado/session.py +262 -0
- pawpado-0.1.0/pyproject.toml +39 -0
- pawpado-0.1.0/tests/__init__.py +0 -0
- pawpado-0.1.0/tests/test_client.py +230 -0
pawpado-0.1.0/.gitignore
ADDED
|
@@ -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
|
pawpado-0.1.0/README.md
ADDED
|
@@ -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"
|