From 46aaaf97cff5329e1fdf615c5462f2f8b13eb6bc Mon Sep 17 00:00:00 2001 From: Alex Golec Date: Sun, 9 Mar 2025 10:07:40 -0400 Subject: [PATCH 1/2] easy_client drops back to manual login flow on detecting a notebook --- schwab/auth.py | 43 +++++++++++++++++-- setup.py | 1 - tests/auth_test.py | 105 ++++++++++++++++++++++++++++++++++++--------- 3 files changed, 124 insertions(+), 25 deletions(-) diff --git a/schwab/auth.py b/schwab/auth.py index fa32b46..ada46c2 100644 --- a/schwab/auth.py +++ b/schwab/auth.py @@ -1,5 +1,4 @@ from authlib.integrations.httpx_client import AsyncOAuth2Client, OAuth2Client -from prompt_toolkit import prompt import collections import contextlib @@ -338,7 +337,7 @@ def callback_server(): print() if interactive: - prompt('Press ENTER to open the browser. Note you can call ' + + input('Press ENTER to open the browser. Note you can call ' + 'this method with interactive=False to skip this input.') controller = webbrowser.get(requested_browser) @@ -485,7 +484,7 @@ def client_from_manual_flow(api_key, app_secret, callback_url, token_path, 'and update your callback URL to begin with \'https\' ' + 'to stop seeing this message.').format(callback_url)) - received_url = prompt('Redirect URL> ').strip() + received_url = input('Redirect URL> ').strip() token_write_func = ( __make_update_token_func(token_path) if token_write_func is None @@ -639,6 +638,31 @@ async def oauth_client_update_token(t, *args, **kwargs): # easy_client +# TODO: Figure out how to properly mock global objects in unittest. This hack +# ensures that the _get_ipython variable is defined so that we can patch is +# using module-level patching. This is safe in most contexts, but there are +# circumstances where it gets weird like starting an ipython notebook after +# schwab-py is loaded. +try: + _get_ipython = get_ipython +except NameError: + _get_ipython = None + + +def __running_in_notebook(): + # Google Colab + if os.getenv('COLAB_RELEASE_TAG'): + return True + + # ipython in notebook mode + if _get_ipython is not None: + shell = _get_ipython().__class__.__name__ + if shell == 'ZMQInteractiveShell': + return True + + return False + + def easy_client(api_key, app_secret, callback_url, token_path, asyncio=False, enforce_enums=True, max_token_age=60*60*24*6.5, callback_timeout=300.0, interactive=True, @@ -707,7 +731,18 @@ def easy_client(api_key, app_secret, callback_url, token_path, asyncio=False, logger.info('token too old, proactively creating a new one') c = None - if c is None: + # Return early on success + if c is not None: + return c + + # Detect whether we're running in a notebook + if __running_in_notebook(): + c = client_from_manual_flow(api_key, app_secret, callback_url, + token_path, enforce_enums=enforce_enums) + logger.info( + 'Returning client fetched using manual flow, writing' + + 'token to \'%s\'', token_path) + else: c = client_from_login_flow( api_key, app_secret, callback_url, token_path, asyncio=asyncio, enforce_enums=enforce_enums, callback_timeout=callback_timeout, diff --git a/setup.py b/setup.py index 6b6eff9..3afee31 100644 --- a/setup.py +++ b/setup.py @@ -35,7 +35,6 @@ 'flask', 'httpx', 'multiprocess', - 'prompt_toolkit', 'psutil', 'python-dateutil', 'urllib3', diff --git a/tests/auth_test.py b/tests/auth_test.py index 8f0a190..19ba3e7 100644 --- a/tests/auth_test.py +++ b/tests/auth_test.py @@ -37,7 +37,7 @@ def setUp(self): @patch('schwab.auth.OAuth2Client', new_callable=MockOAuthClient) @patch('schwab.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) @patch('schwab.auth.webbrowser.get', new_callable=MagicMock) - @patch('schwab.auth.prompt', MagicMock(return_value='')) + @patch('schwab.auth.input', MagicMock(return_value='')) @patch('time.time', MagicMock(return_value=MOCK_NOW)) def test_create_token_file( self, mock_webbrowser_get, async_session, sync_session, client): @@ -71,7 +71,7 @@ def test_create_token_file( @patch('schwab.auth.OAuth2Client', new_callable=MockOAuthClient) @patch('schwab.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) @patch('schwab.auth.webbrowser.get', new_callable=MagicMock) - @patch('schwab.auth.prompt', MagicMock(return_value='')) + @patch('schwab.auth.input', MagicMock(return_value='')) @patch('time.time', MagicMock(return_value=MOCK_NOW)) def test_specify_web_browser( self, mock_webbrowser_get, async_session, sync_session, client): @@ -100,7 +100,7 @@ def test_specify_web_browser( @patch('schwab.auth.OAuth2Client', new_callable=MockOAuthClient) @patch('schwab.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) @patch('schwab.auth.webbrowser.get', new_callable=MagicMock) - @patch('schwab.auth.prompt') + @patch('schwab.auth.input') @patch('time.time', MagicMock(return_value=MOCK_NOW)) def test_create_token_file_not_interactive( self, mock_prompt,mock_webbrowser_get, async_session, sync_session, @@ -138,7 +138,7 @@ def test_create_token_file_not_interactive( @patch('schwab.auth.OAuth2Client', new_callable=MockOAuthClient) @patch('schwab.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) @patch('schwab.auth.webbrowser.get', new_callable=MagicMock) - @patch('schwab.auth.prompt', MagicMock(return_value='')) + @patch('schwab.auth.input', MagicMock(return_value='')) @patch('time.time', MagicMock(return_value=MOCK_NOW)) def test_create_token_file_root_callback_url( self, mock_webbrowser_get, async_session, sync_session, client): @@ -233,7 +233,7 @@ def test_start_on_port_443( @patch('schwab.auth.OAuth2Client', new_callable=MockOAuthClient) @patch('schwab.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) @patch('schwab.auth.webbrowser.get', new_callable=MagicMock) - @patch('schwab.auth.prompt', MagicMock(return_value='')) + @patch('schwab.auth.input', MagicMock(return_value='')) @patch('time.time', MagicMock(return_value=MOCK_NOW)) def test_time_out_waiting_for_request( self, mock_webbrowser_get, async_session, sync_session, client): @@ -256,7 +256,7 @@ def test_time_out_waiting_for_request( @patch('schwab.auth.OAuth2Client', new_callable=MockOAuthClient) @patch('schwab.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) @patch('schwab.auth.webbrowser.get', new_callable=MagicMock) - @patch('schwab.auth.prompt', MagicMock(return_value='')) + @patch('schwab.auth.input', MagicMock(return_value='')) @patch('time.time', MagicMock(return_value=MOCK_NOW)) def test_wait_forever_callback_timeout_equals_none( self, mock_webbrowser_get, async_session, sync_session, client): @@ -278,7 +278,7 @@ def test_wait_forever_callback_timeout_equals_none( @patch('schwab.auth.OAuth2Client', new_callable=MockOAuthClient) @patch('schwab.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) @patch('schwab.auth.webbrowser.get', new_callable=MagicMock) - @patch('schwab.auth.prompt', MagicMock(return_value='')) + @patch('schwab.auth.input', MagicMock(return_value='')) @patch('time.time', MagicMock(return_value=MOCK_NOW)) def test_wait_forever_callback_timeout_equals_zero( self, mock_webbrowser_get, async_session, sync_session, client): @@ -641,7 +641,7 @@ def setUp(self): @patch('schwab.auth.Client') @patch('schwab.auth.OAuth2Client', new_callable=MockOAuthClient) @patch('schwab.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) - @patch('schwab.auth.prompt') + @patch('schwab.auth.input') @patch('time.time', MagicMock(return_value=MOCK_NOW)) def test_no_token_file( self, prompt_func, async_session, sync_session, client): @@ -668,7 +668,7 @@ def test_no_token_file( @patch('schwab.auth.Client') @patch('schwab.auth.OAuth2Client', new_callable=MockOAuthClient) @patch('schwab.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) - @patch('schwab.auth.prompt') + @patch('schwab.auth.input') @patch('time.time', MagicMock(return_value=MOCK_NOW)) def test_custom_token_write_func( self, prompt_func, async_session, sync_session, client): @@ -704,7 +704,7 @@ def dummy_token_write_func(token): @patch('schwab.auth.Client') @patch('schwab.auth.OAuth2Client', new_callable=MockOAuthClient) @patch('schwab.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) - @patch('schwab.auth.prompt') + @patch('schwab.auth.input') @patch('builtins.print') @patch('time.time', MagicMock(return_value=MOCK_NOW)) def test_print_warning_on_http_redirect_uri( @@ -736,7 +736,7 @@ def test_print_warning_on_http_redirect_uri( @patch('schwab.auth.Client') @patch('schwab.auth.OAuth2Client', new_callable=MockOAuthClient) @patch('schwab.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) - @patch('schwab.auth.prompt') + @patch('schwab.auth.input') @patch('time.time', MagicMock(return_value=MOCK_NOW)) def test_enforce_enums_disabled( self, prompt_func, async_session, sync_session, client): @@ -761,7 +761,7 @@ def test_enforce_enums_disabled( @patch('schwab.auth.Client') @patch('schwab.auth.OAuth2Client', new_callable=MockOAuthClient) @patch('schwab.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) - @patch('schwab.auth.prompt') + @patch('schwab.auth.input') @patch('time.time', MagicMock(return_value=MOCK_NOW)) def test_enforce_enums_enabled( self, prompt_func, async_session, sync_session, client): @@ -852,7 +852,74 @@ def test_no_token( c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path) - assert c is mock_client + self.assertIs(c, mock_client) + + + @no_duplicates + @patch('schwab.auth.client_from_token_file') + @patch('schwab.auth.client_from_login_flow', new_callable=MockOAuthClient) + @patch('schwab.auth.client_from_manual_flow', new_callable=MockOAuthClient) + @patch('os.getenv', new_callable=MockOAuthClient) + @patch('time.time', MagicMock(return_value=MOCK_NOW)) + def test_running_on_collab_environment( + self, getenv, client_from_manual_flow, client_from_login_flow, + client_from_token_file): + def do_getenv(flag): + assert flag == 'COLAB_RELEASE_TAG' + return 'yes' + getenv.side_effect = do_getenv + + mock_client = MagicMock() + client_from_manual_flow.return_value = mock_client + + c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path) + self.assertIs(c, mock_client) + + + @no_duplicates + @patch('schwab.auth.client_from_token_file') + @patch('schwab.auth.client_from_login_flow', new_callable=MockOAuthClient) + @patch('schwab.auth.client_from_manual_flow', new_callable=MockOAuthClient) + @patch('os.getenv', new_callable=MockOAuthClient) + @patch('schwab.auth._get_ipython') + @patch('time.time', MagicMock(return_value=MOCK_NOW)) + def test_running_on_ipython_in_notebook_mode( + self, get_ipython, getenv, client_from_manual_flow, + client_from_login_flow, client_from_token_file): + getenv.return_value = '' + + class ZMQInteractiveShell: + pass + get_ipython.return_value = ZMQInteractiveShell() + + mock_client = MagicMock() + client_from_manual_flow.return_value = mock_client + + c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path) + self.assertIs(c, mock_client) + + + @no_duplicates + @patch('schwab.auth.client_from_token_file') + @patch('schwab.auth.client_from_login_flow', new_callable=MockOAuthClient) + @patch('schwab.auth.client_from_manual_flow', new_callable=MockOAuthClient) + @patch('os.getenv', new_callable=MockOAuthClient) + @patch('schwab.auth._get_ipython') + @patch('time.time', MagicMock(return_value=MOCK_NOW)) + def test_running_on_ipython_in_something_other_than_notebook_mode( + self, get_ipython, getenv, client_from_manual_flow, + client_from_login_flow, client_from_token_file): + getenv.return_value = '' + + class NotZMQInteractiveShell: + pass + get_ipython.return_value = NotZMQInteractiveShell() + + mock_client = MagicMock() + client_from_login_flow.return_value = mock_client + + c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path) + self.assertIs(c, mock_client) @no_duplicates @@ -870,7 +937,7 @@ def test_no_token_passing_parameters( callback_timeout='callback_timeout', interactive='interactive', requested_browser='requested_browser') - assert c is mock_client + self.assertIs(c, mock_client) client_from_login_flow.assert_called_once_with( API_KEY, APP_SECRET, CALLBACK_URL, self.token_path, @@ -893,7 +960,7 @@ def test_existing_token( c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path) - assert c is mock_client + self.assertIs(c, mock_client) @no_duplicates @@ -911,7 +978,7 @@ def test_existing_token_passing_parameters( c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path, asyncio='asyncio', enforce_enums='enforce_enums') - assert c is mock_client + self.assertIs(c, mock_client) client_from_token_file.assert_called_once_with( self.token_path, API_KEY, APP_SECRET, @@ -936,7 +1003,7 @@ def test_token_too_old( c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path) - assert c is mock_browser_client + self.assertIs(c, mock_browser_client) @no_duplicates @@ -966,7 +1033,7 @@ def test_none_max_token_age( c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path, max_token_age=None) - assert c is mock_client + self.assertIs(c, mock_client) @no_duplicates @@ -983,5 +1050,3 @@ def test_zero_max_token_age( c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path, max_token_age=0) - - assert c is mock_client From fc0a94851b6a3294425ef7f37a6d37f84d275067 Mon Sep 17 00:00:00 2001 From: Alex Golec Date: Sun, 9 Mar 2025 10:31:01 -0400 Subject: [PATCH 2/2] Update docs --- docs/auth.rst | 138 +++++++++++++++++++++++++++++---------------- docs/client.rst | 2 +- docs/streaming.rst | 7 ++- 3 files changed, 93 insertions(+), 54 deletions(-) diff --git a/docs/auth.rst b/docs/auth.rst index 96ca65e..c476110 100644 --- a/docs/auth.rst +++ b/docs/auth.rst @@ -17,61 +17,41 @@ plan on distributing your app, or if you plan on running it on a server and allowing access to other users, these login flows are not for you. ---------------- -OAuth Refresher ---------------- +------------------------ +The Quick and Easy Route +------------------------ -*This section is purely for the curious. If you already understand OAuth (wow, -congrats) or if you don't care and just want to use this package as fast as -possible, feel free to skip this section. If you encounter any weird behavior, -this section may help you understand what's going on.* +If all you want to do is create a client, you should use +:func:`~schwab.auth.easy_client`. This method will attempt to create a client in +a way that's appropriate to the context in which you're running: -Webapp authentication is a complex beast. The OAuth protocol was created to -allow applications to access one anothers' APIs securely and with the minimum -level of trust possible. A full treatise on this topic is well beyond the scope -of this guide, but in order to alleviate some of the complexity that seems to -surround this part of the API, let's give a quick explanation of how OAuth works -in the context of Schwab's API. + * If you've already got a token at ``token_path``, + :func:`load it ` and continue. Otherwise + create a new one. + * In desktop environments, :func:`start a web browser + ` in which you can sign in, and + automatically capture the created token. + * In a notebook like Google Colab or Jupyter, instead run the :func:`manual + flow `. -The first thing to understand is that the OAuth webapp flow was created to allow -client-side applications consisting of a webapp frontend and a remotely hosted -backend to interact with a third party API. Unlike the `backend application flow -`__, in which the remotely hosted backend has a secret -which allows it to access the API on its own behalf, the webapp flow allows -either the webapp frontend or the remotely host backend to access the API *on -behalf of its users*. +Here's how you can use it. If for some reason this doesn't work, please report +your issues in the `Discord server `__. See +:func:`~schwab.auth.easy_client` for details: -If you've ever installed a GitHub, Facebook, Twitter, GMail, etc. app, you've -seen this flow. You click on the "install" link, a login window pops up, you -enter your password, and you're presented with a page that asks whether you want -to grant the app access to your account. +.. code-block:: python -Here's what's happening under the hood. The window that pops up is the -authentication URL, which opens a login page for the target API. The aim is to -allow the user to input their username and password without the webapp frontend -or the remotely hosted backend seeing it. On web browsers, this is accomplished -using the browser's refusal to send credentials from one domain to another. - -Once login here is successful, the API replies with a redirect to a URL that the -remotely hosted backend controls. This is the callback URL. This redirect will -contain a code which securely identifies the user to the API, embedded in the -query of the request. - -You might think that code is enough to access the API, and it would be if the -API author were willing to sacrifice long-term security. The exact reasons why -it doesn't work involve some deep security topics like robustness against replay -attacks and session duration limitation, but we'll skip them here. + from schwab.auth import easy_client -This code is useful only for fetching a token from the authentication endpoint. -*This token* is what we want: a secure secret which the client can use to access -API endpoints, and can be refreshed over time. + # Follow the instructions on the screen to authenticate your client. + c = easy_client( + api_key='APIKEY', + app_secret='APP_SECRET', + callback_url='https://127.0.0.1', + token_path='/tmp/token.json') -If you've gotten this far and your head isn't spinning, you haven't been paying -attention. Security-sensitive protocols can be very complicated, and you should -**never** build your own implementation. Fortunately there exist very robust -implementations of this flow, and ``schwab-py``'s authentication module makes -using them easy. + resp = c.get_price_history_every_day('AAPL') + assert resp.status_code == httpx.codes.OK + history = resp.json() .. _login_flow: @@ -88,8 +68,8 @@ token. .. _manual_login: If for some reason you cannot open a web browser, such as when running in a -cloud environment, this function will guide you through the process of manually -creating a token by copy-pasting relevant URLs. +cloud environment or a notebook, this function will guide you through the +process of manually creating a token by copy-pasting relevant URLs. .. autofunction:: schwab.auth.client_from_manual_flow @@ -204,6 +184,64 @@ seeing this error, you have no choice but to delete your old token file and create a new one. +--------------- +OAuth Refresher +--------------- + +*This section is purely for the curious. If you already understand OAuth (wow, +congrats) or if you don't care and just want to use this package as fast as +possible, feel free to skip this section. If you encounter any weird behavior, +this section may help you understand what's going on.* + +Webapp authentication is a complex beast. The OAuth protocol was created to +allow applications to access one anothers' APIs securely and with the minimum +level of trust possible. A full treatise on this topic is well beyond the scope +of this guide, but in order to alleviate some of the complexity that seems to +surround this part of the API, let's give a quick explanation of how OAuth works +in the context of Schwab's API. + +The first thing to understand is that the OAuth webapp flow was created to allow +client-side applications consisting of a webapp frontend and a remotely hosted +backend to interact with a third party API. Unlike the `backend application flow +`__, in which the remotely hosted backend has a secret +which allows it to access the API on its own behalf, the webapp flow allows +either the webapp frontend or the remotely host backend to access the API *on +behalf of its users*. + +If you've ever installed a GitHub, Facebook, Twitter, GMail, etc. app, you've +seen this flow. You click on the "install" link, a login window pops up, you +enter your password, and you're presented with a page that asks whether you want +to grant the app access to your account. + +Here's what's happening under the hood. The window that pops up is the +authentication URL, which opens a login page for the target API. The aim is to +allow the user to input their username and password without the webapp frontend +or the remotely hosted backend seeing it. On web browsers, this is accomplished +using the browser's refusal to send credentials from one domain to another. + +Once login here is successful, the API replies with a redirect to a URL that the +remotely hosted backend controls. This is the callback URL. This redirect will +contain a code which securely identifies the user to the API, embedded in the +query of the request. + +You might think that code is enough to access the API, and it would be if the +API author were willing to sacrifice long-term security. The exact reasons why +it doesn't work involve some deep security topics like robustness against replay +attacks and session duration limitation, but we'll skip them here. + +This code is useful only for fetching a token from the authentication endpoint. +*This token* is what we want: a secure secret which the client can use to access +API endpoints, and can be refreshed over time. + +If you've gotten this far and your head isn't spinning, you haven't been paying +attention. Security-sensitive protocols can be very complicated, and you should +**never** build your own implementation. Fortunately there exist very robust +implementations of this flow, and ``schwab-py``'s authentication module makes +using them easy. + + + --------------- Troubleshooting --------------- diff --git a/docs/client.rst b/docs/client.rst index 2979c94..979d68c 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -23,7 +23,7 @@ this will likely cause issues with the underlying OAuth2 session management** from schwab.auth import client_from_manual_flow # Follow the instructions on the screen to authenticate your client. - c = client_from_manual_flow( + c = easy_client( api_key='APIKEY', app_secret='APP_SECRET', callback_url='https://127.0.0.1', diff --git a/docs/streaming.rst b/docs/streaming.rst index d2b9227..b39c645 100644 --- a/docs/streaming.rst +++ b/docs/streaming.rst @@ -27,10 +27,11 @@ run this outside regular trading hours you may not see anything): # Assumes you've already created a token. See the authentication page for more # information. - client = client_from_token_file( - token_path='/path/to/token.json', + client = easy_client( api_key='YOUR_API_KEY', - app_secret='YOUR_APP_SECRET') + app_secret='YOUR_APP_SECRET', + callback_url='https://127.0.0.1', + token_path='/path/to/token.json') stream_client = StreamClient(client, account_id=1234567890) async def read_stream():