Skip to content

Commit d8392ea

Browse files
authored
Merge pull request #21 from Deigue/update-python
Update Python version to 3.10+
2 parents 0b07ef0 + eb39053 commit d8392ea

4 files changed

Lines changed: 68 additions & 58 deletions

File tree

.gitignore

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,5 @@ cython_debug/
165165
# VS Code
166166
.vscode/
167167

168-
# uv local dev dependencies
169-
uv.lock
170-
pyproject.toml
168+
# Dev dependencies
169+
ruff.toml

setup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
author_email = 'guillaume@pommepause.com',
1111
url = 'https://github.com/gboudreau/ws-api-python',
1212
keywords = ['wealthsimple'],
13+
python_requires='>=3.10',
1314
install_requires = [
1415
'requests',
1516
],
@@ -19,5 +20,6 @@
1920
'Operating System :: OS Independent',
2021
'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
2122
'Programming Language :: Python :: 3',
23+
"Programming Language :: Python :: 3.13"
2224
],
2325
)

ws_api/session.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
from abc import ABC
21
import json
2+
from abc import ABC
33

44

55
class OAuthSession(ABC):
@@ -13,7 +13,7 @@ class OAuthSession(ABC):
1313
"""
1414

1515
def __init__(self):
16-
self.client_id = None
16+
self.client_id: str | None = None
1717
self.access_token = None
1818
self.refresh_token = None
1919

@@ -31,8 +31,8 @@ class WSAPISession(OAuthSession):
3131

3232
def __init__(self):
3333
super().__init__()
34-
self.session_id = None
35-
self.wssdi = None
34+
self.session_id: str | None = None
35+
self.wssdi: str | None = None
3636
self.token_info = None
3737

3838
def to_json(self):

ws_api/wealthsimple_api.py

Lines changed: 60 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import re
22
import uuid
3+
from collections.abc import Callable
34
from datetime import datetime, timedelta
45
from inspect import signature
5-
from typing import Any, Callable, Optional, Union
6+
from typing import Any
67

78
import requests
89

@@ -37,24 +38,24 @@ class WealthsimpleAPIBase:
3738
'FetchBrokerageMonthlyStatementTransactions': "query FetchBrokerageMonthlyStatementTransactions($period: String!, $accountId: String!) {\n brokerageMonthlyStatements(period: $period, accountId: $accountId) {\n id\n statementType\n createdAt\n data {\n ... on BrokerageMonthlyStatementObject {\n ...BrokerageMonthlyStatementObject\n __typename\n }\n __typename\n }\n __typename\n }\n}\n\nfragment BrokerageMonthlyStatementObject on BrokerageMonthlyStatementObject {\n custodianAccountId\n activitiesPerCurrency {\n currency\n currentTransactions {\n ...BrokerageMonthlyStatementTransactions\n __typename\n }\n __typename\n }\n currentTransactions {\n ...BrokerageMonthlyStatementTransactions\n __typename\n }\n isMultiCurrency\n __typename\n}\n\nfragment BrokerageMonthlyStatementTransactions on BrokerageMonthlyStatementTransactions {\n balance\n cashMovement\n unit\n description\n transactionDate\n transactionType\n __typename\n}",
3839
}
3940

40-
def __init__(self, sess: Optional[WSAPISession] = None):
41+
def __init__(self, sess: WSAPISession | None = None):
4142
self.security_market_data_cache_getter = None
4243
self.security_market_data_cache_setter = None
4344
self.session = WSAPISession()
4445
self.start_session(sess)
4546

46-
user_agent = None
47+
user_agent: str | None = None
4748

4849
@staticmethod
49-
def set_user_agent(user_agent: str):
50+
def set_user_agent(user_agent: str) -> None:
5051
WealthsimpleAPI.user_agent = user_agent
5152

5253
@staticmethod
5354
def uuidv4() -> str:
5455
return str(uuid.uuid4())
5556

5657
def send_http_request(
57-
self, url: str, method: str = 'POST', data: Optional[dict] = None, headers: Optional[dict] = None, return_headers: bool = False
58+
self, url: str, method: str = 'POST', data: dict | None = None, headers: dict | None = None, return_headers: bool = False
5859
) -> Any:
5960
headers = headers or {}
6061
if method == 'POST':
@@ -77,20 +78,20 @@ def send_http_request(
7778

7879
if return_headers:
7980
# Combine headers and body as a single string
80-
headers = '\r\n'.join(f"{k}: {v}" for k, v in response.headers.items())
81-
return f"{headers}\r\n\r\n{response.text}"
81+
response_headers = '\r\n'.join(f"{k}: {v}" for k, v in response.headers.items())
82+
return f"{response_headers}\r\n\r\n{response.text}"
8283

8384
return response.json()
8485
except requests.exceptions.RequestException as e:
8586
raise CurlException(f"HTTP request failed: {e}")
8687

87-
def send_get(self, url: str, headers: Optional[dict] = None, return_headers: bool = False) -> Any:
88+
def send_get(self, url: str, headers: dict | None = None, return_headers: bool = False) -> Any:
8889
return self.send_http_request(url, 'GET', headers=headers, return_headers=return_headers)
8990

90-
def send_post(self, url: str, data: dict, headers: Optional[dict] = None, return_headers: bool = False) -> Any:
91+
def send_post(self, url: str, data: dict, headers: dict | None = None, return_headers: bool = False) -> Any:
9192
return self.send_http_request(url, 'POST', data=data, headers=headers, return_headers=return_headers)
9293

93-
def start_session(self, sess: WSAPISession = None):
94+
def start_session(self, sess: WSAPISession | None = None):
9495
if sess:
9596
self.session.access_token = sess.access_token
9697
self.session.wssdi = sess.wssdi
@@ -138,16 +139,25 @@ def start_session(self, sess: WSAPISession = None):
138139
if not self.session.session_id:
139140
self.session.session_id = str(uuid.uuid4())
140141

141-
def check_oauth_token(self, persist_session_fct: Optional[Callable] = None, username = None):
142+
def search_security(self, query):
143+
# Fetch security search results using GraphQL query
144+
return self.do_graphql_query(
145+
'FetchSecuritySearchResult',
146+
{'query': query},
147+
'securitySearch.results',
148+
'array',
149+
)
150+
151+
def check_oauth_token(self, persist_session_fct: Callable | None = None, username = None):
142152
if self.session.access_token:
143153
try:
144-
# noinspection PyUnresolvedReferences
145154
self.search_security('XEQT')
146-
return
147155
except WSApiException as e:
148-
if e.response['message'] != 'Not Authorized.':
149-
raise e
156+
if e.response is None or e.response.get('message') != 'Not Authorized.':
157+
raise
150158
# Access token expired; try to refresh it below
159+
else:
160+
return
151161

152162
if self.session.refresh_token:
153163
data = {
@@ -161,7 +171,7 @@ def check_oauth_token(self, persist_session_fct: Optional[Callable] = None, user
161171
}
162172
response = self.send_post(f"{self.OAUTH_BASE_URL}/token", data, headers)
163173
if 'access_token' not in response or 'refresh_token' not in response:
164-
raise ManualLoginRequired(f"OAuth token invalid and cannot be refreshed: {response['error'] if 'error' in response else 'Invalid response from API'}")
174+
raise ManualLoginRequired(f"OAuth token invalid and cannot be refreshed: {response.get('error', 'Invalid response from API')}")
165175
self.session.access_token = response['access_token']
166176
self.session.refresh_token = response['refresh_token']
167177
if persist_session_fct:
@@ -179,8 +189,8 @@ def check_oauth_token(self, persist_session_fct: Optional[Callable] = None, user
179189
def login_internal(self,
180190
username: str,
181191
password: str,
182-
otp_answer: Optional[str] = None,
183-
persist_session_fct: Optional[Callable] = None,
192+
otp_answer: str | None = None,
193+
persist_session_fct: Callable | None = None,
184194
scope: str = SCOPE_READ_ONLY
185195
) -> WSAPISession:
186196
data = {
@@ -228,7 +238,7 @@ def login_internal(self,
228238
return self.session
229239

230240
def do_graphql_query(self, query_name: str, variables: dict, data_response_path: str, expect_type: str,
231-
filter_fn: Optional[Callable[[Any], bool]] = None, *, load_all_pages: bool = False):
241+
filter_fn: Callable[[Any], bool] | None = None, *, load_all_pages: bool = False):
232242
query = {
233243
'operationName': query_name,
234244
'query': self.GRAPHQL_QUERIES[query_name],
@@ -305,8 +315,8 @@ def get_token_info(self):
305315
def login(
306316
username: str,
307317
password: str,
308-
otp_answer: Optional[str] = None,
309-
persist_session_fct: Optional[Callable] = None,
318+
otp_answer: str | None = None,
319+
persist_session_fct: Callable | None = None,
310320
scope: str = SCOPE_READ_ONLY
311321
) -> WSAPISession:
312322
"""Login to Wealthsimple API and return a session object.
@@ -329,13 +339,13 @@ def login(
329339
return ws.login_internal(username, password, otp_answer, persist_session_fct, scope)
330340

331341
@staticmethod
332-
def from_token(sess: WSAPISession, persist_session_fct: callable = None, username: Optional[str] = None):
342+
def from_token(sess: WSAPISession, persist_session_fct: Callable | None = None, username: str | None = None):
333343
ws = WealthsimpleAPI(sess)
334344
ws.check_oauth_token(persist_session_fct, username)
335345
return ws
336346

337347
class WealthsimpleAPI(WealthsimpleAPIBase):
338-
def __init__(self, sess: WSAPISession = None):
348+
def __init__(self, sess: WSAPISession | None = None) -> None:
339349
super().__init__(sess)
340350
self.account_cache = {}
341351

@@ -417,7 +427,7 @@ def get_account_balances(self, account_id):
417427
for account in accounts[0]['custodianAccounts']:
418428
for balance in account['financials']['balance']:
419429
security = balance['securityId']
420-
if security != 'sec-c-cad' and security != 'sec-c-usd':
430+
if security not in {'sec-c-cad', 'sec-c-usd'}:
421431
security = self.security_id_to_symbol(security)
422432
balances[security] = balance['quantity']
423433

@@ -457,12 +467,12 @@ def get_identity_historical_financials(self, account_ids = None, currency: str =
457467

458468
def get_activities(
459469
self,
460-
account_id: Union[str, list[str]],
470+
account_id: str | list[str],
461471
how_many: int = 50,
462472
order_by: str = 'OCCURRED_AT_DESC',
463473
ignore_rejected: bool = True,
464-
start_date: Optional[datetime] = None,
465-
end_date: Optional[datetime] = None,
474+
start_date: datetime | None = None,
475+
end_date: datetime | None = None,
466476
load_all: bool = False
467477
) -> list[Any]:
468478
"""Retrieve activities for a specific account or list of accounts.
@@ -587,24 +597,30 @@ def _activity_add_description(self, act):
587597
type_ = act['type'].lower().capitalize()
588598
direction = 'from' if act['type'] == 'DEPOSIT' else 'to'
589599
prop = 'source' if act['type'] == 'DEPOSIT' else 'destination'
590-
bank_account = details[prop]['bankAccount']
591-
nickname = bank_account.get('nickname')
600+
if isinstance(details, dict):
601+
bank_account_info = details.get(prop, {})
602+
if isinstance(bank_account_info, dict):
603+
bank_account = bank_account_info.get('bankAccount', {})
604+
nickname = bank_account.get('nickname')
605+
account_number = bank_account.get('accountNumber')
592606
if not nickname:
593-
nickname = bank_account['accountName']
594-
act['description'] = (
595-
f"{type_}: EFT {direction} {nickname} {bank_account['accountNumber']}"
596-
)
607+
nickname = bank_account.get('accountName')
608+
act['description'] = f"{type_}: EFT {direction} {nickname} {account_number}"
597609

598610
elif act['type'] == 'REFUND' and act['subType'] == 'TRANSFER_FEE_REFUND':
599611
act['description'] = "Reimbursement: account transfer fee"
600612

601613
elif act['type'] == 'INSTITUTIONAL_TRANSFER_INTENT' and act['subType'] == 'TRANSFER_IN':
602614
details = self.get_transfer_details(act['externalCanonicalId'])
603-
verb = details['transferType'].replace('_', '-').capitalize()
615+
if isinstance(details, dict):
616+
verb = details['transferType'].replace('_', '-').capitalize()
617+
client_account_type = details['clientAccountType'].upper()
618+
institution_name = details['institutionName']
619+
redacted_account_number = details['redactedInstitutionAccountNumber']
604620
act['description'] = (
605-
f"Institutional transfer: {verb} {details['clientAccountType'].upper()} "
606-
f"account transfer from {details['institutionName']} "
607-
f"****{details['redactedInstitutionAccountNumber']}"
621+
f"Institutional transfer: {verb} {client_account_type} "
622+
f"account transfer from {institution_name} "
623+
f"****{redacted_account_number}"
608624
)
609625
elif act['type'] == 'INSTITUTIONAL_TRANSFER_INTENT' and act['subType'] == 'TRANSFER_OUT':
610626
act['description'] = (
@@ -646,8 +662,8 @@ def _activity_add_description(self, act):
646662

647663
elif act['type'] == 'P2P_PAYMENT' and act['subType'] in ('SEND', 'SEND_RECEIVED'):
648664
direction = 'sent to' if act['subType'] == 'SEND' else 'received from'
649-
p2pHandle = act['p2pHandle']
650-
act['description'] = f"Cash {direction} {p2pHandle}"
665+
p2p_handle = act['p2pHandle']
666+
act['description'] = f"Cash {direction} {p2p_handle}"
651667

652668
elif act['type'] == 'PROMOTION' and act['subType'] == 'INCENTIVE_BONUS':
653669
type_ = act['type'].capitalize()
@@ -685,7 +701,7 @@ def security_id_to_symbol(self, security_id: str) -> str:
685701
security_symbol = f"[{security_id}]"
686702
if self.security_market_data_cache_getter:
687703
market_data = self.get_security_market_data(security_id)
688-
if market_data and 'stock' in market_data and market_data['stock']:
704+
if isinstance(market_data, dict) and market_data['stock']:
689705
stock = market_data['stock']
690706
security_symbol = f"{stock['primaryExchange']}:{stock['symbol']}"
691707
return security_symbol
@@ -706,7 +722,7 @@ def get_transfer_details(self, transfer_id):
706722
'object',
707723
)
708724

709-
def set_security_market_data_cache(self, security_market_data_cache_getter: callable, security_market_data_cache_setter: callable):
725+
def set_security_market_data_cache(self, security_market_data_cache_getter: Callable, security_market_data_cache_setter: Callable) -> None:
710726
self.security_market_data_cache_getter = security_market_data_cache_getter
711727
self.security_market_data_cache_setter = security_market_data_cache_setter
712728

@@ -731,14 +747,7 @@ def get_security_market_data(self, security_id: str, use_cache: bool = True):
731747

732748
return value
733749

734-
def search_security(self, query):
735-
# Fetch security search results using GraphQL query
736-
return self.do_graphql_query(
737-
'FetchSecuritySearchResult',
738-
{'query': query},
739-
'securitySearch.results',
740-
'array',
741-
)
750+
742751

743752
def get_security_historical_quotes(self, security_id, time_range='1m'):
744753
# Fetch historical quotes for a security using GraphQL query
@@ -765,7 +774,7 @@ def get_corporate_action_child_activities(self, activity_canonical_id):
765774

766775
def get_statement_transactions(self, account_id: str, period: str) -> list[Any]:
767776
"""Retrieve transactions from account monthly statement.
768-
777+
769778
Args:
770779
account_id (str): The account ID to retrieve transactions for.
771780
period (str): The statement start date in 'YYYY-MM-DD' format.
@@ -795,5 +804,5 @@ def get_statement_transactions(self, account_id: str, period: str) -> list[Any]:
795804

796805
if not isinstance(transactions, list):
797806
raise WSApiException(f"Unexpected response format: {self.get_statement_transactions.__name__}", transactions)
798-
807+
799808
return transactions

0 commit comments

Comments
 (0)