From 2d9ae882bb80e8c9a378326fe531310c018edc21 Mon Sep 17 00:00:00 2001 From: Ken Moriarty Date: Mon, 13 Oct 2025 15:52:31 -0600 Subject: [PATCH 1/3] Add support for using OAuth2 refresh tokens. The Tastytrade.from_env convenience method has been added to load OAuth2 parameters from the environment, but these can also be passed directly to the constructor. This additionally deprecates support for login via session. --- .env.sample | 7 +- .gitignore | 1 + docs/users/README.md | 23 ++-- poetry.lock | 119 ++++++++++++++++-- pyproject.toml | 3 +- src/tastytrade_sdk/api.py | 32 ++++- src/tastytrade_sdk/config.py | 4 + src/tastytrade_sdk/tastytrade.py | 41 +++++- tests/__init__.py | 2 + .../test_streamer_symbol_translations.py | 4 +- tests/market_data/test_subscription.py | 4 +- tests/market_data_experiment.py | 4 +- tests/test_authentication.py | 12 +- tests/utils.py | 12 -- 14 files changed, 220 insertions(+), 48 deletions(-) delete mode 100644 tests/utils.py diff --git a/.env.sample b/.env.sample index 001f059..6794f02 100644 --- a/.env.sample +++ b/.env.sample @@ -1,3 +1,4 @@ -TASTYTRADE_LOGIN=your-username -TASTYTRADE_PASSWORD=your-password -API_BASE_URL=api.cert.tastyworks.com +API_BASE_URL=api.tastyworks.com +TT_CLIENT_ID= +TT_CLIENT_SECRET= +TT_REFRESH_TOKEN= diff --git a/.gitignore b/.gitignore index ea39d90..994f692 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .env .cache* .DS_Store +.vscode dist *.egg-info docs/_build diff --git a/docs/users/README.md b/docs/users/README.md index 35a6d3e..ef7bd98 100644 --- a/docs/users/README.md +++ b/docs/users/README.md @@ -7,6 +7,7 @@ Before using this SDK, ensure that you: * have a [Tastytrade account](https://open.tastytrade.com/) * have opted into the [Tastytrade Open API program](https://developer.tastytrade.com/) +* Have a set of OAuth2 client credentials and a refresh token for your personal OAuth2 app. See [Tastytrade OAuth2](https://developer.tastytrade.com/oauth/) for more details. ## Install @@ -19,16 +20,14 @@ pip install tastytrade-sdk ```python from tastytrade_sdk import Tastytrade -tasty = Tastytrade() -tasty.login( - login='trader@email.com', - password='password' +tasty = Tastytrade( + client_id = YOUR_CLIENT_ID, + client_secret = YOUR_CLIENT_SECRET, + refresh_token = YOUR_REFRESH_TOKEN, ) tasty.api.post('/sessions/validate') - -tasty.logout() ``` --- @@ -39,7 +38,17 @@ tasty.logout() ```python from tastytrade_sdk import Tastytrade -tasty = Tastytrade().login(login='trader@email.com', password='password') +# Instead of creating a Tastyworks object manually, you can store the details in the following +# environment variables and call this to create the object automatically. Remember, never store +# your client secret or refresh token directly in code or check it into version control. +# To use the sandbox environment instead, use api.cert.tastyworks.com as the API_BASE_URL and +# be sure to use client credentials generated for that environment. +# API_BASE_URL=api.tastyworks.com +# TT_CLIENT_ID= +# TT_CLIENT_SECRET= +# TT_REFRESH_TOKEN= + +tasty = Tastytrade.from_env() # Subscribing to symbols across different instrument types # Please note: The option symbols here are expired. You need to subscribe to an unexpired symbol to receive quote data diff --git a/poetry.lock b/poetry.lock index 02602ba..f18ea44 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "astroid" @@ -6,6 +6,7 @@ version = "2.15.4" description = "An abstract syntax tree for Python with inference support." optional = false python-versions = ">=3.7.2" +groups = ["dev"] files = [ {file = "astroid-2.15.4-py3-none-any.whl", hash = "sha256:a1b8543ef9d36ea777194bc9b17f5f8678d2c56ee6a45b2c2f17eec96f242347"}, {file = "astroid-2.15.4.tar.gz", hash = "sha256:c81e1c7fbac615037744d067a9bb5f9aeb655edf59b63ee8b59585475d6f80d8"}, @@ -25,6 +26,8 @@ version = "1.6.3" description = "An AST unparser for Python" optional = false python-versions = "*" +groups = ["dev"] +markers = "python_version == \"3.8\"" files = [ {file = "astunparse-1.6.3-py2.py3-none-any.whl", hash = "sha256:c2652417f2c8b5bb325c885ae329bdf3f86424075c4fd1a128674bc6fba4b8e8"}, {file = "astunparse-1.6.3.tar.gz", hash = "sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872"}, @@ -40,6 +43,7 @@ version = "0.22.1" description = "The bidirectional mapping library for Python." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "bidict-0.22.1-py3-none-any.whl", hash = "sha256:6ef212238eb884b664f28da76f33f1d28b260f665fc737b413b287d5487d1e7b"}, {file = "bidict-0.22.1.tar.gz", hash = "sha256:1e0f7f74e4860e6d0943a05d4134c63a2fad86f3d4732fb265bd79e4e856d81d"}, @@ -56,6 +60,7 @@ version = "2023.7.22" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, @@ -67,6 +72,7 @@ version = "3.3.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" +groups = ["main"] files = [ {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, @@ -166,6 +172,8 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -177,6 +185,7 @@ version = "0.3.6" description = "serialize all of python" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "dill-0.3.6-py3-none-any.whl", hash = "sha256:a07ffd2351b8c678dfc4a856a3005f8067aea51d6ba6c700796a4d9e280f39f0"}, {file = "dill-0.3.6.tar.gz", hash = "sha256:e5db55f3687856d8fbdab002ed78544e1c4559a130302693d839dfe8f93f2373"}, @@ -191,6 +200,7 @@ version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" +groups = ["main"] files = [ {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, @@ -202,6 +212,7 @@ version = "0.20.1" description = "Injector - Python dependency injection framework, inspired by Guice" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "injector-0.20.1-py2.py3-none-any.whl", hash = "sha256:f8fc5994176a8cf6b0455a8d1558c588733474ef17795553464e7e9d2f94eaf5"}, {file = "injector-0.20.1.tar.gz", hash = "sha256:8661b49a2f8309ce61e3a6a82b7acb5e225c4bde8e17d1610c893a670dff223a"}, @@ -211,7 +222,7 @@ files = [ typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.9\""} [package.extras] -dev = ["black", "check-manifest", "dataclasses", "mypy", "pytest", "pytest-cov (>=2.5.1)"] +dev = ["black ; implementation_name == \"cpython\"", "check-manifest", "dataclasses ; python_version < \"3.7\"", "mypy ; implementation_name == \"cpython\"", "pytest", "pytest-cov (>=2.5.1)"] [[package]] name = "isort" @@ -219,6 +230,7 @@ version = "5.12.0" description = "A Python utility / library to sort Python imports." optional = false python-versions = ">=3.8.0" +groups = ["dev"] files = [ {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"}, {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"}, @@ -236,6 +248,7 @@ version = "3.1.2" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, @@ -253,6 +266,7 @@ version = "1.9.0" description = "A fast and thorough lazy object proxy." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "lazy-object-proxy-1.9.0.tar.gz", hash = "sha256:659fb5809fa4629b8a1ac5106f669cfc7bef26fbb389dda53b3e010d1ac4ebae"}, {file = "lazy_object_proxy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b40387277b0ed2d0602b8293b94d7257e17d1479e257b4de114ea11a8cb7f2d7"}, @@ -298,6 +312,7 @@ version = "2.1.2" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7"}, {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036"}, @@ -357,17 +372,36 @@ version = "0.7.0" description = "McCabe checker, plugin for flake8" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +[[package]] +name = "oauthlib" +version = "3.3.1" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1"}, + {file = "oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9"}, +] + +[package.extras] +rsa = ["cryptography (>=3.0.0)"] +signals = ["blinker (>=1.4.0)"] +signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] + [[package]] name = "pdoc" version = "14.0.0" description = "API Documentation for Python Projects" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pdoc-14.0.0-py3-none-any.whl", hash = "sha256:4514041ff5da33f1adbc700002a661600fc13a9adadef317bc6ae8be9e61154b"}, {file = "pdoc-14.0.0.tar.gz", hash = "sha256:ad6c16c949e5dd8b30effc5398aedb5779ffe8ab94be91ce2cddc320e8127900"}, @@ -388,6 +422,7 @@ version = "3.5.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "platformdirs-3.5.0-py3-none-any.whl", hash = "sha256:47692bc24c1958e8b0f13dd727307cff1db103fca36399f457da8e05f222fdc4"}, {file = "platformdirs-3.5.0.tar.gz", hash = "sha256:7954a68d0ba23558d753f73437c55f89027cf8f5108c19844d4b82e5af396335"}, @@ -403,13 +438,14 @@ version = "2.15.1" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "Pygments-2.15.1-py3-none-any.whl", hash = "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1"}, {file = "Pygments-2.15.1.tar.gz", hash = "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c"}, ] [package.extras] -plugins = ["importlib-metadata"] +plugins = ["importlib-metadata ; python_version < \"3.8\""] [[package]] name = "pylint" @@ -417,6 +453,7 @@ version = "2.17.4" description = "python code static checker" optional = false python-versions = ">=3.7.2" +groups = ["dev"] files = [ {file = "pylint-2.17.4-py3-none-any.whl", hash = "sha256:7a1145fb08c251bdb5cca11739722ce64a63db479283d10ce718b2460e54123c"}, {file = "pylint-2.17.4.tar.gz", hash = "sha256:5dcf1d9e19f41f38e4e85d10f511e5b9c35e1aa74251bf95cdd8cb23584e2db1"}, @@ -446,6 +483,7 @@ version = "0.2.3" description = "Quote consistency checker for PyLint.." optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "pylint-quotes-0.2.3.tar.gz", hash = "sha256:2d6bb3fa8a1a85af3af8a0ca875a719ac5bcdb735c45756284699d809c109c95"}, {file = "pylint_quotes-0.2.3-py2.py3-none-any.whl", hash = "sha256:89decd985d3c019314da630f5e3fe0e0df951c2310525fbd6e710bca329c810e"}, @@ -460,6 +498,7 @@ version = "1.0.0" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba"}, {file = "python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a"}, @@ -470,18 +509,20 @@ cli = ["click (>=5.0)"] [[package]] name = "requests" -version = "2.31.0" +version = "2.32.4" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version < \"3.11\"" files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, ] [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" +charset_normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.21.1,<3" @@ -489,12 +530,56 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "requests" +version = "2.32.5" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.11\"" +files = [ + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +description = "OAuthlib authentication support for Requests." +optional = false +python-versions = ">=3.4" +groups = ["main"] +files = [ + {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, + {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, +] + +[package.dependencies] +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[package.extras] +rsa = ["oauthlib[signedtoken] (>=3.0.0)"] + [[package]] name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["dev"] +markers = "python_version == \"3.8\"" files = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, @@ -506,6 +591,7 @@ version = "0.4.15" description = "An Enum that inherits from str." optional = false python-versions = "*" +groups = ["main"] files = [ {file = "StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659"}, {file = "StrEnum-0.4.15.tar.gz", hash = "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff"}, @@ -522,6 +608,8 @@ version = "2.0.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, @@ -533,6 +621,7 @@ version = "0.11.8" description = "Style preserving TOML library" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "tomlkit-0.11.8-py3-none-any.whl", hash = "sha256:8c726c4c202bdb148667835f68d68780b9a003a9ec34167b6c673b38eff2a171"}, {file = "tomlkit-0.11.8.tar.gz", hash = "sha256:9330fc7faa1db67b541b28e62018c17d20be733177d290a13b24c62d1614e0c3"}, @@ -544,10 +633,12 @@ version = "4.5.0" description = "Backported and Experimental Type Hints for Python 3.7+" optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, ] +markers = {main = "python_version == \"3.8\"", dev = "python_version < \"3.11\""} [[package]] name = "ujson" @@ -555,6 +646,7 @@ version = "5.8.0" description = "Ultra fast JSON encoder and decoder for Python" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "ujson-5.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4511560d75b15ecb367eef561554959b9d49b6ec3b8d5634212f9fed74a6df1"}, {file = "ujson-5.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9399eaa5d1931a0ead49dce3ffacbea63f3177978588b956036bfe53cdf6af75"}, @@ -625,13 +717,14 @@ version = "2.1.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -641,6 +734,7 @@ version = "11.0.3" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "websockets-11.0.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3ccc8a0c387629aec40f2fc9fdcb4b9d5431954f934da3eaf16cdc94f67dbfac"}, {file = "websockets-11.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d67ac60a307f760c6e65dad586f556dde58e683fab03323221a4e530ead6f74d"}, @@ -720,6 +814,8 @@ version = "0.40.0" description = "A built-package format for Python" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version == \"3.8\"" files = [ {file = "wheel-0.40.0-py3-none-any.whl", hash = "sha256:d236b20e7cb522daf2390fa84c55eea81c5c30190f90f29ae2ca1ad8355bf247"}, {file = "wheel-0.40.0.tar.gz", hash = "sha256:cd1196f3faee2b31968d626e1731c94f99cbdb67cf5a46e4f5656cbee7738873"}, @@ -734,6 +830,7 @@ version = "1.15.0" description = "Module for decorators, wrappers and monkey patching." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +groups = ["dev"] files = [ {file = "wrapt-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ca1cccf838cd28d5a0883b342474c630ac48cac5df0ee6eacc9c7290f76b11c1"}, {file = "wrapt-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e826aadda3cae59295b95343db8f3d965fb31059da7de01ee8d1c40a60398b29"}, @@ -813,6 +910,6 @@ files = [ ] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = "^3.8" -content-hash = "16e2cbe4a7d8fd6b0b49422c87bff5eec5793de532ed1c9f5f722fa39a6a730b" +content-hash = "ebd72d952290c350e6664f1f37868ec4758e03cc8b3975279e9ec592283c7eba" diff --git a/pyproject.toml b/pyproject.toml index 3dbc1e0..f4082dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,8 @@ websockets = "^11.0.3" bidict = "^0.22.1" strenum = "^0.4.15" ujson = "^5.8.0" -requests = "^2.31.0" +requests = "^2.32.0" +requests-oauthlib = "^2.0.0" [tool.poetry.group.dev.dependencies] python-dotenv = "^1.0.0" diff --git a/src/tastytrade_sdk/api.py b/src/tastytrade_sdk/api.py index bac6a0a..34d7b54 100644 --- a/src/tastytrade_sdk/api.py +++ b/src/tastytrade_sdk/api.py @@ -1,8 +1,11 @@ import logging from typing import Optional, Tuple, List, Any, Union, Dict +from urllib.parse import urljoin +import warnings from injector import singleton, inject from requests import Session, JSONDecodeError +from requests_oauthlib import OAuth2Session from tastytrade_sdk.config import Config from tastytrade_sdk.exceptions import TastytradeSdkException @@ -14,20 +17,31 @@ @singleton class RequestsSession: - __session = Session() __user_agent = 'tastytrade-sdk-python' @inject def __init__(self, config: Config): self.__base_url = f'https://{config.api_base_url}' + if config.client_id: + self._start_oauth_sesion(config) + else: + self.__session = Session() def login(self, login: str, password: str) -> None: + warnings.warn( + 'Session login is deprecated and will stop working soon, please use OAuth2 instead.', + DeprecationWarning) + self.__session = Session() # We can't use the OAuthSession object with a login session, so replace it. self.__session.headers['Authorization'] = self.request( 'POST', '/sessions', data={'login': login, 'password': password} )['data']['session-token'] + @property + def is_oauth_session(self) -> bool: + return isinstance(self.__session, OAuth2Session) + def request(self, method: str, path: str, params: Optional[QueryParams] = tuple(), data: Optional[dict] = None) -> Optional[dict]: url = self.__url(path, params) @@ -60,8 +74,22 @@ def request(self, method: str, path: str, params: Optional[QueryParams] = tuple( error = HttpError raise error(response.reason, response.status_code, error_data) + def _start_oauth_sesion(self, config: Config) -> None: + self.__session = OAuth2Session( + client_id=config.client_id, + token={ + 'access_token': 'Established on first use', + 'refresh_token': config.refresh_token, + 'token_type': 'Bearer', + 'expires_in': '-100', + }, + auto_refresh_url=self.__url('/oauth/token'), + auto_refresh_kwargs={'client_secret': config.client_secret}, + token_updater=lambda _: None, + ) + def __url(self, path: str, params: Optional[QueryParams] = None) -> str: - url = f'{self.__base_url}{path}' + url = urljoin(self.__base_url, path) if params: if isinstance(params, dict): params = list(params.items()) diff --git a/src/tastytrade_sdk/config.py b/src/tastytrade_sdk/config.py index 027494d..d305c93 100644 --- a/src/tastytrade_sdk/config.py +++ b/src/tastytrade_sdk/config.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Optional @dataclass @@ -7,3 +8,6 @@ class Config: Global configuration for the SDK """ api_base_url: str + client_id: Optional[str] = None + client_secret: Optional[str] = None + refresh_token: Optional[str] = None diff --git a/src/tastytrade_sdk/tastytrade.py b/src/tastytrade_sdk/tastytrade.py index 60af6df..5623e2b 100644 --- a/src/tastytrade_sdk/tastytrade.py +++ b/src/tastytrade_sdk/tastytrade.py @@ -1,3 +1,6 @@ +from os import environ +from typing import Optional +import warnings from injector import Injector from tastytrade_sdk.config import Config @@ -10,20 +13,30 @@ class Tastytrade: The SDK's top-level class """ - def __init__(self, api_base_url: str = 'api.tastytrade.com'): + def __init__(self, + api_base_url: str = 'api.tastytrade.com', + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + refresh_token: Optional[str] = None): """ :param api_base_url: Optionally override the base URL used by the API (when using the sandbox environment, for e.g.) """ def __configure(binder): - binder.bind(Config, to=Config(api_base_url=api_base_url)) + binder.bind(Config, to=Config(api_base_url=api_base_url, + client_id=client_id, + client_secret=client_secret, + refresh_token=refresh_token)) self.__container = Injector(__configure) def login(self, login: str, password: str) -> 'Tastytrade': """ Initialize a logged-in session + + .. deprecated:: 1.3.0 + Use OAuth2 credentials instead. """ self.__container.get(RequestsSession).login(login, password) return self @@ -31,8 +44,30 @@ def login(self, login: str, password: str) -> 'Tastytrade': def logout(self) -> None: """ End the session + + .. deprecated:: 1.3.0 + Use OAuth2 credentials instead. """ - self.api.delete('/sessions') + if self.__container.get(RequestsSession).is_oauth_session: + warnings.warn('Logout does not need to be called with an OAuth2 session.', DeprecationWarning) + else: + self.api.delete('/sessions') + + @classmethod + def from_env(cls) -> 'Tastytrade': + """Creates a Tastytrade OAuth2 session using the environment variables: + + - `API_BASE_URL` (uses `api.tastyworks.com` unless specified) + - `TT_CLIENT_ID` + - `TT_CLIENT_SECRET` + - `TT_REFRESH_TOKEN` + """ + return Tastytrade(api_base_url=environ.get('API_BASE_URL', 'api.tastyworks.com'), + client_id=environ.get('TT_CLIENT_ID'), + client_secret=environ.get('TT_CLIENT_SECRET'), + refresh_token=environ.get('TT_REFRESH_TOKEN') + ) + @property def market_data(self) -> MarketData: diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..faaa497 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +from dotenv import load_dotenv +load_dotenv() diff --git a/tests/market_data/test_streamer_symbol_translations.py b/tests/market_data/test_streamer_symbol_translations.py index e6fa741..54b9527 100644 --- a/tests/market_data/test_streamer_symbol_translations.py +++ b/tests/market_data/test_streamer_symbol_translations.py @@ -1,14 +1,14 @@ from unittest import TestCase, skip from tastytrade_sdk.market_data.streamer_symbol_translation import StreamerSymbolTranslationsFactory -from tests.utils import get_tasty +from tastytrade_sdk import Tastytrade class StreamerSymbolTranslationsFactoryTest(TestCase): @classmethod def setUpClass(cls) -> None: - cls.__factory = StreamerSymbolTranslationsFactory(get_tasty().api) + cls.__factory = StreamerSymbolTranslationsFactory(Tastytrade.from_env().api) def test_equities(self): translations = self.__factory.create(['SPY', 'AAPL', 'FOO']) diff --git a/tests/market_data/test_subscription.py b/tests/market_data/test_subscription.py index 97011cd..a89addb 100644 --- a/tests/market_data/test_subscription.py +++ b/tests/market_data/test_subscription.py @@ -4,7 +4,7 @@ from tastytrade_sdk import Subscription from tastytrade_sdk.exceptions import InvalidArgument from tastytrade_sdk.market_data.streamer_symbol_translation import StreamerSymbolTranslations -from tests.utils import get_tasty +from tastytrade_sdk import Tastytrade TIMEOUT=30 @@ -15,7 +15,7 @@ def test_requires_at_least_one_event_handler(self): Subscription('url', 'token', StreamerSymbolTranslations([])) def test_subscription(self): - tasty = get_tasty() + tasty = Tastytrade.from_env() symbols = ['AAPL'] quotes = [] fields = {'Quote': ['eventSymbol', 'askPrice']} diff --git a/tests/market_data_experiment.py b/tests/market_data_experiment.py index f7aacc0..3e32881 100644 --- a/tests/market_data_experiment.py +++ b/tests/market_data_experiment.py @@ -1,11 +1,11 @@ import logging import os -from tests.utils import get_tasty +from tastytrade_sdk import Tastytrade def main(): logging.basicConfig(level=os.environ.get('LOGLEVEL', 'INFO').upper()) - tasty = get_tasty() + tasty = Tastytrade.from_env() symbols = ['YELP 240517C00042000', 'AAPL'] subscription = tasty.market_data.subscribe( symbols=symbols, on_quote=print, on_candle=print, on_greeks=print, diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 9e689a9..c5d7d23 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -1,13 +1,19 @@ +from os import environ from unittest import TestCase from tastytrade_sdk.api import Unauthorized -from tests.utils import get_tasty +from tastytrade_sdk.tastytrade import Tastytrade class Authentication(TestCase): - def test_authentication(self): - tasty = get_tasty() + def test_login_authentication(self): + tasty = Tastytrade(api_base_url=environ.get('API_BASE_URL')) \ + .login(environ.get('TASTYTRADE_LOGIN'), environ.get('TASTYTRADE_PASSWORD')) tasty.api.post('/sessions/validate') tasty.logout() with self.assertRaises(Unauthorized): tasty.api.post('/sessions/validate') + + def test_oauth_authentication(self): + tasty = Tastytrade.from_env() + tasty.api.post('/sessions/validate') diff --git a/tests/utils.py b/tests/utils.py deleted file mode 100644 index 332efbe..0000000 --- a/tests/utils.py +++ /dev/null @@ -1,12 +0,0 @@ -from os import environ - -from dotenv import load_dotenv - -from tastytrade_sdk import Tastytrade - -load_dotenv() - - -def get_tasty() -> Tastytrade: - return Tastytrade(api_base_url=environ.get('API_BASE_URL')) \ - .login(environ.get('TASTYTRADE_LOGIN'), environ.get('TASTYTRADE_PASSWORD')) From 27f729fc6d2cfe8f7980f65eb7d95845c654e2f4 Mon Sep 17 00:00:00 2001 From: Ken Moriarty Date: Mon, 13 Oct 2025 16:49:35 -0600 Subject: [PATCH 2/3] Add env vars for CI action --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1411404..ec061ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,4 +23,7 @@ jobs: env: API_BASE_URL: ${{ vars.TASTYTRADE_CERT_URL }} TASTYTRADE_LOGIN: ${{ secrets.TASTYTRADE_CERT_LOGIN }} - TASTYTRADE_PASSWORD: ${{ secrets.TASTYTRADE_CERT_PASSWORD }} \ No newline at end of file + TASTYTRADE_PASSWORD: ${{ secrets.TASTYTRADE_CERT_PASSWORD }} + TT_CLIENT_ID: ${{ secrets.TT_CLIENT_ID }} + TT_CLIENT_SECRET: ${{ secrets.TT_CLIENT_SECRET }} + TT_REFRESH_TOKEN: ${{ secrets.TT_REFRESH_TOKEN }} From 0309c397b190c3a86da89c81d5d57076e7f8fbdb Mon Sep 17 00:00:00 2001 From: Ken Moriarty Date: Mon, 13 Oct 2025 16:59:19 -0600 Subject: [PATCH 3/3] More vars --- .github/workflows/release-test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release-test.yml b/.github/workflows/release-test.yml index 95a003b..7eb5d15 100644 --- a/.github/workflows/release-test.yml +++ b/.github/workflows/release-test.yml @@ -38,6 +38,9 @@ jobs: API_BASE_URL: ${{ vars.TASTYTRADE_CERT_URL }} TASTYTRADE_LOGIN: ${{ secrets.TASTYTRADE_CERT_LOGIN }} TASTYTRADE_PASSWORD: ${{ secrets.TASTYTRADE_CERT_PASSWORD }} + TT_CLIENT_ID: ${{ secrets.TT_CLIENT_ID }} + TT_CLIENT_SECRET: ${{ secrets.TT_CLIENT_SECRET }} + TT_REFRESH_TOKEN: ${{ secrets.TT_REFRESH_TOKEN }} - name: Build with Poetry run: | poetry version "$(poetry version -s)dev${GITHUB_RUN_NUMBER}"