Skip to content

Commit 91f53bc

Browse files
authored
Merge pull request #76 from Amodio/add-logging
Add HTTP logging with the debug attribute passed to FTSession.
2 parents c77a558 + 4936b35 commit 91f53bc

3 files changed

Lines changed: 78 additions & 25 deletions

File tree

firstrade/account.py

Lines changed: 70 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from pathlib import Path
33

44
import pyotp
5+
import logging
56
import requests
67

78
from firstrade import urls
@@ -12,6 +13,7 @@
1213
LoginResponseError,
1314
)
1415

16+
logger = logging.getLogger(__name__)
1517

1618
class FTSession:
1719
"""Class creating a session for Firstrade.
@@ -22,18 +24,19 @@ class FTSession:
2224
Attributes:
2325
username (str): Firstrade login username.
2426
password (str): Firstrade login password.
25-
pin (str): Firstrade login pin.
27+
pin (str, optional): Firstrade login pin.
2628
email (str, optional): Firstrade MFA email.
2729
phone (str, optional): Firstrade MFA phone number.
2830
mfa_secret (str, optional): Secret key for generating MFA codes.
2931
profile_path (str, optional): The path where the user wants to save the cookie pkl file.
32+
debug (bool, optional): Log HTTP requests/responses if true. DO NOT POST YOUR LOGS ONLINE.
3033
t_token (str, optional): Token used for MFA.
3134
otp_options (dict, optional): Options for OTP (One-Time Password) if MFA is enabled.
3235
login_json (dict, optional): JSON response from the login request.
3336
session (requests.Session): The requests session object used for making HTTP requests.
3437
3538
Methods:
36-
__init__(username, password, pin=None, email=None, phone=None, mfa_secret=None, profile_path=None):
39+
__init__(username, password, pin=None, email=None, phone=None, mfa_secret=None, profile_path=None, debug=False):
3740
Initializes a new instance of the FTSession class.
3841
login():
3942
Validates and logs into the Firstrade platform.
@@ -49,6 +52,8 @@ class FTSession:
4952
Masks the email for use in the API.
5053
_handle_mfa():
5154
Handles multi-factor authentication.
55+
_request(method, url, **kwargs):
56+
HTTP requests wrapper to the API.
5257
5358
"""
5459

@@ -61,16 +66,19 @@ def __init__(
6166
phone: str = "",
6267
mfa_secret: str = "",
6368
profile_path: str | None = None,
69+
debug: bool = False
6470
) -> None:
6571
"""Initialize a new instance of the FTSession class.
6672
6773
Args:
6874
username (str): Firstrade login username.
6975
password (str): Firstrade login password.
70-
pin (str): Firstrade login pin.
76+
pin (str, optional): Firstrade login pin.
7177
email (str, optional): Firstrade MFA email.
7278
phone (str, optional): Firstrade MFA phone number.
79+
mfa_secret (str, optional): Firstrade MFA secret key to generate TOTP.
7380
profile_path (str, optional): The path where the user wants to save the cookie pkl file.
81+
debug (bool, optional): Log HTTP requests/responses if true. DO NOT POST YOUR LOGS ONLINE.
7482
7583
"""
7684
self.username: str = username
@@ -80,6 +88,15 @@ def __init__(
8088
self.phone: str = phone
8189
self.mfa_secret: str = mfa_secret
8290
self.profile_path: str | None = profile_path
91+
self.debug: bool = debug
92+
if self.debug:
93+
logging.basicConfig(level=logging.DEBUG)
94+
# Enable HTTP connection debug output
95+
import http.client as http_client
96+
http_client.HTTPConnection.debuglevel = 1
97+
# requests logging too
98+
logging.getLogger("requests.packages.urllib3").setLevel(logging.DEBUG)
99+
logging.getLogger("requests.packages.urllib3").propagate = True
83100
self.t_token: str | None = None
84101
self.otp_options: list[dict[str, str]] | None = None
85102
self.login_json: dict[str, str] = {}
@@ -100,15 +117,16 @@ def login(self) -> bool:
100117
ftat: str = self._load_cookies()
101118
if not ftat:
102119
self.session.headers["ftat"] = ftat
103-
response: requests.Response = self.session.get(url="https://api3x.firstrade.com/", timeout=10)
120+
response: requests.Response = self._request("get", url="https://api3x.firstrade.com/", timeout=10)
104121
self.session.headers["access-token"] = urls.access_token()
105122

106123
data: dict[str, str] = {
107124
"username": r"" + self.username,
108125
"password": r"" + self.password,
109126
}
110127

111-
response: requests.Response = self.session.post(
128+
response: requests.Response = self._request(
129+
"post",
112130
url=urls.login(),
113131
data=data,
114132
)
@@ -145,7 +163,7 @@ def login_two(self, code: str) -> None:
145163
"remember_for": "30",
146164
"t_token": self.t_token,
147165
}
148-
response: requests.Response = self.session.post(urls.verify_pin(), data=data)
166+
response: requests.Response = self._request("post", urls.verify_pin(), data=data)
149167
self.login_json: dict[str, str] = response.json()
150168
if not self.login_json["error"]:
151169
raise LoginResponseError(self.login_json["error"])
@@ -242,7 +260,7 @@ def _handle_pin_mfa(self, data: dict[str, str | None]) -> requests.Response:
242260
"remember_for": "30",
243261
"t_token": self.t_token,
244262
})
245-
return self.session.post(urls.verify_pin(), data=data)
263+
return self._request("post", urls.verify_pin(), data=data)
246264

247265
def _handle_otp_mfa(self, data: dict[str, str | None]) -> requests.Response:
248266
"""Handle email/phone OTP-based MFA."""
@@ -258,7 +276,7 @@ def _handle_otp_mfa(self, data: dict[str, str | None]) -> requests.Response:
258276
})
259277
break
260278

261-
return self.session.post(urls.request_code(), data=data)
279+
return self._request("post", urls.request_code(), data=data)
262280

263281
def _handle_secret_mfa(self, data: dict[str, str | None]) -> requests.Response:
264282
"""Handle MFA secret-based authentication."""
@@ -268,7 +286,42 @@ def _handle_secret_mfa(self, data: dict[str, str | None]) -> requests.Response:
268286
"remember_for": "30",
269287
"t_token": self.t_token,
270288
})
271-
return self.session.post(urls.verify_pin(), data=data)
289+
return self._request("post", urls.verify_pin(), data=data)
290+
291+
def _request(self, method, url, **kwargs):
292+
"""Send HTTP request and log the full response content if debug=True."""
293+
resp = self.session.request(method, url, **kwargs)
294+
295+
if self.debug:
296+
# Suppress urllib3 / http.client debug so we only see this log
297+
logging.getLogger("urllib3").setLevel(logging.WARNING)
298+
299+
# Basic request info
300+
logger.debug(f">>> {method.upper()} {url}")
301+
logger.debug(f"<<< Status: {resp.status_code}")
302+
logger.debug(f"<<< Headers: {resp.headers}")
303+
304+
# Log raw bytes length
305+
try:
306+
logger.debug(f"<<< Raw bytes length: {len(resp.content)}")
307+
except Exception as e:
308+
logger.debug(f"<<< Could not read raw bytes: {e}")
309+
310+
# Log pretty JSON (if any)
311+
try:
312+
import json as pyjson
313+
# This automatically uses requests decompression if gzip is set
314+
json_body = resp.json()
315+
pretty = pyjson.dumps(json_body, indent=2)
316+
logger.debug(f"<<< JSON body:\n{pretty}")
317+
except Exception as e:
318+
# If JSON decoding fails, fallback to raw text
319+
try:
320+
logger.debug(f"<<< Body (text):\n{resp.text}")
321+
except Exception as e2:
322+
logger.debug(f"<<< Could not read body text: {e2}")
323+
324+
return resp
272325

273326
def __getattr__(self, name: str) -> object:
274327
"""Forward unknown attribute access to session object.
@@ -297,9 +350,9 @@ def __init__(self, session: requests.Session) -> None:
297350
self.all_accounts: list[dict[str, object]] = []
298351
self.account_numbers: list[str] = []
299352
self.account_balances: dict[str, object] = {}
300-
response: requests.Response = self.session.get(url=urls.user_info())
353+
response: requests.Response = self.session._request("get", url=urls.user_info())
301354
self.user_info: dict[str, object] = response.json()
302-
response: requests.Response = self.session.get(urls.account_list())
355+
response: requests.Response = self.session._request("get", urls.account_list())
303356
if response.status_code != 200 or response.json()["error"] != "":
304357
raise AccountResponseError(response.json()["error"])
305358
self.all_accounts = response.json()
@@ -317,7 +370,7 @@ def get_account_balances(self, account: str) -> dict[str, object]:
317370
dict: Dict of the response from the API.
318371
319372
"""
320-
response: requests.Response = self.session.get(urls.account_balances(account))
373+
response: requests.Response = self.session._request("get", urls.account_balances(account))
321374
return response.json()
322375

323376
def get_positions(self, account: str) -> dict[str, object]:
@@ -330,7 +383,7 @@ def get_positions(self, account: str) -> dict[str, object]:
330383
dict: Dict of the response from the API.
331384
332385
"""
333-
response = self.session.get(urls.account_positions(account))
386+
response = self.session._request("get", urls.account_positions(account))
334387
return response.json()
335388

336389
def get_account_history(
@@ -357,8 +410,8 @@ def get_account_history(
357410
"""
358411
if date_range == "cust" and custom_range is None:
359412
raise ValueError("Custom range required.")
360-
response: requests.Response = self.session.get(
361-
urls.account_history(account, date_range, custom_range),
413+
response: requests.Response = self.session._request(
414+
"get", urls.account_history(account, date_range, custom_range),
362415
)
363416
return response.json()
364417

@@ -372,7 +425,7 @@ def get_orders(self, account: str) -> list[dict[str, object]]:
372425
list: A list of dictionaries, each containing details about an order.
373426
374427
"""
375-
response = self.session.get(url=urls.order_list(account))
428+
response = self.session._request("get", url=urls.order_list(account))
376429
return response.json()
377430

378431
def cancel_order(self, order_id: str) -> dict[str, object]:
@@ -389,7 +442,7 @@ def cancel_order(self, order_id: str) -> dict[str, object]:
389442
"order_id": order_id,
390443
}
391444

392-
response = self.session.post(url=urls.cancel_order(), data=data)
445+
response = self.session._request("post", url=urls.cancel_order(), data=data)
393446
return response.json()
394447

395448
def get_balance_overview(self, account: str, keywords: list[str] | None = None) -> dict[str, object]:

firstrade/order.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,15 +166,15 @@ def place_order(
166166
data["limit_price"] = price
167167
if price_type in {PriceType.STOP, PriceType.STOP_LIMIT}:
168168
data["stop_price"] = stop_price
169-
response: requests.Response = self.ft_session.post(url=urls.order(), data=data)
169+
response: requests.Response = self.ft_session._request("post", url=urls.order(), data=data)
170170
if response.status_code != 200 or response.json()["error"] != "":
171171
return response.json()
172172
preview_data = response.json()
173173
if dry_run:
174174
return preview_data
175175
data["preview"] = "false"
176176
data["stage"] = "P"
177-
response = self.ft_session.post(url=urls.order(), data=data)
177+
response = self.ft_session._request("post", url=urls.order(), data=data)
178178
return response.json()
179179

180180
def place_option_order(
@@ -232,11 +232,11 @@ def place_option_order(
232232
if price_type in {PriceType.STOP, PriceType.STOP_LIMIT}:
233233
data["stop_price"] = stop_price
234234

235-
response = self.ft_session.post(url=urls.option_order(), data=data)
235+
response = self.ft_session._request("post", url=urls.option_order(), data=data)
236236
if response.status_code != 200 or response.json()["error"] != "":
237237
return response.json()
238238
if dry_run:
239239
return response.json()
240240
data["preview"] = "false"
241-
response = self.ft_session.post(url=urls.option_order(), data=data)
241+
response = self.ft_session._request("post", url=urls.option_order(), data=data)
242242
return response.json()

firstrade/symbols.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def __init__(self, ft_session: FTSession, account: str, symbol: str):
5656
5757
"""
5858
self.ft_session: FTSession = ft_session
59-
response = self.ft_session.get(url=urls.quote(account, symbol))
59+
response = self.ft_session._request("get", url=urls.quote(account, symbol))
6060
if response.status_code != 200:
6161
raise QuoteRequestError(response.status_code)
6262
if response.json().get("error", ""):
@@ -125,7 +125,7 @@ def get_option_dates(self, symbol: str):
125125
dict: A dict of expiration dates and other information for options on the given symbol.
126126
127127
"""
128-
response = self.ft_session.get(url=urls.option_dates(symbol))
128+
response = self.ft_session._request("get", url=urls.option_dates(symbol))
129129
return response.json()
130130

131131
def get_option_quote(self, symbol: str, date: str) -> dict[Any, Any]:
@@ -138,7 +138,7 @@ def get_option_quote(self, symbol: str, date: str) -> dict[Any, Any]:
138138
dict: A dictionary containing the quote and other information for the given option symbol.
139139
140140
"""
141-
response = self.ft_session.get(url=urls.option_quotes(symbol, date))
141+
response = self.ft_session._request("get", url=urls.option_quotes(symbol, date))
142142
return response.json()
143143

144144
def get_greek_options(self, symbol: str, exp_date: str):
@@ -158,5 +158,5 @@ def get_greek_options(self, symbol: str, exp_date: str):
158158
"root_symbol": symbol,
159159
"exp_date": exp_date,
160160
}
161-
response = self.ft_session.post(url=urls.greek_options(), data=data)
161+
response = self.ft_session._request("post", url=urls.greek_options(), data=data)
162162
return response.json()

0 commit comments

Comments
 (0)