Skip to content

Commit 6036337

Browse files
authored
Bring back easy client (#133)
* reintroduced easy_client * remove note on missing client_from_login_flow and easy_client * bug in test!
1 parent bcc51a1 commit 6036337

File tree

4 files changed

+255
-26
lines changed

4 files changed

+255
-26
lines changed

docs/auth.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ the login flow again.
9898

9999
.. autofunction:: schwab.auth.client_from_token_file
100100

101+
The following is a convenient wrapper around token creation and fetching,
102+
calling each when appropriate:
103+
104+
.. autofunction:: schwab.auth.easy_client
105+
101106
If you don't want to create a client and just want to fetch a token, you can use
102107
the ``schwab-generate-token.py`` script that's installed with the library. This
103108
method is particularly useful if you want to create your token on one machine

docs/tda-transition.rst

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -38,25 +38,6 @@ old that worked under ``tda-api``. You must delete that one and create a new
3838
one.
3939

4040

41-
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
42-
No More ``client_from_login_flow()`` or ``easy_client()``
43-
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
44-
45-
``tda-api`` supported a convenient flow where the library opens a browser to
46-
fetch a token, waits for you to complete login, and then closes the browser when
47-
it notices you succeeded. Schwab seems to explicitly disallow this sort of
48-
thing: if it notices your browser is under the control of automation software
49-
(like the ``selenium`` library which ``tda-api`` used to control the browser) it
50-
rejects all login credentials, even valid ones.
51-
52-
At this time, all token creation must be performed through
53-
:meth:`client_from_manual_flow <schwab.auth.client_from_manual_flow>`. There
54-
*appears* to be a way to recreate most of the old ``client_from_login_flow``
55-
functionality, but there is no timeline on when we'll begin to experiment with
56-
it. Please don't pester the server with requests to implement this. You are
57-
advised to transition to the manual method method for the foreseeable future.
58-
59-
6041
+++++++++++++++++++++++++++++++++
6142
Tokens lifetimes are much shorter
6243
+++++++++++++++++++++++++++++++++

schwab/auth.py

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -384,8 +384,8 @@ def callback_server():
384384
print()
385385

386386
if interactive:
387-
prompt('Press ENTER to open the browser. Note you can run ' +
388-
'client_from_login_flow with interactive=False to skip this input')
387+
prompt('Press ENTER to open the browser. Note you can call ' +
388+
'this method with interactive=False to skip this input.')
389389

390390
controller = webbrowser.get(requested_browser)
391391
print(webbrowser.get)
@@ -610,3 +610,88 @@ async def oauth_client_update_token(t, *args, **kwargs):
610610
leeway=300),
611611
token_metadata=metadata,
612612
enforce_enums=enforce_enums)
613+
614+
615+
################################################################################
616+
# easy_client
617+
618+
619+
def easy_client(api_key, app_secret, callback_url, token_path, asyncio=False,
620+
enforce_enums=True, max_token_age=60*60*24*6.5,
621+
callback_timeout=300.0, interactive=True,
622+
requested_browser=None):
623+
'''
624+
Convenient wrapper around :func:`client_from_login_flow` and
625+
:func:`client_from_token_file`. If ``token_path`` exists, loads the token
626+
from it. Otherwise open a login flow to fetch a new token. Returns a client
627+
configured to refresh the token to ``token_path``.
628+
629+
*Reminder:* You should never create the token file yourself or modify it in
630+
any way. If ``token_path`` refers to an existing file, this method will
631+
assume that file is valid token and will attempt to parse it.
632+
633+
:param api_key: Your Schwab application's app key.
634+
:param app_secret: Application secret provided upon :ref:`app approval
635+
<approved_pending>`.
636+
:param callback_url: Your Schwab application's callback URL. Note this must
637+
*exactly* match the value you've entered in your
638+
application configuration, otherwise login will fail
639+
with a security error. Be sure to check case and
640+
trailing slashes. :ref:`See the above note for
641+
important information about setting your callback URL.
642+
<callback_url_advisory>`
643+
:param token_path: Path to which the new token will be written. If the token
644+
file already exists, it will be overwritten with a new
645+
one. Updated tokens will be written to this path as well.
646+
:param asyncio: If set to ``True``, this will enable async support allowing
647+
the client to be used in an async environment. Defaults to
648+
``False``
649+
:param enforce_enums: Set it to ``False`` to disable the enum checks on ALL
650+
the client methods. Only do it if you know you really
651+
need it. For most users, it is advised to use enums
652+
to avoid errors.
653+
:param max_token_age: If the token is loaded from a file but is older than
654+
this age (in seconds), proactively delete it and
655+
create a new one. Assists with
656+
:ref:`token expiration <token_expiration>`. If set to
657+
None, never proactively delete the token.
658+
:param callback_timeout: See the corresponding parameter to
659+
:func:`client_from_login_flow
660+
<client_from_login_flow>`.
661+
:param interactive: See the corresponding parameter to
662+
:func:`client_from_login_flow
663+
<client_from_login_flow>`.
664+
:param requested_browser: See the corresponding parameter to
665+
:func:`client_from_login_flow
666+
<client_from_login_flow>`.
667+
'''
668+
if max_token_age is None:
669+
max_token_age = 0
670+
if max_token_age < 0:
671+
raise ValueError('max_token_age must be positive, zero, or None')
672+
673+
logger = get_logger()
674+
675+
c = None
676+
677+
if os.path.isfile(token_path):
678+
c = client_from_token_file(token_path, api_key, app_secret,
679+
asyncio=asyncio,
680+
enforce_enums=enforce_enums)
681+
logger.info('Loaded token from file \'%s\'', token_path)
682+
683+
if max_token_age > 0 and c.token_age() >= max_token_age:
684+
logger.info('token too old, proactively creating a new one')
685+
c = None
686+
687+
if c is None:
688+
c = client_from_login_flow(
689+
api_key, app_secret, callback_url, token_path, asyncio=asyncio,
690+
enforce_enums=enforce_enums, callback_timeout=callback_timeout,
691+
requested_browser=requested_browser, interactive=interactive)
692+
693+
logger.info(
694+
'Returning client fetched using web browser, writing' +
695+
'token to \'%s\'', token_path)
696+
697+
return c

tests/auth_test.py

Lines changed: 163 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
APP_SECRET = '0x5EC07'
2020
TOKEN_CREATION_TIMESTAMP = 1613745000
2121
MOCK_NOW = 1613745082
22-
REDIRECT_URL = 'https://redirect.url.com'
22+
CALLBACK_URL = 'https://redirect.url.com'
2323

2424

2525
class ClientFromLoginFlowTest(unittest.TestCase):
@@ -562,7 +562,7 @@ def test_no_token_file(
562562

563563
self.assertEqual('returned client',
564564
auth.client_from_manual_flow(
565-
API_KEY, APP_SECRET, REDIRECT_URL, self.token_path))
565+
API_KEY, APP_SECRET, CALLBACK_URL, self.token_path))
566566

567567
with open(self.token_path, 'r') as f:
568568
self.assertEqual({
@@ -594,7 +594,7 @@ def dummy_token_write_func(token):
594594

595595
self.assertEqual('returned client',
596596
auth.client_from_manual_flow(
597-
API_KEY, APP_SECRET, REDIRECT_URL,
597+
API_KEY, APP_SECRET, CALLBACK_URL,
598598
self.token_path,
599599
token_write_func=dummy_token_write_func))
600600

@@ -657,7 +657,7 @@ def test_enforce_enums_disabled(
657657

658658
self.assertEqual('returned client',
659659
auth.client_from_manual_flow(
660-
API_KEY, APP_SECRET, REDIRECT_URL, self.token_path,
660+
API_KEY, APP_SECRET, CALLBACK_URL, self.token_path,
661661
enforce_enums=False))
662662

663663
client.assert_called_once_with(API_KEY, _, token_metadata=_,
@@ -682,7 +682,7 @@ def test_enforce_enums_enabled(
682682

683683
self.assertEqual('returned client',
684684
auth.client_from_manual_flow(
685-
API_KEY, APP_SECRET, REDIRECT_URL, self.token_path))
685+
API_KEY, APP_SECRET, CALLBACK_URL, self.token_path))
686686

687687
client.assert_called_once_with(API_KEY, _, token_metadata=_,
688688
enforce_enums=True)
@@ -733,3 +733,161 @@ def test_token_age(self):
733733
token, unwrapped_token_write_func=None)
734734
self.assertEqual(metadata.token_age(),
735735
MOCK_NOW - TOKEN_CREATION_TIMESTAMP)
736+
737+
738+
class EasyClientTest(unittest.TestCase):
739+
740+
def setUp(self):
741+
self.tmp_dir = tempfile.TemporaryDirectory()
742+
self.token_path = os.path.join(self.tmp_dir.name, 'token.json')
743+
self.raw_token = {'token': 'yes'}
744+
745+
def put_token(self):
746+
with open(self.token_path, 'w') as f:
747+
f.write(json.dumps(self.raw_token))
748+
749+
750+
@no_duplicates
751+
@patch('schwab.auth.client_from_token_file')
752+
@patch('schwab.auth.client_from_login_flow', new_callable=MockOAuthClient)
753+
@patch('time.time', MagicMock(return_value=MOCK_NOW))
754+
def test_no_token(
755+
self, client_from_login_flow, client_from_token_file):
756+
mock_client = MagicMock()
757+
client_from_login_flow.return_value = mock_client
758+
759+
c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path)
760+
761+
assert c is mock_client
762+
763+
764+
@no_duplicates
765+
@patch('schwab.auth.client_from_token_file')
766+
@patch('schwab.auth.client_from_login_flow', new_callable=MockOAuthClient)
767+
@patch('time.time', MagicMock(return_value=MOCK_NOW))
768+
def test_no_token_passing_parameters(
769+
self, client_from_login_flow, client_from_token_file):
770+
mock_client = MagicMock()
771+
client_from_login_flow.return_value = mock_client
772+
773+
c = auth.easy_client(
774+
API_KEY, APP_SECRET, CALLBACK_URL, self.token_path,
775+
asyncio='asyncio', enforce_enums='enforce_enums',
776+
callback_timeout='callback_timeout', interactive='interactive',
777+
requested_browser='requested_browser')
778+
779+
assert c is mock_client
780+
781+
client_from_login_flow.assert_called_once_with(
782+
API_KEY, APP_SECRET, CALLBACK_URL, self.token_path,
783+
asyncio='asyncio', enforce_enums='enforce_enums',
784+
callback_timeout='callback_timeout', interactive='interactive',
785+
requested_browser='requested_browser')
786+
787+
788+
@no_duplicates
789+
@patch('schwab.auth.client_from_token_file')
790+
@patch('schwab.auth.client_from_login_flow', new_callable=MockOAuthClient)
791+
@patch('time.time', MagicMock(return_value=MOCK_NOW))
792+
def test_existing_token(
793+
self, client_from_login_flow, client_from_token_file):
794+
self.put_token()
795+
796+
mock_client = MagicMock()
797+
client_from_token_file.return_value = mock_client
798+
mock_client.token_age.return_value = 1
799+
800+
c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path)
801+
802+
assert c is mock_client
803+
804+
805+
@no_duplicates
806+
@patch('schwab.auth.client_from_token_file')
807+
@patch('schwab.auth.client_from_login_flow', new_callable=MockOAuthClient)
808+
@patch('time.time', MagicMock(return_value=MOCK_NOW))
809+
def test_existing_token_passing_parameters(
810+
self, client_from_login_flow, client_from_token_file):
811+
self.put_token()
812+
813+
mock_client = MagicMock()
814+
client_from_token_file.return_value = mock_client
815+
mock_client.token_age.return_value = 1
816+
817+
c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path,
818+
asyncio='asyncio', enforce_enums='enforce_enums')
819+
820+
assert c is mock_client
821+
822+
client_from_token_file.assert_called_once_with(
823+
self.token_path, API_KEY, APP_SECRET,
824+
asyncio='asyncio', enforce_enums='enforce_enums')
825+
826+
827+
@no_duplicates
828+
@patch('schwab.auth.client_from_token_file')
829+
@patch('schwab.auth.client_from_login_flow', new_callable=MockOAuthClient)
830+
@patch('time.time', MagicMock(return_value=MOCK_NOW))
831+
def test_token_too_old(
832+
self, client_from_login_flow, client_from_token_file):
833+
self.put_token()
834+
835+
mock_file_client = MagicMock()
836+
client_from_token_file.return_value = mock_file_client
837+
mock_file_client.token_age.return_value = 9999999999
838+
839+
mock_browser_client = MagicMock()
840+
client_from_login_flow.return_value = mock_browser_client
841+
mock_browser_client.token_age.return_value = 1
842+
843+
c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path)
844+
845+
assert c is mock_browser_client
846+
847+
848+
@no_duplicates
849+
@patch('schwab.auth.client_from_token_file')
850+
@patch('schwab.auth.client_from_login_flow', new_callable=MockOAuthClient)
851+
@patch('time.time', MagicMock(return_value=MOCK_NOW))
852+
def test_negative_max_token_age(
853+
self, client_from_login_flow, client_from_token_file):
854+
with self.assertRaisesRegex(
855+
ValueError, 'max_token_age must be positive, zero, or None'):
856+
c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL,
857+
self.token_path, max_token_age=-1)
858+
859+
860+
@no_duplicates
861+
@patch('schwab.auth.client_from_token_file')
862+
@patch('schwab.auth.client_from_login_flow', new_callable=MockOAuthClient)
863+
@patch('time.time', MagicMock(return_value=MOCK_NOW))
864+
def test_none_max_token_age(
865+
self, client_from_login_flow, client_from_token_file):
866+
self.put_token()
867+
868+
mock_client = MagicMock()
869+
client_from_token_file.return_value = mock_client
870+
mock_client.token_age.return_value = 9999999999
871+
872+
c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path,
873+
max_token_age=None)
874+
875+
assert c is mock_client
876+
877+
878+
@no_duplicates
879+
@patch('schwab.auth.client_from_token_file')
880+
@patch('schwab.auth.client_from_login_flow', new_callable=MockOAuthClient)
881+
@patch('time.time', MagicMock(return_value=MOCK_NOW))
882+
def test_zero_max_token_age(
883+
self, client_from_login_flow, client_from_token_file):
884+
self.put_token()
885+
886+
mock_client = MagicMock()
887+
client_from_token_file.return_value = mock_client
888+
mock_client.token_age.return_value = 9999999999
889+
890+
c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path,
891+
max_token_age=0)
892+
893+
assert c is mock_client

0 commit comments

Comments
 (0)