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.
@@ -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