pysequence-sdk 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.
- pysequence_sdk-0.1.0/PKG-INFO +17 -0
- pysequence_sdk-0.1.0/pyproject.toml +21 -0
- pysequence_sdk-0.1.0/src/pysequence_sdk/__init__.py +14 -0
- pysequence_sdk-0.1.0/src/pysequence_sdk/auth.py +246 -0
- pysequence_sdk-0.1.0/src/pysequence_sdk/client.py +535 -0
- pysequence_sdk-0.1.0/src/pysequence_sdk/config.py +48 -0
- pysequence_sdk-0.1.0/src/pysequence_sdk/exceptions.py +20 -0
- pysequence_sdk-0.1.0/src/pysequence_sdk/graphql/__init__.py +0 -0
- pysequence_sdk-0.1.0/src/pysequence_sdk/graphql/mutations.py +41 -0
- pysequence_sdk-0.1.0/src/pysequence_sdk/graphql/queries.py +643 -0
- pysequence_sdk-0.1.0/src/pysequence_sdk/models.py +70 -0
- pysequence_sdk-0.1.0/src/pysequence_sdk/types.py +28 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pysequence-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial Python SDK for the GetSequence personal finance platform
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Christian De Leon
|
|
7
|
+
Author-email: christian@deleon.me
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Requires-Dist: curl-cffi (>=0.14.0,<0.15.0)
|
|
16
|
+
Requires-Dist: playwright (>=1.58.0,<2.0.0)
|
|
17
|
+
Requires-Dist: pydantic (>=2.12.5,<3.0.0)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pysequence-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Unofficial Python SDK for the GetSequence personal finance platform"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Christian De Leon", email = "christian@deleon.me"}
|
|
7
|
+
]
|
|
8
|
+
license = {text = "MIT"}
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"curl-cffi (>=0.14.0,<0.15.0)",
|
|
12
|
+
"playwright (>=1.58.0,<2.0.0)",
|
|
13
|
+
"pydantic (>=2.12.5,<3.0.0)",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[tool.poetry]
|
|
17
|
+
packages = [{include = "pysequence_sdk", from = "src"}]
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
21
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from pysequence_sdk.auth import AuthTokens, get_access_token
|
|
2
|
+
from pysequence_sdk.client import SequenceClient
|
|
3
|
+
from pysequence_sdk.config import (
|
|
4
|
+
get_credentials,
|
|
5
|
+
get_sequence_config,
|
|
6
|
+
SequenceConfig,
|
|
7
|
+
SequenceCredentials,
|
|
8
|
+
)
|
|
9
|
+
from pysequence_sdk.exceptions import (
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
GraphQLError,
|
|
12
|
+
SequenceError,
|
|
13
|
+
TokenExpiredError,
|
|
14
|
+
)
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Auth0 token management for GetSequence API.
|
|
2
|
+
|
|
3
|
+
Handles authentication via headless Chromium browser login:
|
|
4
|
+
1. Navigate to app.getsequence.io -> Auth0 login redirect
|
|
5
|
+
2. Fill email + password + TOTP with per-character delays
|
|
6
|
+
3. Capture tokens from the Auth0 token exchange response
|
|
7
|
+
|
|
8
|
+
Tokens are cached to .tokens.json so they survive restarts.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import random
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import asdict, dataclass
|
|
16
|
+
|
|
17
|
+
from curl_cffi.requests import Session
|
|
18
|
+
from playwright.sync_api import Page, sync_playwright
|
|
19
|
+
from pysequence_sdk.config import DATA_DIR, get_credentials, get_sequence_config
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
AUTH0_DOMAIN = "https://auth.getsequence.io"
|
|
25
|
+
SEQUENCE_APP_URL = "https://app.getsequence.io"
|
|
26
|
+
TOKEN_PATH = DATA_DIR / ".tokens.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AuthTokens:
|
|
31
|
+
access_token: str
|
|
32
|
+
refresh_token: str | None
|
|
33
|
+
expires_at: float
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _save_tokens(tokens: AuthTokens) -> None:
|
|
37
|
+
TOKEN_PATH.write_text(json.dumps(asdict(tokens)))
|
|
38
|
+
log.debug("Tokens saved to %s", TOKEN_PATH)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_tokens() -> AuthTokens | None:
|
|
42
|
+
if not TOKEN_PATH.exists():
|
|
43
|
+
log.debug("No token cache at %s", TOKEN_PATH)
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
data = json.loads(TOKEN_PATH.read_text())
|
|
48
|
+
return AuthTokens(**data)
|
|
49
|
+
|
|
50
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
51
|
+
log.warning("Corrupt token cache at %s, ignoring", TOKEN_PATH)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _human_type(page: Page, selector: str, text: str) -> None:
|
|
56
|
+
"""Type text into a field with random per-character delays."""
|
|
57
|
+
|
|
58
|
+
page.click(selector)
|
|
59
|
+
|
|
60
|
+
for char in text:
|
|
61
|
+
page.keyboard.type(char)
|
|
62
|
+
page.wait_for_timeout(random.randint(50, 150))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def authenticate() -> AuthTokens:
|
|
66
|
+
"""Full Auth0 login via headless Chromium browser.
|
|
67
|
+
|
|
68
|
+
Launches a headless browser, navigates to the app login page, clicks
|
|
69
|
+
through Auth0's Universal Login (email -> password -> MFA), then
|
|
70
|
+
extracts the access token from sessionStorage after the app loads.
|
|
71
|
+
|
|
72
|
+
The app uses Auth0's implicit flow (response_type=token), so the
|
|
73
|
+
token arrives as a URL fragment and the app stores it in sessionStorage.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
log.info("Starting browser authentication")
|
|
77
|
+
creds = get_credentials()
|
|
78
|
+
|
|
79
|
+
with sync_playwright() as p:
|
|
80
|
+
browser = p.chromium.launch(headless=True)
|
|
81
|
+
context = browser.new_context()
|
|
82
|
+
context.add_init_script(
|
|
83
|
+
"Object.defineProperty(navigator, 'webdriver', {get: () => false})"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
page = context.new_page()
|
|
87
|
+
|
|
88
|
+
# Navigate to app login page
|
|
89
|
+
log.info("Navigating to %s", SEQUENCE_APP_URL)
|
|
90
|
+
page.goto(SEQUENCE_APP_URL)
|
|
91
|
+
log.info("Page loaded: %s", page.url)
|
|
92
|
+
|
|
93
|
+
# Click "Log in" button to trigger Auth0 redirect
|
|
94
|
+
page.wait_for_selector('button:has-text("Log in")')
|
|
95
|
+
page.wait_for_timeout(random.randint(500, 2000))
|
|
96
|
+
page.click('button:has-text("Log in")')
|
|
97
|
+
log.info("Clicked 'Log in', waiting for Auth0 redirect")
|
|
98
|
+
|
|
99
|
+
# Email field (on Auth0 login page)
|
|
100
|
+
page.wait_for_selector('input[name="username"], input[type="email"]')
|
|
101
|
+
log.info("Auth0 login page loaded: %s", page.url)
|
|
102
|
+
page.wait_for_timeout(random.randint(500, 2000))
|
|
103
|
+
_human_type(page, 'input[name="username"], input[type="email"]', creds.email)
|
|
104
|
+
|
|
105
|
+
# Submit email
|
|
106
|
+
page.wait_for_timeout(random.randint(300, 1000))
|
|
107
|
+
page.click('button[type="submit"]')
|
|
108
|
+
log.info("Email submitted, waiting for password field")
|
|
109
|
+
|
|
110
|
+
# Password field (appears after email submit)
|
|
111
|
+
page.wait_for_selector('input[name="password"]')
|
|
112
|
+
page.wait_for_timeout(random.randint(500, 2000))
|
|
113
|
+
_human_type(page, 'input[name="password"]', creds.password)
|
|
114
|
+
|
|
115
|
+
# Submit password
|
|
116
|
+
page.wait_for_timeout(random.randint(300, 1000))
|
|
117
|
+
page.click('button[type="submit"]')
|
|
118
|
+
log.info("Password submitted, waiting for MFA page")
|
|
119
|
+
|
|
120
|
+
# MFA page — wait for OTP input
|
|
121
|
+
page.wait_for_selector('input[name="code"], input[inputmode="numeric"]')
|
|
122
|
+
log.info("MFA page loaded")
|
|
123
|
+
page.wait_for_timeout(random.randint(500, 2000))
|
|
124
|
+
_human_type(page, 'input[name="code"], input[inputmode="numeric"]', creds.totp)
|
|
125
|
+
|
|
126
|
+
# Submit MFA
|
|
127
|
+
page.wait_for_timeout(random.randint(300, 1000))
|
|
128
|
+
page.click('button[type="submit"]')
|
|
129
|
+
log.info("MFA submitted, waiting for app to load")
|
|
130
|
+
|
|
131
|
+
# Wait for redirect back to the app and token to appear in sessionStorage
|
|
132
|
+
page.wait_for_url(f"{SEQUENCE_APP_URL}/**", timeout=30000)
|
|
133
|
+
log.info("Redirected to app: %s", page.url)
|
|
134
|
+
|
|
135
|
+
# The app stores the access token in sessionStorage after processing
|
|
136
|
+
# the Auth0 implicit flow callback (token arrives via URL fragment)
|
|
137
|
+
access_token = None
|
|
138
|
+
deadline = time.time() + 10
|
|
139
|
+
|
|
140
|
+
while time.time() < deadline:
|
|
141
|
+
access_token = page.evaluate("() => sessionStorage.getItem('access_token')")
|
|
142
|
+
if access_token:
|
|
143
|
+
break
|
|
144
|
+
page.wait_for_timeout(500)
|
|
145
|
+
|
|
146
|
+
if not access_token:
|
|
147
|
+
log.error("No access_token in sessionStorage. Final URL: %s", page.url)
|
|
148
|
+
browser.close()
|
|
149
|
+
raise RuntimeError(
|
|
150
|
+
"Browser login failed: no access token found in sessionStorage"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
log.info("Access token extracted from sessionStorage")
|
|
154
|
+
browser.close()
|
|
155
|
+
|
|
156
|
+
tokens = AuthTokens(
|
|
157
|
+
access_token=access_token,
|
|
158
|
+
refresh_token=None,
|
|
159
|
+
expires_at=time.time() + 86400,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
_save_tokens(tokens)
|
|
163
|
+
|
|
164
|
+
log.info(
|
|
165
|
+
"Authentication successful, token expires at %s", time.ctime(tokens.expires_at)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return tokens
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def refresh(refresh_token: str) -> AuthTokens:
|
|
172
|
+
"""Silently refresh the access token using a refresh token."""
|
|
173
|
+
|
|
174
|
+
log.info("Refreshing access token")
|
|
175
|
+
seq_config = get_sequence_config()
|
|
176
|
+
|
|
177
|
+
with Session(impersonate="chrome", timeout=30) as s:
|
|
178
|
+
resp = s.post(
|
|
179
|
+
f"{AUTH0_DOMAIN}/oauth/token",
|
|
180
|
+
json={
|
|
181
|
+
"grant_type": "refresh_token",
|
|
182
|
+
"client_id": seq_config.auth0_client_id,
|
|
183
|
+
"refresh_token": refresh_token,
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
body = resp.json()
|
|
188
|
+
|
|
189
|
+
if resp.status_code >= 400:
|
|
190
|
+
log.error(
|
|
191
|
+
"Token refresh failed (HTTP %d): %s",
|
|
192
|
+
resp.status_code,
|
|
193
|
+
body.get("error_description", body),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
raise RuntimeError(
|
|
197
|
+
f"Auth0 token refresh failed (HTTP {resp.status_code}): "
|
|
198
|
+
f"{body.get('error_description', body)}"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
tokens = AuthTokens(
|
|
202
|
+
access_token=body["access_token"],
|
|
203
|
+
refresh_token=body.get("refresh_token", refresh_token),
|
|
204
|
+
expires_at=time.time() + body.get("expires_in", 86400),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
_save_tokens(tokens)
|
|
208
|
+
|
|
209
|
+
log.info("Token refreshed, expires at %s", time.ctime(tokens.expires_at))
|
|
210
|
+
|
|
211
|
+
return tokens
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_access_token() -> str:
|
|
215
|
+
"""Get a valid access token, refreshing or re-authenticating as needed.
|
|
216
|
+
|
|
217
|
+
This is the main entry point for consumers. It:
|
|
218
|
+
1. Loads cached tokens from .tokens.json
|
|
219
|
+
2. Returns the access token if still valid
|
|
220
|
+
3. Refreshes via refresh_token if expired
|
|
221
|
+
4. Falls back to full re-authentication if no refresh token
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
tokens = _load_tokens()
|
|
225
|
+
|
|
226
|
+
if tokens is not None:
|
|
227
|
+
remaining = tokens.expires_at - time.time()
|
|
228
|
+
log.debug("Cached token found, expires in %.0fs", remaining)
|
|
229
|
+
|
|
230
|
+
# Still valid (with 60s buffer)
|
|
231
|
+
if remaining > 60:
|
|
232
|
+
log.info("Using cached token (expires in %.0fs)", remaining)
|
|
233
|
+
return tokens.access_token
|
|
234
|
+
|
|
235
|
+
# Expired but we have a refresh token
|
|
236
|
+
if tokens.refresh_token:
|
|
237
|
+
try:
|
|
238
|
+
tokens = refresh(tokens.refresh_token)
|
|
239
|
+
return tokens.access_token
|
|
240
|
+
except RuntimeError:
|
|
241
|
+
log.warning("Refresh failed, falling back to full authentication")
|
|
242
|
+
|
|
243
|
+
# No tokens or refresh failed — full authentication
|
|
244
|
+
tokens = authenticate()
|
|
245
|
+
|
|
246
|
+
return tokens.access_token
|