Skip to content

Commit 5feb4b1

Browse files
committed
Add TRANSFER_OUT and cleanup API usage exceptions
1 parent 9de7320 commit 5feb4b1

2 files changed

Lines changed: 90 additions & 17 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,9 @@ cython_debug/
162162
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
163163
#.idea/
164164

165+
# VS Code
166+
.vscode/
167+
168+
# uv local dev dependencies
169+
uv.lock
170+
pyproject.toml

ws_api/wealthsimple_api.py

Lines changed: 84 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1+
import re
2+
import uuid
13
from datetime import datetime, timedelta
4+
from inspect import signature
5+
from typing import Any, Callable, Optional
26

3-
import re
47
import requests
5-
import uuid
6-
from typing import Optional, Callable, Any
78

8-
from ws_api.exceptions import CurlException, LoginFailedException, ManualLoginRequired, OTPRequiredException, UnexpectedException, WSApiException
9+
from ws_api.exceptions import (
10+
CurlException,
11+
LoginFailedException,
12+
ManualLoginRequired,
13+
OTPRequiredException,
14+
UnexpectedException,
15+
WSApiException,
16+
)
917
from ws_api.session import WSAPISession
10-
from inspect import signature
1118

1219

1320
class WealthsimpleAPIBase:
@@ -130,7 +137,7 @@ def start_session(self, sess: WSAPISession = None):
130137
if not self.session.session_id:
131138
self.session.session_id = str(uuid.uuid4())
132139

133-
def check_oauth_token(self, persist_session_fct: Optional[Callable[[WSAPISession, Optional[str]], None]] = None, username = None):
140+
def check_oauth_token(self, persist_session_fct: Optional[Callable] = None, username = None):
134141
if self.session.access_token:
135142
try:
136143
# noinspection PyUnresolvedReferences
@@ -152,7 +159,7 @@ def check_oauth_token(self, persist_session_fct: Optional[Callable[[WSAPISession
152159
'x-ws-profile': 'invest'
153160
}
154161
response = self.send_post(f"{self.OAUTH_BASE_URL}/token", data, headers)
155-
if not 'access_token' in response or not 'refresh_token' in response:
162+
if 'access_token' not in response or 'refresh_token' not in response:
156163
raise ManualLoginRequired(f"OAuth token invalid and cannot be refreshed: {response['error'] if 'error' in response else 'Invalid response from API'}")
157164
self.session.access_token = response['access_token']
158165
self.session.refresh_token = response['refresh_token']
@@ -168,8 +175,13 @@ def check_oauth_token(self, persist_session_fct: Optional[Callable[[WSAPISession
168175
SCOPE_READ_ONLY = 'invest.read trade.read tax.read'
169176
SCOPE_READ_WRITE = 'invest.read trade.read tax.read invest.write trade.write tax.write'
170177

171-
def login_internal(self, username: str, password: str, otp_answer: str = None,
172-
persist_session_fct: callable = None, scope: str = SCOPE_READ_ONLY) -> WSAPISession:
178+
def login_internal(self,
179+
username: str,
180+
password: str,
181+
otp_answer: str | None = None,
182+
persist_session_fct: Optional[Callable] = None,
183+
scope: str = SCOPE_READ_ONLY
184+
) -> WSAPISession:
173185
data = {
174186
'grant_type': 'password',
175187
'username': username,
@@ -215,7 +227,7 @@ def login_internal(self, username: str, password: str, otp_answer: str = None,
215227
return self.session
216228

217229
def do_graphql_query(self, query_name: str, variables: dict, data_response_path: str, expect_type: str,
218-
filter_fn: callable = None, *, load_all_pages: bool = False):
230+
filter_fn: Optional[Callable[[Any], bool]] = None, *, load_all_pages: bool = False):
219231
query = {
220232
'operationName': query_name,
221233
'query': self.GRAPHQL_QUERIES[query_name],
@@ -289,7 +301,29 @@ def get_token_info(self):
289301
return self.session.token_info
290302

291303
@staticmethod
292-
def login(username: str, password: str, otp_answer: str = None, persist_session_fct: callable = None, scope: str = SCOPE_READ_ONLY):
304+
def login(
305+
username: str,
306+
password: str,
307+
otp_answer: str | None = None,
308+
persist_session_fct: Optional[Callable] = None,
309+
scope: str = SCOPE_READ_ONLY
310+
) -> WSAPISession:
311+
"""Login to Wealthsimple API and return a session object.
312+
313+
Args:
314+
username (str): The username of the Wealthsimple account.
315+
password (str): The password of the Wealthsimple account.
316+
otp_answer (str, optional): The answer to the 2FA code. Defaults to None.
317+
persist_session_fct (callable, optional): A function to call to persist the session. Defaults to None.
318+
scope (str, optional): The OAuth scope for the session. Defaults to SCOPE_READ_ONLY.
319+
320+
Returns:
321+
WSAPISession: The session object.
322+
323+
Raises:
324+
LoginFailedException: If the login fails.
325+
OTPRequiredException: If 2FA code is required.
326+
"""
293327
ws = WealthsimpleAPI()
294328
return ws.login_internal(username, password, otp_answer, persist_session_fct, scope)
295329

@@ -420,7 +454,35 @@ def get_identity_historical_financials(self, account_ids = None, currency: str =
420454
'array',
421455
)
422456

423-
def get_activities(self, account_id, how_many=50, order_by='OCCURRED_AT_DESC', ignore_rejected=True, start_date = None, end_date = None, load_all = False):
457+
def get_activities(
458+
self,
459+
account_id: str | list[str],
460+
how_many: int = 50,
461+
order_by: str = 'OCCURRED_AT_DESC',
462+
ignore_rejected: bool = True,
463+
start_date: datetime | None = None,
464+
end_date: datetime | None = None,
465+
load_all: bool = False
466+
) -> list[Any]:
467+
"""Retrieve activities for a specific account or list of accounts.
468+
469+
Args:
470+
account_id (str | list[str]): The account ID or list of account IDs to retrieve activities for.
471+
how_many (int): The maximum number of activities to retrieve.
472+
order_by (str): The order in which to sort the activities.
473+
ignore_rejected (bool): Whether to ignore rejected or cancelled activities.
474+
start_date (datetime | None): The start date for filtering activities.
475+
end_date (datetime | None): The end date for filtering activities.
476+
load_all (bool): Whether to load all pages of activities.
477+
478+
Returns:
479+
list[Any]: A list of activity objects.
480+
481+
Raises:
482+
WSApiException: If the response format is unexpected.
483+
"""
484+
if isinstance(account_id, str):
485+
account_id = [account_id]
424486
# Calculate the end date for the condition
425487
end_date = (end_date if end_date else datetime.now() + timedelta(hours=23, minutes=59, seconds=59, milliseconds=999))
426488

@@ -438,15 +500,17 @@ def filter_fn(activity):
438500
'condition': {
439501
'startDate': start_date.strftime('%Y-%m-%dT%H:%M:%S.%fZ') if start_date else None,
440502
'endDate': end_date.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
441-
'accountIds': [account_id],
503+
'accountIds': account_id,
442504
},
443505
},
444506
'activityFeedItems.edges',
445507
'array',
446-
filter_fn = filter_fn,
447-
load_all_pages = load_all,
508+
filter_fn=filter_fn,
509+
load_all_pages=load_all,
448510
)
449511

512+
if not isinstance(activities, list):
513+
raise WSApiException(f"Unexpected response format: {self.get_activities.__name__}", activities)
450514
for act in activities:
451515
self._activity_add_description(act)
452516

@@ -528,7 +592,10 @@ def _activity_add_description(self, act):
528592
f"account transfer from {details['institutionName']} "
529593
f"****{details['redactedInstitutionAccountNumber']}"
530594
)
531-
595+
elif act['type'] == 'INSTITUTIONAL_TRANSFER_INTENT' and act['subType'] == 'TRANSFER_OUT':
596+
act['description'] = (
597+
f"Institutional transfer: transfer to {act['institutionName']} "
598+
)
532599
elif act['type'] == 'INTEREST':
533600
if act['subType'] == 'FPL_INTEREST':
534601
act['description'] = "Stock Lending Earnings"
@@ -666,7 +733,7 @@ def get_security_historical_quotes(self, security_id, time_range='1m'):
666733
'security.historicalQuotes',
667734
'array',
668735
)
669-
736+
670737
def get_corporate_action_child_activities(self, activity_canonical_id):
671738
# Fetch details about a corporate action (eg. a split) using GraphQL query
672739
return self.do_graphql_query(

0 commit comments

Comments
 (0)