Skip to content

Commit

Permalink
Bring back easy client (#133)
Browse files Browse the repository at this point in the history
* reintroduced easy_client

* remove note on missing client_from_login_flow and easy_client

* bug in test!
  • Loading branch information
alexgolec authored Jun 18, 2024
1 parent bcc51a1 commit 6036337
Show file tree
Hide file tree
Showing 4 changed files with 255 additions and 26 deletions.
5 changes: 5 additions & 0 deletions docs/auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ the login flow again.

.. autofunction:: schwab.auth.client_from_token_file

The following is a convenient wrapper around token creation and fetching,
calling each when appropriate:

.. autofunction:: schwab.auth.easy_client

If you don't want to create a client and just want to fetch a token, you can use
the ``schwab-generate-token.py`` script that's installed with the library. This
method is particularly useful if you want to create your token on one machine
Expand Down
19 changes: 0 additions & 19 deletions docs/tda-transition.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,25 +38,6 @@ old that worked under ``tda-api``. You must delete that one and create a new
one.


+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
No More ``client_from_login_flow()`` or ``easy_client()``
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++

``tda-api`` supported a convenient flow where the library opens a browser to
fetch a token, waits for you to complete login, and then closes the browser when
it notices you succeeded. Schwab seems to explicitly disallow this sort of
thing: if it notices your browser is under the control of automation software
(like the ``selenium`` library which ``tda-api`` used to control the browser) it
rejects all login credentials, even valid ones.

At this time, all token creation must be performed through
:meth:`client_from_manual_flow <schwab.auth.client_from_manual_flow>`. There
*appears* to be a way to recreate most of the old ``client_from_login_flow``
functionality, but there is no timeline on when we'll begin to experiment with
it. Please don't pester the server with requests to implement this. You are
advised to transition to the manual method method for the foreseeable future.


+++++++++++++++++++++++++++++++++
Tokens lifetimes are much shorter
+++++++++++++++++++++++++++++++++
Expand Down
89 changes: 87 additions & 2 deletions schwab/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,8 @@ def callback_server():
print()

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

controller = webbrowser.get(requested_browser)
print(webbrowser.get)
Expand Down Expand Up @@ -610,3 +610,88 @@ async def oauth_client_update_token(t, *args, **kwargs):
leeway=300),
token_metadata=metadata,
enforce_enums=enforce_enums)


################################################################################
# easy_client


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,
requested_browser=None):
'''
Convenient wrapper around :func:`client_from_login_flow` and
:func:`client_from_token_file`. If ``token_path`` exists, loads the token
from it. Otherwise open a login flow to fetch a new token. Returns a client
configured to refresh the token to ``token_path``.
*Reminder:* You should never create the token file yourself or modify it in
any way. If ``token_path`` refers to an existing file, this method will
assume that file is valid token and will attempt to parse it.
:param api_key: Your Schwab application's app key.
:param app_secret: Application secret provided upon :ref:`app approval
<approved_pending>`.
:param callback_url: Your Schwab application's callback URL. Note this must
*exactly* match the value you've entered in your
application configuration, otherwise login will fail
with a security error. Be sure to check case and
trailing slashes. :ref:`See the above note for
important information about setting your callback URL.
<callback_url_advisory>`
:param token_path: Path to which the new token will be written. If the token
file already exists, it will be overwritten with a new
one. Updated tokens will be written to this path as well.
:param asyncio: If set to ``True``, this will enable async support allowing
the client to be used in an async environment. Defaults to
``False``
:param enforce_enums: Set it to ``False`` to disable the enum checks on ALL
the client methods. Only do it if you know you really
need it. For most users, it is advised to use enums
to avoid errors.
:param max_token_age: If the token is loaded from a file but is older than
this age (in seconds), proactively delete it and
create a new one. Assists with
:ref:`token expiration <token_expiration>`. If set to
None, never proactively delete the token.
:param callback_timeout: See the corresponding parameter to
:func:`client_from_login_flow
<client_from_login_flow>`.
:param interactive: See the corresponding parameter to
:func:`client_from_login_flow
<client_from_login_flow>`.
:param requested_browser: See the corresponding parameter to
:func:`client_from_login_flow
<client_from_login_flow>`.
'''
if max_token_age is None:
max_token_age = 0
if max_token_age < 0:
raise ValueError('max_token_age must be positive, zero, or None')

logger = get_logger()

c = None

if os.path.isfile(token_path):
c = client_from_token_file(token_path, api_key, app_secret,
asyncio=asyncio,
enforce_enums=enforce_enums)
logger.info('Loaded token from file \'%s\'', token_path)

if max_token_age > 0 and c.token_age() >= max_token_age:
logger.info('token too old, proactively creating a new one')
c = None

if c is None:
c = client_from_login_flow(
api_key, app_secret, callback_url, token_path, asyncio=asyncio,
enforce_enums=enforce_enums, callback_timeout=callback_timeout,
requested_browser=requested_browser, interactive=interactive)

logger.info(
'Returning client fetched using web browser, writing' +
'token to \'%s\'', token_path)

return c
168 changes: 163 additions & 5 deletions tests/auth_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
APP_SECRET = '0x5EC07'
TOKEN_CREATION_TIMESTAMP = 1613745000
MOCK_NOW = 1613745082
REDIRECT_URL = 'https://redirect.url.com'
CALLBACK_URL = 'https://redirect.url.com'


class ClientFromLoginFlowTest(unittest.TestCase):
Expand Down Expand Up @@ -562,7 +562,7 @@ def test_no_token_file(

self.assertEqual('returned client',
auth.client_from_manual_flow(
API_KEY, APP_SECRET, REDIRECT_URL, self.token_path))
API_KEY, APP_SECRET, CALLBACK_URL, self.token_path))

with open(self.token_path, 'r') as f:
self.assertEqual({
Expand Down Expand Up @@ -594,7 +594,7 @@ def dummy_token_write_func(token):

self.assertEqual('returned client',
auth.client_from_manual_flow(
API_KEY, APP_SECRET, REDIRECT_URL,
API_KEY, APP_SECRET, CALLBACK_URL,
self.token_path,
token_write_func=dummy_token_write_func))

Expand Down Expand Up @@ -657,7 +657,7 @@ def test_enforce_enums_disabled(

self.assertEqual('returned client',
auth.client_from_manual_flow(
API_KEY, APP_SECRET, REDIRECT_URL, self.token_path,
API_KEY, APP_SECRET, CALLBACK_URL, self.token_path,
enforce_enums=False))

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

self.assertEqual('returned client',
auth.client_from_manual_flow(
API_KEY, APP_SECRET, REDIRECT_URL, self.token_path))
API_KEY, APP_SECRET, CALLBACK_URL, self.token_path))

client.assert_called_once_with(API_KEY, _, token_metadata=_,
enforce_enums=True)
Expand Down Expand Up @@ -733,3 +733,161 @@ def test_token_age(self):
token, unwrapped_token_write_func=None)
self.assertEqual(metadata.token_age(),
MOCK_NOW - TOKEN_CREATION_TIMESTAMP)


class EasyClientTest(unittest.TestCase):

def setUp(self):
self.tmp_dir = tempfile.TemporaryDirectory()
self.token_path = os.path.join(self.tmp_dir.name, 'token.json')
self.raw_token = {'token': 'yes'}

def put_token(self):
with open(self.token_path, 'w') as f:
f.write(json.dumps(self.raw_token))


@no_duplicates
@patch('schwab.auth.client_from_token_file')
@patch('schwab.auth.client_from_login_flow', new_callable=MockOAuthClient)
@patch('time.time', MagicMock(return_value=MOCK_NOW))
def test_no_token(
self, client_from_login_flow, client_from_token_file):
mock_client = MagicMock()
client_from_login_flow.return_value = mock_client

c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path)

assert c is mock_client


@no_duplicates
@patch('schwab.auth.client_from_token_file')
@patch('schwab.auth.client_from_login_flow', new_callable=MockOAuthClient)
@patch('time.time', MagicMock(return_value=MOCK_NOW))
def test_no_token_passing_parameters(
self, client_from_login_flow, client_from_token_file):
mock_client = MagicMock()
client_from_login_flow.return_value = mock_client

c = auth.easy_client(
API_KEY, APP_SECRET, CALLBACK_URL, self.token_path,
asyncio='asyncio', enforce_enums='enforce_enums',
callback_timeout='callback_timeout', interactive='interactive',
requested_browser='requested_browser')

assert c is mock_client

client_from_login_flow.assert_called_once_with(
API_KEY, APP_SECRET, CALLBACK_URL, self.token_path,
asyncio='asyncio', enforce_enums='enforce_enums',
callback_timeout='callback_timeout', interactive='interactive',
requested_browser='requested_browser')


@no_duplicates
@patch('schwab.auth.client_from_token_file')
@patch('schwab.auth.client_from_login_flow', new_callable=MockOAuthClient)
@patch('time.time', MagicMock(return_value=MOCK_NOW))
def test_existing_token(
self, client_from_login_flow, client_from_token_file):
self.put_token()

mock_client = MagicMock()
client_from_token_file.return_value = mock_client
mock_client.token_age.return_value = 1

c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path)

assert c is mock_client


@no_duplicates
@patch('schwab.auth.client_from_token_file')
@patch('schwab.auth.client_from_login_flow', new_callable=MockOAuthClient)
@patch('time.time', MagicMock(return_value=MOCK_NOW))
def test_existing_token_passing_parameters(
self, client_from_login_flow, client_from_token_file):
self.put_token()

mock_client = MagicMock()
client_from_token_file.return_value = mock_client
mock_client.token_age.return_value = 1

c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path,
asyncio='asyncio', enforce_enums='enforce_enums')

assert c is mock_client

client_from_token_file.assert_called_once_with(
self.token_path, API_KEY, APP_SECRET,
asyncio='asyncio', enforce_enums='enforce_enums')


@no_duplicates
@patch('schwab.auth.client_from_token_file')
@patch('schwab.auth.client_from_login_flow', new_callable=MockOAuthClient)
@patch('time.time', MagicMock(return_value=MOCK_NOW))
def test_token_too_old(
self, client_from_login_flow, client_from_token_file):
self.put_token()

mock_file_client = MagicMock()
client_from_token_file.return_value = mock_file_client
mock_file_client.token_age.return_value = 9999999999

mock_browser_client = MagicMock()
client_from_login_flow.return_value = mock_browser_client
mock_browser_client.token_age.return_value = 1

c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path)

assert c is mock_browser_client


@no_duplicates
@patch('schwab.auth.client_from_token_file')
@patch('schwab.auth.client_from_login_flow', new_callable=MockOAuthClient)
@patch('time.time', MagicMock(return_value=MOCK_NOW))
def test_negative_max_token_age(
self, client_from_login_flow, client_from_token_file):
with self.assertRaisesRegex(
ValueError, 'max_token_age must be positive, zero, or None'):
c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL,
self.token_path, max_token_age=-1)


@no_duplicates
@patch('schwab.auth.client_from_token_file')
@patch('schwab.auth.client_from_login_flow', new_callable=MockOAuthClient)
@patch('time.time', MagicMock(return_value=MOCK_NOW))
def test_none_max_token_age(
self, client_from_login_flow, client_from_token_file):
self.put_token()

mock_client = MagicMock()
client_from_token_file.return_value = mock_client
mock_client.token_age.return_value = 9999999999

c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path,
max_token_age=None)

assert c is mock_client


@no_duplicates
@patch('schwab.auth.client_from_token_file')
@patch('schwab.auth.client_from_login_flow', new_callable=MockOAuthClient)
@patch('time.time', MagicMock(return_value=MOCK_NOW))
def test_zero_max_token_age(
self, client_from_login_flow, client_from_token_file):
self.put_token()

mock_client = MagicMock()
client_from_token_file.return_value = mock_client
mock_client.token_age.return_value = 9999999999

c = auth.easy_client(API_KEY, APP_SECRET, CALLBACK_URL, self.token_path,
max_token_age=0)

assert c is mock_client

0 comments on commit 6036337

Please sign in to comment.