22from pathlib import Path
33
44import pyotp
5+ import logging
56import requests
67
78from firstrade import urls
1213 LoginResponseError ,
1314)
1415
16+ logger = logging .getLogger (__name__ )
1517
1618class 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 ]:
0 commit comments