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/.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 }} 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}" 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'))