Skip to content

Commit 05c2b40

Browse files
authored
collab and notebook support for easy_client (#193)
* easy_client drops back to manual login flow on detecting a notebook * Update docs
1 parent 3976382 commit 05c2b40

File tree

6 files changed

+217
-79
lines changed

6 files changed

+217
-79
lines changed

docs/auth.rst

Lines changed: 88 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -17,61 +17,41 @@ plan on distributing your app, or if you plan on running it on a server and
1717
allowing access to other users, these login flows are not for you.
1818

1919

20-
---------------
21-
OAuth Refresher
22-
---------------
20+
------------------------
21+
The Quick and Easy Route
22+
------------------------
2323

24-
*This section is purely for the curious. If you already understand OAuth (wow,
25-
congrats) or if you don't care and just want to use this package as fast as
26-
possible, feel free to skip this section. If you encounter any weird behavior,
27-
this section may help you understand what's going on.*
24+
If all you want to do is create a client, you should use
25+
:func:`~schwab.auth.easy_client`. This method will attempt to create a client in
26+
a way that's appropriate to the context in which you're running:
2827

29-
Webapp authentication is a complex beast. The OAuth protocol was created to
30-
allow applications to access one anothers' APIs securely and with the minimum
31-
level of trust possible. A full treatise on this topic is well beyond the scope
32-
of this guide, but in order to alleviate some of the complexity that seems to
33-
surround this part of the API, let's give a quick explanation of how OAuth works
34-
in the context of Schwab's API.
28+
* If you've already got a token at ``token_path``,
29+
:func:`load it <schwab.auth.client_from_token_file>` and continue. Otherwise
30+
create a new one.
31+
* In desktop environments, :func:`start a web browser
32+
<schwab.auth.client_from_login_flow>` in which you can sign in, and
33+
automatically capture the created token.
34+
* In a notebook like Google Colab or Jupyter, instead run the :func:`manual
35+
flow <schwab.auth.client_from_manual_flow>`.
3536

36-
The first thing to understand is that the OAuth webapp flow was created to allow
37-
client-side applications consisting of a webapp frontend and a remotely hosted
38-
backend to interact with a third party API. Unlike the `backend application flow
39-
<https://requests-oauthlib.readthedocs.io/en/latest/oauth2_workflow.html
40-
#backend-application-flow>`__, in which the remotely hosted backend has a secret
41-
which allows it to access the API on its own behalf, the webapp flow allows
42-
either the webapp frontend or the remotely host backend to access the API *on
43-
behalf of its users*.
37+
Here's how you can use it. If for some reason this doesn't work, please report
38+
your issues in the `Discord server <https://discord.gg/BEr6y6Xqyv>`__. See
39+
:func:`~schwab.auth.easy_client` for details:
4440

45-
If you've ever installed a GitHub, Facebook, Twitter, GMail, etc. app, you've
46-
seen this flow. You click on the "install" link, a login window pops up, you
47-
enter your password, and you're presented with a page that asks whether you want
48-
to grant the app access to your account.
41+
.. code-block:: python
4942
50-
Here's what's happening under the hood. The window that pops up is the
51-
authentication URL, which opens a login page for the target API. The aim is to
52-
allow the user to input their username and password without the webapp frontend
53-
or the remotely hosted backend seeing it. On web browsers, this is accomplished
54-
using the browser's refusal to send credentials from one domain to another.
55-
56-
Once login here is successful, the API replies with a redirect to a URL that the
57-
remotely hosted backend controls. This is the callback URL. This redirect will
58-
contain a code which securely identifies the user to the API, embedded in the
59-
query of the request.
60-
61-
You might think that code is enough to access the API, and it would be if the
62-
API author were willing to sacrifice long-term security. The exact reasons why
63-
it doesn't work involve some deep security topics like robustness against replay
64-
attacks and session duration limitation, but we'll skip them here.
43+
from schwab.auth import easy_client
6544
66-
This code is useful only for fetching a token from the authentication endpoint.
67-
*This token* is what we want: a secure secret which the client can use to access
68-
API endpoints, and can be refreshed over time.
45+
# Follow the instructions on the screen to authenticate your client.
46+
c = easy_client(
47+
api_key='APIKEY',
48+
app_secret='APP_SECRET',
49+
callback_url='https://127.0.0.1',
50+
token_path='/tmp/token.json')
6951
70-
If you've gotten this far and your head isn't spinning, you haven't been paying
71-
attention. Security-sensitive protocols can be very complicated, and you should
72-
**never** build your own implementation. Fortunately there exist very robust
73-
implementations of this flow, and ``schwab-py``'s authentication module makes
74-
using them easy.
52+
resp = c.get_price_history_every_day('AAPL')
53+
assert resp.status_code == httpx.codes.OK
54+
history = resp.json()
7555
7656
7757
.. _login_flow:
@@ -88,8 +68,8 @@ token.
8868
.. _manual_login:
8969

9070
If for some reason you cannot open a web browser, such as when running in a
91-
cloud environment, this function will guide you through the process of manually
92-
creating a token by copy-pasting relevant URLs.
71+
cloud environment or a notebook, this function will guide you through the
72+
process of manually creating a token by copy-pasting relevant URLs.
9373

9474
.. autofunction:: schwab.auth.client_from_manual_flow
9575

@@ -204,6 +184,64 @@ seeing this error, you have no choice but to delete your old token file and
204184
create a new one.
205185

206186

187+
---------------
188+
OAuth Refresher
189+
---------------
190+
191+
*This section is purely for the curious. If you already understand OAuth (wow,
192+
congrats) or if you don't care and just want to use this package as fast as
193+
possible, feel free to skip this section. If you encounter any weird behavior,
194+
this section may help you understand what's going on.*
195+
196+
Webapp authentication is a complex beast. The OAuth protocol was created to
197+
allow applications to access one anothers' APIs securely and with the minimum
198+
level of trust possible. A full treatise on this topic is well beyond the scope
199+
of this guide, but in order to alleviate some of the complexity that seems to
200+
surround this part of the API, let's give a quick explanation of how OAuth works
201+
in the context of Schwab's API.
202+
203+
The first thing to understand is that the OAuth webapp flow was created to allow
204+
client-side applications consisting of a webapp frontend and a remotely hosted
205+
backend to interact with a third party API. Unlike the `backend application flow
206+
<https://requests-oauthlib.readthedocs.io/en/latest/oauth2_workflow.html
207+
#backend-application-flow>`__, in which the remotely hosted backend has a secret
208+
which allows it to access the API on its own behalf, the webapp flow allows
209+
either the webapp frontend or the remotely host backend to access the API *on
210+
behalf of its users*.
211+
212+
If you've ever installed a GitHub, Facebook, Twitter, GMail, etc. app, you've
213+
seen this flow. You click on the "install" link, a login window pops up, you
214+
enter your password, and you're presented with a page that asks whether you want
215+
to grant the app access to your account.
216+
217+
Here's what's happening under the hood. The window that pops up is the
218+
authentication URL, which opens a login page for the target API. The aim is to
219+
allow the user to input their username and password without the webapp frontend
220+
or the remotely hosted backend seeing it. On web browsers, this is accomplished
221+
using the browser's refusal to send credentials from one domain to another.
222+
223+
Once login here is successful, the API replies with a redirect to a URL that the
224+
remotely hosted backend controls. This is the callback URL. This redirect will
225+
contain a code which securely identifies the user to the API, embedded in the
226+
query of the request.
227+
228+
You might think that code is enough to access the API, and it would be if the
229+
API author were willing to sacrifice long-term security. The exact reasons why
230+
it doesn't work involve some deep security topics like robustness against replay
231+
attacks and session duration limitation, but we'll skip them here.
232+
233+
This code is useful only for fetching a token from the authentication endpoint.
234+
*This token* is what we want: a secure secret which the client can use to access
235+
API endpoints, and can be refreshed over time.
236+
237+
If you've gotten this far and your head isn't spinning, you haven't been paying
238+
attention. Security-sensitive protocols can be very complicated, and you should
239+
**never** build your own implementation. Fortunately there exist very robust
240+
implementations of this flow, and ``schwab-py``'s authentication module makes
241+
using them easy.
242+
243+
244+
207245
---------------
208246
Troubleshooting
209247
---------------

docs/client.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ this will likely cause issues with the underlying OAuth2 session management**
2323
from schwab.auth import client_from_manual_flow
2424
2525
# Follow the instructions on the screen to authenticate your client.
26-
c = client_from_manual_flow(
26+
c = easy_client(
2727
api_key='APIKEY',
2828
app_secret='APP_SECRET',
2929
callback_url='https://127.0.0.1',

docs/streaming.rst

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ run this outside regular trading hours you may not see anything):
2727
2828
# Assumes you've already created a token. See the authentication page for more
2929
# information.
30-
client = client_from_token_file(
31-
token_path='/path/to/token.json',
30+
client = easy_client(
3231
api_key='YOUR_API_KEY',
33-
app_secret='YOUR_APP_SECRET')
32+
app_secret='YOUR_APP_SECRET',
33+
callback_url='https://127.0.0.1',
34+
token_path='/path/to/token.json')
3435
stream_client = StreamClient(client, account_id=1234567890)
3536
3637
async def read_stream():

schwab/auth.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from authlib.integrations.httpx_client import AsyncOAuth2Client, OAuth2Client
2-
from prompt_toolkit import prompt
32

43
import collections
54
import contextlib
@@ -338,7 +337,7 @@ def callback_server():
338337
print()
339338

340339
if interactive:
341-
prompt('Press ENTER to open the browser. Note you can call ' +
340+
input('Press ENTER to open the browser. Note you can call ' +
342341
'this method with interactive=False to skip this input.')
343342

344343
controller = webbrowser.get(requested_browser)
@@ -485,7 +484,7 @@ def client_from_manual_flow(api_key, app_secret, callback_url, token_path,
485484
'and update your callback URL to begin with \'https\' ' +
486485
'to stop seeing this message.').format(callback_url))
487486

488-
received_url = prompt('Redirect URL> ').strip()
487+
received_url = input('Redirect URL> ').strip()
489488

490489
token_write_func = (
491490
__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):
639638
# easy_client
640639

641640

641+
# TODO: Figure out how to properly mock global objects in unittest. This hack
642+
# ensures that the _get_ipython variable is defined so that we can patch is
643+
# using module-level patching. This is safe in most contexts, but there are
644+
# circumstances where it gets weird like starting an ipython notebook after
645+
# schwab-py is loaded.
646+
try:
647+
_get_ipython = get_ipython
648+
except NameError:
649+
_get_ipython = None
650+
651+
652+
def __running_in_notebook():
653+
# Google Colab
654+
if os.getenv('COLAB_RELEASE_TAG'):
655+
return True
656+
657+
# ipython in notebook mode
658+
if _get_ipython is not None:
659+
shell = _get_ipython().__class__.__name__
660+
if shell == 'ZMQInteractiveShell':
661+
return True
662+
663+
return False
664+
665+
642666
def easy_client(api_key, app_secret, callback_url, token_path, asyncio=False,
643667
enforce_enums=True, max_token_age=60*60*24*6.5,
644668
callback_timeout=300.0, interactive=True,
@@ -707,7 +731,18 @@ def easy_client(api_key, app_secret, callback_url, token_path, asyncio=False,
707731
logger.info('token too old, proactively creating a new one')
708732
c = None
709733

710-
if c is None:
734+
# Return early on success
735+
if c is not None:
736+
return c
737+
738+
# Detect whether we're running in a notebook
739+
if __running_in_notebook():
740+
c = client_from_manual_flow(api_key, app_secret, callback_url,
741+
token_path, enforce_enums=enforce_enums)
742+
logger.info(
743+
'Returning client fetched using manual flow, writing' +
744+
'token to \'%s\'', token_path)
745+
else:
711746
c = client_from_login_flow(
712747
api_key, app_secret, callback_url, token_path, asyncio=asyncio,
713748
enforce_enums=enforce_enums, callback_timeout=callback_timeout,

setup.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
'flask',
3636
'httpx',
3737
'multiprocess',
38-
'prompt_toolkit',
3938
'psutil',
4039
'python-dateutil',
4140
'urllib3',

0 commit comments

Comments
 (0)