diff --git a/.gitignore b/.gitignore index fb0226e..9209816 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ + .idea/ # Created by https://www.gitignore.io/api/macos,linux,django,python,pycharm diff --git a/README.md b/README.md index 9ebb76a..2b2a23f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,61 @@ -# Venmo API +# Python Venmo API - Updated Fork + +This is a fork of mmohades' [python venmo api](https://github.com/mmohades/Venmo.git) +package, which is no longer maintained, and therefore some features/payloads (notably +payments) no longer worked. I took the liberty of fixing payment functionality, adding +additional endpoints, and refactoring it almost beyond recognition. To be specific, this +uses the mobile Venmo app API, the browser version is quite different. + +## FORK UPDATES + +- Payments work again! Credit to [Joseph Charles](https://github.com/j027/Venmo) for + adding eligibility token support and laying the groundwork. +- Added `PaymentApi.get_transfer_destinations()` and `PaymentApi.initiate_transfer()` + for standard/instant transfers to bank/card. +- Requires Python 3.11+, using a pyproject.toml (`uv` friendly) and modern language + features. +- The data models now use `pydantic-v2`, removing a bunch of boilerplate. +- Set the env var `LOGGING_SESSION` to print the raw requests sent and responses + received. +- `venmo.Client` has context manager dunder methods for `with` block logout using a + stored access token. +- I got rid of the threaded-async callback functionality, because it added complexity + that I didn't see as useful. In my experience Venmo is now quick to pump the brakes + on anyone hitting the API too rapidly. This manifests in the dreaded 403 response: + "OAuth2 Exception: Unable to complete your request. Please try again later", locking + you out of your account with a variable cooldown time. +- Request headers now mirror the actual app's as closely as possible. The default + headers live in `default_headers.json`. +- All code docstrings have been updated with changes. + +## TODO + +- Update Sphinx docs. +- Get the original creator's blessing to submit package to PyPi with a new name. + +## Device ID Rigmarole + +In my experience, the random device IDs generated by default are no longer accepted by +the API. I had to grab my iPhone's actual device ID from the app's request headers to +get it to cooperate. I did that using the `mitmproxy` command line tool, +routing my phone's WiFi connection through the proxy, and grabbing it from the aptly +named header `device-id` present in any request to `https://api.venmo.com/v1`. +Good instructions [here](https://blog.sayan.page/mitm-proxy-on-ios/). Luckily you only +have to do this once, the ID is fixed. -Disclaimer: This is an individual effort and is not PayPal/Venmo sponsored or maintained. +```bash +$ brew install mitmproxy +# You'll route your WiFi through your desktop IP address, port 8080 +$ mitmweb --listen-host 0.0.0.0 --listen-port 8080 --web-port 8081 +``` + +# ORIGINAL README BELOW + +Disclaimer: This is an individual effort and is not PayPal/Venmo sponsored or maintained. ## Introduction -This is a wrapper for the Venmo API. This library provides a Python interface for the Venmo API. It's compatible with Python versions 3.6+. +This library provides a Python wrapper for the Venmo API, using synchronous requests. ## Installing @@ -41,7 +92,7 @@ print("My token:", access_token) The following is an example of initializing and working with the api client. - ```python +```python access_token = "YOUR_ACCESS_TOKEN" # Initialize api client using an access-token @@ -50,17 +101,9 @@ client = Client(access_token=access_token) # Search for users. You get a maximum of 50 results per request. users = client.user.search_for_users(query="Peter") for user in users: - print(user.username) - -# Or pass a callback to make it multi-threaded -def callback(users): - for user in users: - print(user.username) + print(user.username) +``` -client.user.search_for_users(query="peter", - callback=callback, - limit=10) - ``` ##### Revoke token Keep this in mind that your access token never expires! You will need to revoke it yoursef: @@ -69,17 +112,15 @@ Keep this in mind that your access token never expires! You will need to revoke client.log_out("Bearer a40fsdfhsfhdsfjhdkgljsdglkdsfj3j3i4349t34j7d") ``` - - ##### Payment methods Get all your payment methods to use one's id for sending_money -````python +```python payment_methods = client.payment.get_payment_methods() for payment_method in payment_methods: print(payment_method.to_json()) -```` +``` ##### Sending or requesting money @@ -101,13 +142,9 @@ client.payment.send_money(amount=13.68, Getting a user's transactions (only the ones that are visible to you, e.g, their `public` transactions) ```python -def callback(transactions_list): - for transaction in transactions_list: - print(transaction) -# callback is optional. Max number of transactions per request is 50. -client.user.get_user_transactions(user_id='0000000000000000000', - callback=callback) +# Max number of transactions per request is 50. +client.user.get_user_transactions(user_id='0000000000000000000') ``` ##### Friends list @@ -140,8 +177,8 @@ while transactions: ## Contributing -Contributions of all sizes are welcome. You can help with the wrapper documentation located in /docs. You can also help by [reporting bugs](https://github.com/mmohades/VenmoApi/issues/new). You can add more routes to both [Venmo Unofficial API Documentation](https://github.com/mmohades/VenmoApiDocumentation) and the `venmo-api` wrapper. +Contributions of all sizes are welcome. You can help with the wrapper documentation located in /docs. You can also help by [reporting bugs](https://github.com/mmohades/VenmoApi/issues/new). You can add more routes to both [Venmo Unofficial API Documentation](https://github.com/mmohades/VenmoApiDocumentation) and the `venmo-api` wrapper. ## Venmo Unofficial API Documentation -You can find and contribute to the [Venmo Unofficial API Documentation](https://github.com/mmohades/VenmoApiDocumentation). \ No newline at end of file +You can find and contribute to the [Venmo Unofficial API Documentation](https://github.com/mmohades/VenmoApiDocumentation). diff --git a/default_headers.json b/default_headers.json new file mode 100644 index 0000000..49ebe60 --- /dev/null +++ b/default_headers.json @@ -0,0 +1,8 @@ +{ + "Host": "api.venmo.com", + "User-Agent": "Venmo/26.1.0 (iPhone; iOS 18.6.2; Scale/3.0)", + "Accept": "application/json; charset=utf-8", + "Accept-Language": "en-US;q=1.0", + "Accept-Encoding": "gzip;q=1.0,compress;q=0.5", + "Connection": "keep-alive" +} diff --git a/docs/conf.py b/docs/conf.py index 7a0fe1f..4ae4b68 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,12 +17,12 @@ # -- Project information ----------------------------------------------------- -project = 'Venmo' -copyright = '2020, Mark Mohades' -author = 'Mark Mohades' +project = "Venmo" +copyright = "2020, Mark Mohades" +author = "Mark Mohades" # The full version, including alpha/beta/rc tags -release = '0.1.0' +release = "1.0.0" # -- General configuration --------------------------------------------------- @@ -30,16 +30,15 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = [ -] +extensions = [] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] -# List of patterns, relative to source directory, that match files and +# list of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # -- Options for HTML output ------------------------------------------------- @@ -47,10 +46,10 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'alabaster' +html_theme = "alabaster" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] -master_doc = 'index' +html_static_path = ["_static"] +master_doc = "index" diff --git a/docs/index.rst b/docs/index.rst index 398278d..5e219c6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -59,14 +59,6 @@ The following is an example of initializing and working with the api client. for user in users: print(user.username) - # Or pass a callback to make it multi-threaded - def callback(users): - for user in users: - print(user.username) - - client.user.search_for_users(query="peter", - callback=callback, - limit=10) Revoke token """""""""""" @@ -111,13 +103,8 @@ Getting a user's transactions (only the ones that are visible to you, e.g, their .. code-block:: python - def callback(transactions_list): - for transaction in transactions_list: - print(transaction) - - # callback is optional. Max number of transactions per request is 50. - client.user.get_user_transactions(user_id='0000000000000000000', - callback=callback) + # Max number of transactions per request is 50. + client.user.get_user_transactions(user_id='0000000000000000000') Friends list """""""""""" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3f4dfcd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,48 @@ +[project] +name = "venmo-api" +version = "1.0.0" +description = "Venmo API client for Python" +readme = "README.md" +authors = [ + { name = "Mark Mohades"}, + { name = "Joseph Charles"}, + { name = "Josh Hubert", email= "102703352+joshhubert-dsp@users.noreply.github.com" } +] +maintainers = [ + { name = "Josh Hubert", email= "102703352+joshhubert-dsp@users.noreply.github.com" } +] +license = "GPL-3.0-only" +license-files = [ + "LICENSE" +] +classifiers = [ + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'Operating System :: OS Independent', + 'Natural Language :: English', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: Internet', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13' +] +requires-python = ">=3.11,<3.14" +dependencies = [ + "devtools>=0.12.2", + "orjson>=3.11.3", + "pydantic>=2.12.4", + "requests>=2.32.5", + "rich>=14.2.0", +] + +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +package-dir = { "" = "." } + +[tool.setuptools.packages.find] +where = ["."] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 1ea19ae..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -requests>=2.19.0 \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index d0044ea..0000000 --- a/setup.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python - -"""The setup script.""" -from setuptools import setup, find_packages - -with open('README.md', encoding = "utf-8") as readme_file: - readme = readme_file.read() - - -def requirements(): - """Build the requirements list for this project""" - requirements_list = [] - - with open('requirements.txt', encoding = "utf-8") as requirements: - for install in requirements: - requirements_list.append(install.strip()) - - return requirements_list - - -requirements = requirements() - -setup( - name='venmo-api', - version='0.3.1', - author="Mark Mohades", - license="GNU General Public License v3", - url='https://github.com/mmohades/venmo', - keywords='Python Venmo API wrapper', - description="Venmo API client for Python", - long_description=readme, - long_description_content_type="text/markdown", - packages=find_packages(), - install_requires=requirements, - python_requires='>=3.6', - include_package_data=True, - classifiers=[ - 'Development Status :: 3 - Alpha', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', - 'Operating System :: OS Independent', - 'Natural Language :: English', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: Internet', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - ] -) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..e8da9f8 --- /dev/null +++ b/uv.lock @@ -0,0 +1,362 @@ +version = 1 +revision = 3 +requires-python = ">=3.11, <3.14" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "asttokens" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/1d/f03bcb60c4a3212e15f99a56085d93093a497718adf828d050b9d675da81/asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0", size = 62284, upload-time = "2023-10-26T10:03:05.06Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/86/4736ac618d82a20d87d2f92ae19441ebc7ac9e7a581d7e58bbe79233b24a/asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24", size = 27764, upload-time = "2023-10-26T10:03:01.789Z" }, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, + { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, + { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, + { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, + { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, + { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + +[[package]] +name = "devtools" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/75/b78198620640d394bc435c17bb49db18419afdd6cfa3ed8bcfe14034ec80/devtools-0.12.2.tar.gz", hash = "sha256:efceab184cb35e3a11fa8e602cc4fadacaa2e859e920fc6f87bf130b69885507", size = 75005, upload-time = "2023-09-03T16:57:00.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/ae/afb1487556e2dc827a17097aac8158a25b433a345386f0e249f6d2694ccb/devtools-0.12.2-py3-none-any.whl", hash = "sha256:c366e3de1df4cdd635f1ad8cbcd3af01a384d7abda71900e68d43b04eb6aaca7", size = 19411, upload-time = "2023-09-03T16:56:59.049Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/4d/8df5f83256a809c22c4d6792ce8d43bb503be0fb7a8e4da9025754b09658/orjson-3.11.3.tar.gz", hash = "sha256:1c0603b1d2ffcd43a411d64797a19556ef76958aef1c182f22dc30860152a98a", size = 5482394, upload-time = "2025-08-26T17:46:43.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/8b/360674cd817faef32e49276187922a946468579fcaf37afdfb6c07046e92/orjson-3.11.3-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9d2ae0cc6aeb669633e0124531f342a17d8e97ea999e42f12a5ad4adaa304c5f", size = 238238, upload-time = "2025-08-26T17:44:54.214Z" }, + { url = "https://files.pythonhosted.org/packages/05/3d/5fa9ea4b34c1a13be7d9046ba98d06e6feb1d8853718992954ab59d16625/orjson-3.11.3-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:ba21dbb2493e9c653eaffdc38819b004b7b1b246fb77bfc93dc016fe664eac91", size = 127713, upload-time = "2025-08-26T17:44:55.596Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5f/e18367823925e00b1feec867ff5f040055892fc474bf5f7875649ecfa586/orjson-3.11.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00f1a271e56d511d1569937c0447d7dce5a99a33ea0dec76673706360a051904", size = 123241, upload-time = "2025-08-26T17:44:57.185Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bd/3c66b91c4564759cf9f473251ac1650e446c7ba92a7c0f9f56ed54f9f0e6/orjson-3.11.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b67e71e47caa6680d1b6f075a396d04fa6ca8ca09aafb428731da9b3ea32a5a6", size = 127895, upload-time = "2025-08-26T17:44:58.349Z" }, + { url = "https://files.pythonhosted.org/packages/82/b5/dc8dcd609db4766e2967a85f63296c59d4722b39503e5b0bf7fd340d387f/orjson-3.11.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7d012ebddffcce8c85734a6d9e5f08180cd3857c5f5a3ac70185b43775d043d", size = 130303, upload-time = "2025-08-26T17:44:59.491Z" }, + { url = "https://files.pythonhosted.org/packages/48/c2/d58ec5fd1270b2aa44c862171891adc2e1241bd7dab26c8f46eb97c6c6f1/orjson-3.11.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd759f75d6b8d1b62012b7f5ef9461d03c804f94d539a5515b454ba3a6588038", size = 132366, upload-time = "2025-08-26T17:45:00.654Z" }, + { url = "https://files.pythonhosted.org/packages/73/87/0ef7e22eb8dd1ef940bfe3b9e441db519e692d62ed1aae365406a16d23d0/orjson-3.11.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6890ace0809627b0dff19cfad92d69d0fa3f089d3e359a2a532507bb6ba34efb", size = 135180, upload-time = "2025-08-26T17:45:02.424Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6a/e5bf7b70883f374710ad74faf99bacfc4b5b5a7797c1d5e130350e0e28a3/orjson-3.11.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9d4a5e041ae435b815e568537755773d05dac031fee6a57b4ba70897a44d9d2", size = 132741, upload-time = "2025-08-26T17:45:03.663Z" }, + { url = "https://files.pythonhosted.org/packages/bd/0c/4577fd860b6386ffaa56440e792af01c7882b56d2766f55384b5b0e9d39b/orjson-3.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d68bf97a771836687107abfca089743885fb664b90138d8761cce61d5625d55", size = 131104, upload-time = "2025-08-26T17:45:04.939Z" }, + { url = "https://files.pythonhosted.org/packages/66/4b/83e92b2d67e86d1c33f2ea9411742a714a26de63641b082bdbf3d8e481af/orjson-3.11.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:bfc27516ec46f4520b18ef645864cee168d2a027dbf32c5537cb1f3e3c22dac1", size = 403887, upload-time = "2025-08-26T17:45:06.228Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e5/9eea6a14e9b5ceb4a271a1fd2e1dec5f2f686755c0fab6673dc6ff3433f4/orjson-3.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f66b001332a017d7945e177e282a40b6997056394e3ed7ddb41fb1813b83e824", size = 145855, upload-time = "2025-08-26T17:45:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/45/78/8d4f5ad0c80ba9bf8ac4d0fc71f93a7d0dc0844989e645e2074af376c307/orjson-3.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:212e67806525d2561efbfe9e799633b17eb668b8964abed6b5319b2f1cfbae1f", size = 135361, upload-time = "2025-08-26T17:45:09.625Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5f/16386970370178d7a9b438517ea3d704efcf163d286422bae3b37b88dbb5/orjson-3.11.3-cp311-cp311-win32.whl", hash = "sha256:6e8e0c3b85575a32f2ffa59de455f85ce002b8bdc0662d6b9c2ed6d80ab5d204", size = 136190, upload-time = "2025-08-26T17:45:10.962Z" }, + { url = "https://files.pythonhosted.org/packages/09/60/db16c6f7a41dd8ac9fb651f66701ff2aeb499ad9ebc15853a26c7c152448/orjson-3.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:6be2f1b5d3dc99a5ce5ce162fc741c22ba9f3443d3dd586e6a1211b7bc87bc7b", size = 131389, upload-time = "2025-08-26T17:45:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2a/bb811ad336667041dea9b8565c7c9faf2f59b47eb5ab680315eea612ef2e/orjson-3.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:fafb1a99d740523d964b15c8db4eabbfc86ff29f84898262bf6e3e4c9e97e43e", size = 126120, upload-time = "2025-08-26T17:45:13.515Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b0/a7edab2a00cdcb2688e1c943401cb3236323e7bfd2839815c6131a3742f4/orjson-3.11.3-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:8c752089db84333e36d754c4baf19c0e1437012242048439c7e80eb0e6426e3b", size = 238259, upload-time = "2025-08-26T17:45:15.093Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c6/ff4865a9cc398a07a83342713b5932e4dc3cb4bf4bc04e8f83dedfc0d736/orjson-3.11.3-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:9b8761b6cf04a856eb544acdd82fc594b978f12ac3602d6374a7edb9d86fd2c2", size = 127633, upload-time = "2025-08-26T17:45:16.417Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e6/e00bea2d9472f44fe8794f523e548ce0ad51eb9693cf538a753a27b8bda4/orjson-3.11.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b13974dc8ac6ba22feaa867fc19135a3e01a134b4f7c9c28162fed4d615008a", size = 123061, upload-time = "2025-08-26T17:45:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/54/31/9fbb78b8e1eb3ac605467cb846e1c08d0588506028b37f4ee21f978a51d4/orjson-3.11.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f83abab5bacb76d9c821fd5c07728ff224ed0e52d7a71b7b3de822f3df04e15c", size = 127956, upload-time = "2025-08-26T17:45:19.172Z" }, + { url = "https://files.pythonhosted.org/packages/36/88/b0604c22af1eed9f98d709a96302006915cfd724a7ebd27d6dd11c22d80b/orjson-3.11.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6fbaf48a744b94091a56c62897b27c31ee2da93d826aa5b207131a1e13d4064", size = 130790, upload-time = "2025-08-26T17:45:20.586Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9d/1c1238ae9fffbfed51ba1e507731b3faaf6b846126a47e9649222b0fd06f/orjson-3.11.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc779b4f4bba2847d0d2940081a7b6f7b5877e05408ffbb74fa1faf4a136c424", size = 132385, upload-time = "2025-08-26T17:45:22.036Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b5/c06f1b090a1c875f337e21dd71943bc9d84087f7cdf8c6e9086902c34e42/orjson-3.11.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd4b909ce4c50faa2192da6bb684d9848d4510b736b0611b6ab4020ea6fd2d23", size = 135305, upload-time = "2025-08-26T17:45:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/a0/26/5f028c7d81ad2ebbf84414ba6d6c9cac03f22f5cd0d01eb40fb2d6a06b07/orjson-3.11.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:524b765ad888dc5518bbce12c77c2e83dee1ed6b0992c1790cc5fb49bb4b6667", size = 132875, upload-time = "2025-08-26T17:45:25.182Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d4/b8df70d9cfb56e385bf39b4e915298f9ae6c61454c8154a0f5fd7efcd42e/orjson-3.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:84fd82870b97ae3cdcea9d8746e592b6d40e1e4d4527835fc520c588d2ded04f", size = 130940, upload-time = "2025-08-26T17:45:27.209Z" }, + { url = "https://files.pythonhosted.org/packages/da/5e/afe6a052ebc1a4741c792dd96e9f65bf3939d2094e8b356503b68d48f9f5/orjson-3.11.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:fbecb9709111be913ae6879b07bafd4b0785b44c1eb5cac8ac76da048b3885a1", size = 403852, upload-time = "2025-08-26T17:45:28.478Z" }, + { url = "https://files.pythonhosted.org/packages/f8/90/7bbabafeb2ce65915e9247f14a56b29c9334003536009ef5b122783fe67e/orjson-3.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9dba358d55aee552bd868de348f4736ca5a4086d9a62e2bfbbeeb5629fe8b0cc", size = 146293, upload-time = "2025-08-26T17:45:29.86Z" }, + { url = "https://files.pythonhosted.org/packages/27/b3/2d703946447da8b093350570644a663df69448c9d9330e5f1d9cce997f20/orjson-3.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eabcf2e84f1d7105f84580e03012270c7e97ecb1fb1618bda395061b2a84a049", size = 135470, upload-time = "2025-08-26T17:45:31.243Z" }, + { url = "https://files.pythonhosted.org/packages/38/70/b14dcfae7aff0e379b0119c8a812f8396678919c431efccc8e8a0263e4d9/orjson-3.11.3-cp312-cp312-win32.whl", hash = "sha256:3782d2c60b8116772aea8d9b7905221437fdf53e7277282e8d8b07c220f96cca", size = 136248, upload-time = "2025-08-26T17:45:32.567Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/9e3127d65de7fff243f7f3e53f59a531bf6bb295ebe5db024c2503cc0726/orjson-3.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:79b44319268af2eaa3e315b92298de9a0067ade6e6003ddaef72f8e0bedb94f1", size = 131437, upload-time = "2025-08-26T17:45:34.949Z" }, + { url = "https://files.pythonhosted.org/packages/51/92/a946e737d4d8a7fd84a606aba96220043dcc7d6988b9e7551f7f6d5ba5ad/orjson-3.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:0e92a4e83341ef79d835ca21b8bd13e27c859e4e9e4d7b63defc6e58462a3710", size = 125978, upload-time = "2025-08-26T17:45:36.422Z" }, + { url = "https://files.pythonhosted.org/packages/fc/79/8932b27293ad35919571f77cb3693b5906cf14f206ef17546052a241fdf6/orjson-3.11.3-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:af40c6612fd2a4b00de648aa26d18186cd1322330bd3a3cc52f87c699e995810", size = 238127, upload-time = "2025-08-26T17:45:38.146Z" }, + { url = "https://files.pythonhosted.org/packages/1c/82/cb93cd8cf132cd7643b30b6c5a56a26c4e780c7a145db6f83de977b540ce/orjson-3.11.3-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:9f1587f26c235894c09e8b5b7636a38091a9e6e7fe4531937534749c04face43", size = 127494, upload-time = "2025-08-26T17:45:39.57Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b8/2d9eb181a9b6bb71463a78882bcac1027fd29cf62c38a40cc02fc11d3495/orjson-3.11.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61dcdad16da5bb486d7227a37a2e789c429397793a6955227cedbd7252eb5a27", size = 123017, upload-time = "2025-08-26T17:45:40.876Z" }, + { url = "https://files.pythonhosted.org/packages/b4/14/a0e971e72d03b509190232356d54c0f34507a05050bd026b8db2bf2c192c/orjson-3.11.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11c6d71478e2cbea0a709e8a06365fa63da81da6498a53e4c4f065881d21ae8f", size = 127898, upload-time = "2025-08-26T17:45:42.188Z" }, + { url = "https://files.pythonhosted.org/packages/8e/af/dc74536722b03d65e17042cc30ae586161093e5b1f29bccda24765a6ae47/orjson-3.11.3-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff94112e0098470b665cb0ed06efb187154b63649403b8d5e9aedeb482b4548c", size = 130742, upload-time = "2025-08-26T17:45:43.511Z" }, + { url = "https://files.pythonhosted.org/packages/62/e6/7a3b63b6677bce089fe939353cda24a7679825c43a24e49f757805fc0d8a/orjson-3.11.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae8b756575aaa2a855a75192f356bbda11a89169830e1439cfb1a3e1a6dde7be", size = 132377, upload-time = "2025-08-26T17:45:45.525Z" }, + { url = "https://files.pythonhosted.org/packages/fc/cd/ce2ab93e2e7eaf518f0fd15e3068b8c43216c8a44ed82ac2b79ce5cef72d/orjson-3.11.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9416cc19a349c167ef76135b2fe40d03cea93680428efee8771f3e9fb66079d", size = 135313, upload-time = "2025-08-26T17:45:46.821Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b4/f98355eff0bd1a38454209bbc73372ce351ba29933cb3e2eba16c04b9448/orjson-3.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b822caf5b9752bc6f246eb08124c3d12bf2175b66ab74bac2ef3bbf9221ce1b2", size = 132908, upload-time = "2025-08-26T17:45:48.126Z" }, + { url = "https://files.pythonhosted.org/packages/eb/92/8f5182d7bc2a1bed46ed960b61a39af8389f0ad476120cd99e67182bfb6d/orjson-3.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:414f71e3bdd5573893bf5ecdf35c32b213ed20aa15536fe2f588f946c318824f", size = 130905, upload-time = "2025-08-26T17:45:49.414Z" }, + { url = "https://files.pythonhosted.org/packages/1a/60/c41ca753ce9ffe3d0f67b9b4c093bdd6e5fdb1bc53064f992f66bb99954d/orjson-3.11.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:828e3149ad8815dc14468f36ab2a4b819237c155ee1370341b91ea4c8672d2ee", size = 403812, upload-time = "2025-08-26T17:45:51.085Z" }, + { url = "https://files.pythonhosted.org/packages/dd/13/e4a4f16d71ce1868860db59092e78782c67082a8f1dc06a3788aef2b41bc/orjson-3.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac9e05f25627ffc714c21f8dfe3a579445a5c392a9c8ae7ba1d0e9fb5333f56e", size = 146277, upload-time = "2025-08-26T17:45:52.851Z" }, + { url = "https://files.pythonhosted.org/packages/8d/8b/bafb7f0afef9344754a3a0597a12442f1b85a048b82108ef2c956f53babd/orjson-3.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e44fbe4000bd321d9f3b648ae46e0196d21577cf66ae684a96ff90b1f7c93633", size = 135418, upload-time = "2025-08-26T17:45:54.806Z" }, + { url = "https://files.pythonhosted.org/packages/60/d4/bae8e4f26afb2c23bea69d2f6d566132584d1c3a5fe89ee8c17b718cab67/orjson-3.11.3-cp313-cp313-win32.whl", hash = "sha256:2039b7847ba3eec1f5886e75e6763a16e18c68a63efc4b029ddf994821e2e66b", size = 136216, upload-time = "2025-08-26T17:45:57.182Z" }, + { url = "https://files.pythonhosted.org/packages/88/76/224985d9f127e121c8cad882cea55f0ebe39f97925de040b75ccd4b33999/orjson-3.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:29be5ac4164aa8bdcba5fa0700a3c9c316b411d8ed9d39ef8a882541bd452fae", size = 131362, upload-time = "2025-08-26T17:45:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cf/0dce7a0be94bd36d1346be5067ed65ded6adb795fdbe3abd234c8d576d01/orjson-3.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:18bd1435cb1f2857ceb59cfb7de6f92593ef7b831ccd1b9bfb28ca530e539dce", size = 125989, upload-time = "2025-08-26T17:45:59.95Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "venmo-api" +version = "1.0.0" +source = { editable = "." } +dependencies = [ + { name = "devtools" }, + { name = "orjson" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "rich" }, +] + +[package.metadata] +requires-dist = [ + { name = "devtools", specifier = ">=0.12.2" }, + { name = "orjson", specifier = ">=3.11.3" }, + { name = "pydantic", specifier = ">=2.12.4" }, + { name = "requests", specifier = ">=2.32.5" }, + { name = "rich", specifier = ">=14.2.0" }, +] diff --git a/venmo_api/__init__.py b/venmo_api/__init__.py index af9b0f1..05b7078 100644 --- a/venmo_api/__init__.py +++ b/venmo_api/__init__.py @@ -1,29 +1,31 @@ -from .utils.model_util import (string_to_timestamp, get_phone_model_from_json, random_device_id) -from .models.exception import * -from .models.base_model import BaseModel -from .models.json_schema import JSONSchema -from .models.user import User -from .models.mention import Mention -from .models.comment import Comment -from .models.transaction import Transaction -from .models.payment import Payment, PaymentStatus -from .models.payment_method import (PaymentMethod, PaymentRole, PaymentPrivacy) +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parents[1] + +# ruff: noqa: I001 +from .models.user import PaymentPrivacy, User +from .models.transaction import Comment, Mention, Transaction +from .models.payment import Payment +from .models.payment import PaymentMethod from .models.page import Page -from .utils.api_util import (deserialize, wrap_callback, warn, get_user_id, confirm, validate_access_token) -from .utils.api_client import ApiClient +from .apis.api_client import ApiClient from .apis.auth_api import AuthenticationApi from .apis.payment_api import PaymentApi from .apis.user_api import UserApi from .venmo import Client -__all__ = ["AuthenticationFailedError", "InvalidArgumentError", "InvalidHttpMethodError", "ArgumentMissingError", - "JSONDecodeError", "ResourceNotFoundError", "HttpCodeError", "NoPaymentMethodFoundError", - "NoPendingPaymentToUpdateError", "AlreadyRemindedPaymentError", "NotEnoughBalanceError", - "GeneralPaymentError", - "get_phone_model_from_json", "random_device_id", "string_to_timestamp", - "deserialize", "wrap_callback", "warn", "confirm", "get_user_id", "validate_access_token", - "JSONSchema", "User", "Mention", "Comment", "Transaction", "Payment", "PaymentStatus", "PaymentMethod", - "PaymentRole", "Page", "BaseModel", - "PaymentPrivacy", "ApiClient", "AuthenticationApi", "UserApi", "PaymentApi", - "Client" - ] +__all__ = [ + "User", + "Mention", + "Comment", + "Transaction", + "Payment", + "PaymentMethod", + "Page", + "PaymentPrivacy", + "ApiClient", + "AuthenticationApi", + "UserApi", + "PaymentApi", + "Client", +] diff --git a/venmo_api/apis/api_client.py b/venmo_api/apis/api_client.py new file mode 100644 index 0000000..f7dc568 --- /dev/null +++ b/venmo_api/apis/api_client.py @@ -0,0 +1,144 @@ +import os +import uuid +from json import JSONDecodeError +from random import getrandbits + +import orjson +import requests + +from venmo_api import PROJECT_ROOT +from venmo_api.apis.api_util import ValidatedResponse +from venmo_api.apis.exception import ( + HttpCodeError, + InvalidHttpMethodError, + ResourceNotFoundError, +) +from venmo_api.apis.logging_session import LoggingSession + + +def random_device_id() -> str: + """ + Generate a random device id that can be used for logging in. + NOTE: As of late 2025, they seem to have tightened security around device-ids, so + that randomly generated ones aren't accepted. + """ + return str(uuid.uuid4()).upper() + + +class ApiClient: + """ + Generic API Client for the Venmo API + + Args: + access_token (str | None, optional): access token you received for your + account, not including the 'Bearer ' prefix (that's added to the request + header). Defaults to None. None is only valid on initial authorization. + device_id (str | None, optional): unique device ID. Defaults to None, in + which case a random one is generated. FYI I don't think random ids work + anymore. + """ + + def __init__(self, access_token: str | None = None, device_id: str | None = None): + self.default_headers = orjson.loads( + (PROJECT_ROOT / "default_headers.json").read_bytes() + ) + if os.getenv("LOGGING_SESSION"): + self.session = LoggingSession() + else: + self.session = requests.Session() + self.session.headers.update(self.default_headers) + + self.access_token = access_token + if access_token: + self.update_access_token(access_token) + + if not device_id: + device_id = random_device_id() + + self.update_device_id(device_id) + + self.update_session_id() + self.configuration = {"host": "https://api.venmo.com/v1"} + + def update_session_id(self): + self._session_id = str(getrandbits(64)) + self.default_headers.update({"X-Session-ID": self._session_id}) + self.session.headers.update({"X-Session-ID": self._session_id}) + + def update_access_token(self, access_token: str): + self.access_token = access_token + self.default_headers.update({"Authorization": "Bearer " + self.access_token}) + self.session.headers.update({"Authorization": "Bearer " + self.access_token}) + + def update_device_id(self, device_id: str): + self.device_id = device_id + self.default_headers.update({"device-id": self.device_id}) + self.session.headers.update({"device-id": self.device_id}) + + def call_api( + self, + resource_path: str, + method: str, + headers: dict = None, + params: dict = None, + body: dict = None, + ok_error_codes: list[int] = None, + ) -> ValidatedResponse: + """Calls API on the provided path + + Args: + resource_path (str): Specific Venmo API path endpoint. + method (str): HTTP request method + headers (dict, optional): request headers. Defaults to None, in which + case the default ones in `default_headers.json` are used. + query_params (dict, optional): endpoint query parameters. Defaults to None. + body (dict, optional): JSON payload to send if request is POST/PUT. Defaults + to None. + ok_error_codes (list[int], optional): Expected integer error codes that will be + handled by calling code and which shouldn't raise. Defaults to None. + + Returns: + ValidatedResponse + """ + + # Update the header with the required values + headers = headers or {} + + if body: # POST or PUT + headers.update({"Content-Type": "application/json; charset=utf-8"}) + url = self.configuration["host"] + resource_path + + if method not in ["POST", "PUT", "GET", "DELETE"]: + raise InvalidHttpMethodError() + + response = self.session.request( + method=method, url=url, headers=headers, params=params, json=body + ) + validated_response = self._validate_response(response, ok_error_codes) + return validated_response + + @staticmethod + def _validate_response( + response: requests.Response, ok_error_codes: list[int] = None + ) -> ValidatedResponse: + """ + Validate and build a new validated response. + """ + headers = response.headers + try: + body = response.json() + except JSONDecodeError: + body = {} + + built_response = ValidatedResponse(response.status_code, headers, body) + + if response.status_code in range(200, 205) or ( + body and ok_error_codes and body.get("error").get("code") in ok_error_codes + ): + return built_response + + elif response.status_code == 400 and body.get("error").get("code") == 283: + raise ResourceNotFoundError() + + else: + raise HttpCodeError(response=response) diff --git a/venmo_api/apis/api_util.py b/venmo_api/apis/api_util.py new file mode 100644 index 0000000..47463d1 --- /dev/null +++ b/venmo_api/apis/api_util.py @@ -0,0 +1,101 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from pydantic import BaseModel +from requests.structures import CaseInsensitiveDict + +from venmo_api.models.page import Page + + +@dataclass(frozen=True, slots=True) +class ValidatedResponse: + status_code: int + headers: CaseInsensitiveDict + body: dict + + +def deserialize( + response: ValidatedResponse, + data_type: type[BaseModel | Any], + nested_response: list[str] | None = None, +) -> Any | Page[Any]: + """Extract one or a list of Objects from the api_client structured response. + + Args: + response (ValidatedResponse): validated response. + data_type (type[BaseModel | Any]): if data of interest is a json object, + should be a pydantic BaseModel subclass. Otherwise can be a primitive class. + nested_response (list[str] | None, optional): _description_. Defaults to None. + + Returns: + Any | Page[Any]: a single or a of objects (Objects can be + User/Transaction/Payment/PaymentMethod) + """ + body = response.body + if not body: + raise Exception("Can't get an empty response body.") + + data = body.get("data") + nested_response = nested_response or [] + for nested in nested_response: + temp = data.get(nested) + if not temp: + raise ValueError(f"Couldn't find {nested} in the {data}.") + data = temp + + # Return a list of data_type + if isinstance(data, list): + return __get_objs_from_json_list(json_list=data, data_type=data_type) + + if issubclass(data_type, BaseModel): + return data_type.model_validate(data) + else: # probably a primitive + return data_type(data) + + +def __get_objs_from_json_list( + json_list: list[Any], data_type: type[BaseModel | Any] +) -> Page[Any]: + """Process response JSON for a data list. + + Args: + json_list (list[Any]): a list of objs + data_type (type[BaseModel | Any]): User/Transaction/Payment/PaymentMethod + + Returns: + Page[Any]: list subclass container that can get its own next page. + """ + result = Page() + for elem in json_list: + if issubclass(data_type, BaseModel): + result.append(data_type.model_validate(elem)) + else: # probably a primitive + result.append(data_type(elem)) + + return result + + +class Colors(Enum): + HEADER = "\033[95m" + OKBLUE = "\033[94m" + OKGREEN = "\033[92m" + WARNING = "\033[93m" + FAIL = "\033[91m" + ENDC = "\033[0m" + BOLD = "\033[1m" + UNDERLINE = "\033[4m" + + +def warn(message): + """ + print message in Red Color + """ + print(Colors.WARNING.value + message + Colors.ENDC.value) + + +def confirm(message): + """ + print message in Blue Color + """ + print(Colors.OKBLUE.value + message + Colors.ENDC.value) diff --git a/venmo_api/apis/auth_api.py b/venmo_api/apis/auth_api.py index 22fa22b..961f31b 100644 --- a/venmo_api/apis/auth_api.py +++ b/venmo_api/apis/auth_api.py @@ -1,180 +1,195 @@ -from venmo_api import random_device_id, warn, confirm, AuthenticationFailedError, ApiClient +from venmo_api.apis.api_client import ApiClient +from venmo_api.apis.api_util import ValidatedResponse, confirm, warn +from venmo_api.apis.exception import AuthenticationFailedError +# NOTE: ApiClient owns device-id now -class AuthenticationApi(object): - TWO_FACTOR_ERROR_CODE = 81109 +class AuthenticationApi: + """Auth API for logging in/out of your account. + + Args: + api_client (ApiClient): Pre-initialized ApiClient that holds device-id. This + instance will be logged in with the access token returned. + """ - def __init__(self, api_client: ApiClient = None, device_id: str = None): - super().__init__() + TWO_FACTOR_ERROR_CODE = 81109 - self.__device_id = device_id or random_device_id() - self.__api_client = api_client or ApiClient() + def __init__(self, api_client: ApiClient): + self._api_client = api_client def login_with_credentials_cli(self, username: str, password: str) -> str: - """ - Pass your username and password to get an access_token for using the API. - :param username: Phone, email or username - :param password: Your account password to login - :return: - """ + """Pass your username and password to get an access_token for using the API. - # Give warnings to the user about device-id and token expiration - warn("IMPORTANT: Take a note of your device-id to avoid 2-factor-authentication for your next login.") - print(f"device-id: {self.__device_id}") - warn("IMPORTANT: Your Access Token will NEVER expire, unless you logout manually (client.log_out(token)).\n" - "Take a note of your token, so you don't have to login every time.\n") + Args: + username (str): Phone, email or username + password (str): Your account password to login + Returns: + str: access token generated for this session + """ + # Give warnings to the user about device-id and token expiration + warn( + "IMPORTANT: Take a note of your device-id to avoid 2-factor-authentication for your next login." + ) + print(f"device-id: {self.get_device_id()}") + warn( + "IMPORTANT: Your Access Token will NEVER expire, unless you logout manually (client.log_out(token)).\n" + "Take a note of your token, so you don't have to login every time.\n" + ) response = self.authenticate_using_username_password(username, password) # if two-factor error - if response.get('body').get('error'): - access_token = self.__two_factor_process_cli(response=response) + if response.body.get("error"): + access_token = self._two_factor_process_cli(response=response) self.trust_this_device() else: - access_token = response['body']['access_token'] + access_token = response.body["access_token"] confirm("Successfully logged in. Note your token and device-id") - print(f"access_token: {access_token}\n" - f"device-id: {self.__device_id}") + print(f"access_token: {access_token}\ndevice-id: {self.get_device_id()}") + self._api_client.update_access_token(access_token) return access_token + def authenticate_using_username_password( + self, username: str, password: str + ) -> ValidatedResponse: + """Authenticate with username and password. Raises exception if either are incorrect. + Check returned response: + - if it has an error (response.body.error), 2-factor is needed + - if no error, (response.body.access_token) gives you the access_token + + Args: + username (str): Phone, email or username + password (str): Your account password to login + + Returns: + ValidatedResponse: validated response containing access token + """ + body = { + "phone_email_or_username": username, + "client_id": "1", + "password": password, + } + + return self._api_client.call_api( + resource_path="/oauth/access_token", + body=body, + method="POST", + ok_error_codes=[self.TWO_FACTOR_ERROR_CODE], + ) + @staticmethod def log_out(access_token: str) -> bool: - """ - Revoke your access_token - :param access_token: - :return: - """ + """Revoke your access_token - resource_path = '/oauth/access_token' + Args: + access_token (str): token for session you want to log out of. + + Returns: + bool: True or raises exception. + """ api_client = ApiClient(access_token=access_token) + api_client.call_api(resource_path="/oauth/access_token", method="DELETE") + confirm("Successfully logged out.") + return True - api_client.call_api(resource_path=resource_path, - method='DELETE') + def _two_factor_process_cli(self, response: ValidatedResponse) -> str: + """Get response from authenticate_with_username_password for a CLI two-factor process - confirm(f"Successfully logged out.") - return True + Args: + response (ValidatedResponse): validated response - def __two_factor_process_cli(self, response: dict) -> str: - """ - Get response from authenticate_with_username_password for a CLI two-factor process - :param response: - :return: access_token - """ + Raises: + AuthenticationFailedError - otp_secret = response['headers'].get('venmo-otp-secret') + Returns: + str: access token generated for this session + """ + otp_secret = response.headers.get("venmo-otp-secret") if not otp_secret: - raise AuthenticationFailedError("Failed to get the otp-secret for the 2-factor authentication process. " - "(check your password)") + raise AuthenticationFailedError( + "Failed to get the otp-secret for the 2-factor authentication process. " + "(check your password)" + ) self.send_text_otp(otp_secret=otp_secret) - user_otp = self.__ask_user_for_otp_password() - + user_otp = self._ask_user_for_otp_password() access_token = self.authenticate_using_otp(user_otp, otp_secret) - self.__api_client.update_access_token(access_token=access_token) + self._api_client.update_access_token(access_token) return access_token - def authenticate_using_username_password(self, username: str, password: str) -> dict: - """ - Authenticate with username and password. Raises exception if either be incorrect. - Check returned response: - if have an error (response.body.error), 2-factor is needed - if no error, (response.body.access_token) gives you the access_token - :param username: - :param password: - :return: - """ + def send_text_otp(self, otp_secret: str) -> ValidatedResponse: + """Send one-time-password to user phone-number - resource_path = '/oauth/access_token' - header_params = {'device-id': self.__device_id, - 'Content-Type': 'application/json', - 'Host': 'api.venmo.com' - } - body = {"phone_email_or_username": username, - "client_id": "1", - "password": password - } + Args: + otp_secret (str): the otp-secret from response_headers.venmo-otp-secret - return self.__api_client.call_api(resource_path=resource_path, header_params=header_params, - body=body, method='POST', ok_error_codes=[self.TWO_FACTOR_ERROR_CODE]) + Raises: + AuthenticationFailedError - def send_text_otp(self, otp_secret: str) -> dict: - """ - Send one-time-password to user phone-number - :param otp_secret: the otp-secret from response_headers.venmo-otp-secret - :return: + Returns: + ValidatedResponse: validated response """ - - resource_path = '/account/two-factor/token' - header_params = {'device-id': self.__device_id, - 'Content-Type': 'application/json', - 'venmo-otp-secret': otp_secret - } - body = {"via": "sms"} - - response = self.__api_client.call_api(resource_path=resource_path, header_params=header_params, - body=body, method='POST') - - if response['status_code'] != 200: + response = self._api_client.call_api( + resource_path="/account/two-factor/token", + headers={"venmo-otp-secret": otp_secret}, + body={"via": "sms"}, + method="POST", + ) + + if response.status_code != 200: reason = None try: - reason = response['body']['error']['message'] + reason = response.body["error"]["message"] finally: - raise AuthenticationFailedError(f"Failed to send the One-Time-Password to" - f" your phone number because: {reason}") + raise AuthenticationFailedError( + f"Failed to send the One-Time-Password to" + f" your phone number because: {reason}" + ) return response def authenticate_using_otp(self, user_otp: str, otp_secret: str) -> str: - """ - Login using one-time-password, for 2-factor process - :param user_otp: otp user received on their phone - :param otp_secret: otp_secret obtained from 2-factor process - :return: access_token - """ + """Login using one-time-password, for 2-factor process - resource_path = '/oauth/access_token' - header_params = {'device-id': self.__device_id, - 'venmo-otp': user_otp, - 'venmo-otp-secret': otp_secret - } - params = {'client_id': 1} + Args: + user_otp (str): otp user received on their phone + otp_secret (str): otp_secret obtained from 2-factor process - response = self.__api_client.call_api(resource_path=resource_path, header_params=header_params, - params=params, - method='POST') - return response['body']['access_token'] - - def trust_this_device(self, device_id=None): + Returns: + str: _description_ """ - Add device_id or self.device_id (if no device_id passed) to the trusted devices on Venmo - :return: + headers = {"venmo-otp": user_otp, "venmo-otp-secret": otp_secret} + response = self._api_client.call_api( + resource_path="/oauth/access_token", + headers=headers, + params={"client_id": 1}, + method="POST", + ) + return response.body["access_token"] + + def trust_this_device(self): """ - device_id = device_id or self.__device_id - header_params = {'device-id': device_id} - resource_path = '/users/devices' - - self.__api_client.call_api(resource_path=resource_path, - header_params=header_params, - method='POST') - - confirm(f"Successfully added your device id to the list of the trusted devices.") - print(f"Use the same device-id: {self.__device_id} next time to avoid 2-factor-auth process.") + Add current device_id to the trusted devices on Venmo + """ + self._api_client.call_api(resource_path="/users/devices", method="POST") + confirm("Successfully added your device id to the list of the trusted devices.") + print( + f"Use the same device-id: {self.get_device_id()} next time to avoid 2-factor-auth process." + ) def get_device_id(self): - return self.__device_id - - def set_access_token(self, access_token): - self.__api_client.update_access_token(access_token=access_token) + return self._api_client.device_id @staticmethod - def __ask_user_for_otp_password(): - + def _ask_user_for_otp_password(): otp = "" while len(otp) < 6 or not otp.isdigit(): - otp = input("Enter OTP that you received on your phone from Venmo: (It must be 6 digits)\n") + otp = input( + "Enter OTP that you received on your phone from Venmo: (It must be 6 digits)\n" + ) return otp diff --git a/venmo_api/models/exception.py b/venmo_api/apis/exception.py similarity index 57% rename from venmo_api/models/exception.py rename to venmo_api/apis/exception.py index a6136c2..d7e5a2a 100644 --- a/venmo_api/models/exception.py +++ b/venmo_api/apis/exception.py @@ -1,23 +1,27 @@ from json import JSONDecodeError - # ======= Authentication Exceptions ======= + class AuthenticationFailedError(Exception): """Raised when there is an invalid argument passed into a method""" def __init__(self, msg: str = None, reason: str = None): - self.msg = msg or f"Authentication failed. " + reason or "" + self.msg = msg or "Authentication failed. " + reason or "" super(AuthenticationFailedError, self).__init__(self.msg) # ======= HTTP Requests Exceptions ======= + class InvalidHttpMethodError(Exception): """HTTP Method must be POST, PUT, GET or DELETE in a string format""" def __init__(self, msg: str = None): - self.msg = msg or "Method is not valid. Method must be POST, PUT, GET or DELETE in a string format" + self.msg = ( + msg + or "Method is not valid. Method must be POST, PUT, GET or DELETE in a string format" + ) super(InvalidHttpMethodError, self).__init__(self.msg) @@ -31,9 +35,12 @@ def __init__(self, msg: str = None): class HttpCodeError(Exception): """When status code is anything except 400 and 200s""" + def __init__(self, response=None, msg: str = None): if response is None and msg is None: - raise Exception("Neither response nor message for creating HttpCodeError was passed.") + raise Exception( + "Neither response nor message for creating HttpCodeError was passed." + ) status_code = response.status_code or "NA" reason = response.reason or "Unknown reason" try: @@ -41,32 +48,18 @@ def __init__(self, response=None, msg: str = None): except JSONDecodeError: json = "Invalid Json" - self.msg = msg or f"HTTP Status code is invalid. Could not make the request because -> "\ + self.msg = ( + msg + or f"HTTP Status code is invalid. Could not make the request because -> " f"{status_code} {reason}.\nError: {json}" + ) super(HttpCodeError, self).__init__(self.msg) -# ======= Methods Exceptions ======= - -class InvalidArgumentError(Exception): - """Raised when there is an invalid argument passed into a method""" - - def __init__(self, msg: str = None, argument_name: str = None, reason=None): - self.msg = msg or f"Invalid argument {argument_name} was passed. " + (reason or "") - super(InvalidArgumentError, self).__init__(self.msg) - - -class ArgumentMissingError(Exception): - """Raised when there is an argument missing in a function""" - - def __init__(self, msg: str = None, arguments: tuple = None, reason=None): - self.msg = msg or f"One of {arguments} must be passed to this method." + (reason or "") - super(ArgumentMissingError, self).__init__(self.msg) - - # ======= Payment ======= + class NoPaymentMethodFoundError(Exception): def __init__(self, msg: str = None, reason=None): self.msg = msg or ("No eligible payment method found." + "" or reason) @@ -87,11 +80,13 @@ def __init__(self, payment_id: int, action: str): class NotEnoughBalanceError(Exception): def __init__(self, amount, target_user_id): - self.msg = f"Failed to complete transaction of ${amount} to {target_user_id}.\n" \ - f"There is not enough balance on the default payment method to complete the transaction.\n" \ - f"hint: Use other payment methods like\n" \ - f"send_money(amount, tr_note, target_user_id, funding_source_id=other_payment_id_here)\n" \ - f"or transfer money to your default payment method.\n" + self.msg = ( + f"Failed to complete transaction of ${amount} to {target_user_id}.\n" + f"There is not enough balance on the default payment method to complete the transaction.\n" + f"hint: Use other payment methods like\n" + f"send_money(amount, tr_note, target_user_id, funding_source_id=other_payment_id_here)\n" + f"or transfer money to your default payment method.\n" + ) super(NotEnoughBalanceError, self).__init__(self.msg) @@ -101,8 +96,15 @@ def __init__(self, msg): super(GeneralPaymentError, self).__init__(self.msg) -__all__ = ["AuthenticationFailedError", "InvalidArgumentError", "InvalidHttpMethodError", "ArgumentMissingError", - "JSONDecodeError", "ResourceNotFoundError", "HttpCodeError", "NoPaymentMethodFoundError", - "AlreadyRemindedPaymentError", "NoPendingPaymentToUpdateError", "NotEnoughBalanceError", - "GeneralPaymentError" - ] +__all__ = [ + "AuthenticationFailedError", + "InvalidHttpMethodError", + "JSONDecodeError", + "ResourceNotFoundError", + "HttpCodeError", + "NoPaymentMethodFoundError", + "AlreadyRemindedPaymentError", + "NoPendingPaymentToUpdateError", + "NotEnoughBalanceError", + "GeneralPaymentError", +] diff --git a/venmo_api/apis/logging_session.py b/venmo_api/apis/logging_session.py new file mode 100644 index 0000000..5ce6cd3 --- /dev/null +++ b/venmo_api/apis/logging_session.py @@ -0,0 +1,58 @@ +import orjson +from devtools import pformat +from requests import PreparedRequest, Response, Session +from rich import print + +MAX_BODY_LOG = 1024 * 100 # 100 KB limit to avoid OOM in logs; tweak as needed + + +def safe_text(b: bytes | None, fallback_repr=True) -> str: + if b is None: + return "None" + try: + text = b.decode("utf-8") + if len(text) <= MAX_BODY_LOG: + return orjson.loads(text) + else: + return text[:MAX_BODY_LOG] + "\n...TRUNCATED..." + except Exception: + if fallback_repr: + return repr(b[:MAX_BODY_LOG]) + ( + "...TRUNCATED..." if len(b) > MAX_BODY_LOG else "" + ) + return "" + + +class LoggingSession(Session): + """ + requests.Session subclass that pretty-logs its requests and responses using + rich.print + """ + + def send(self, request: PreparedRequest, **kwargs) -> Response: + print(f"\n→ REQUEST: {request.method} {request.url}") + print(f"→ Request headers: {pformat(dict(request.headers))}") + + body = request.body + if isinstance(body, str): + print(f"→ Request body (str): {pformat(safe_text(body.encode()))}") + elif isinstance(body, bytes): + print(f"→ Request body (bytes): {pformat(safe_text(body))}") + elif body is None: + print("→ Request body: None") + else: + # could be generator/iterable (multipart streaming) + print(f"→ Request body: (type={type(body).__name__}) {repr(body)}") + + resp = super().send(request, **kwargs) + + print(f"← RESPONSE: {resp.status_code} {resp.reason}") + print(f"← Response headers: {pformat(dict(resp.headers))}") + + try: + content = resp.content + print(f"← Response body: {pformat(safe_text(content))}") + except Exception as e: + print(f"← Response body: ") + + return resp diff --git a/venmo_api/apis/payment_api.py b/venmo_api/apis/payment_api.py index bf95493..156c731 100644 --- a/venmo_api/apis/payment_api.py +++ b/venmo_api/apis/payment_api.py @@ -1,269 +1,396 @@ -from venmo_api import ApiClient, Payment, ArgumentMissingError, AlreadyRemindedPaymentError, \ - NoPendingPaymentToUpdateError, NoPaymentMethodFoundError, NotEnoughBalanceError, GeneralPaymentError, \ - User, PaymentMethod, PaymentRole, PaymentPrivacy, deserialize, wrap_callback, get_user_id -from typing import List, Union - - -class PaymentApi(object): - - def __init__(self, profile, api_client: ApiClient): - super().__init__() - self.__profile = profile - self.__api_client = api_client - self.__payment_error_codes = { +import uuid +from typing import Literal + +from venmo_api.apis.api_client import ApiClient +from venmo_api.apis.api_util import ValidatedResponse, deserialize +from venmo_api.apis.exception import ( + AlreadyRemindedPaymentError, + GeneralPaymentError, + NoPaymentMethodFoundError, + NoPendingPaymentToUpdateError, + NotEnoughBalanceError, +) +from venmo_api.models.page import Page +from venmo_api.models.payment import ( + EligibilityToken, + Payment, + PaymentAction, + PaymentMethod, + PaymentMethodRole, + TransferDestination, + TransferPostResponse, +) +from venmo_api.models.user import PaymentPrivacy, User + + +class PaymentApi: + """ + API for querying and making/requesting payments. + + Args: + profile (User): User object for the current user, fetched at login. + api_client (ApiClient): Logged in client instance to use for requests. + balance (float | None, optional): User initial Venmo balance, if desired. Defaults + to None. + """ + + def __init__( + self, profile: User, api_client: ApiClient, balance: float | None = None + ): + self._profile = profile + self._balance = balance + self._api_client = api_client + self._payment_error_codes = { "already_reminded_error": 2907, "no_pending_payment_error": 2901, "no_pending_payment_error2": 2905, - "not_enough_balance_error": 13006 + "not_enough_balance_error": 13006, + "otp_step_up_required_error": 1396, } - def get_charge_payments(self, limit=100000, callback=None): - """ - Get a list of charge ongoing payments (pending request money) - :param limit: - :param callback: - :return: - """ - return self.__get_payments(action="charge", - limit=limit, - callback=callback) + def get_charge_payments(self, limit=100000) -> Page[Payment]: + """Get a list of charge ongoing payments (pending request money) - def get_pay_payments(self, limit=100000, callback=None): - """ - Get a list of pay ongoing payments (pending requested money from your profile) - :param limit: - :param callback: - :return: - """ - return self.__get_payments(action="pay", - limit=limit, - callback=callback) + Args: + limit (int, optional): Maximum number of payments to fetch. Defaults to 100000. - def remind_payment(self, payment: Payment = None, payment_id: int = None) -> bool: + Returns: + Page[Payment] """ - Send a reminder for payment/payment_id - :param payment: either payment object or payment_id must be be provided - :param payment_id: - :return: True or raises AlreadyRemindedPaymentError + return self._get_payments(action="charge", limit=limit) + + def get_pay_payments(self, limit=100000) -> Page[Payment]: + """Get a list of pay ongoing payments (pending requested money from your profile) + + Args: + limit (int, optional): Maximum number of payments to fetch. Defaults to 100000. + + Returns: + Page[Payment] """ + return self._get_payments(action="pay", limit=limit) - # if the reminder has already sent - payment_id = payment_id or payment.id - action = 'remind' + def remind_payment(self, payment_id: str) -> bool: + """Send a reminder for a payment + + Args: + payment_id (str): the uuid for the payment, as returned by Payment.id. - response = self.__update_payment(action=action, - payment_id=payment_id) + Raises: + NoPendingPaymentToUpdateError + AlreadyRemindedPaymentError + + Returns: + bool: True or raises AlreadyRemindedPaymentError + """ + action = "remind" + response = self._update_payment(action=action, payment_id=payment_id) # if the reminder has already sent - if 'error' in response.get('body'): - if response['body']['error']['code'] == self.__payment_error_codes['no_pending_payment_error2']: - raise NoPendingPaymentToUpdateError(payment_id=payment_id, - action=action) + if "error" in response.body: + if ( + response.body["error"]["code"] + == self._payment_error_codes["no_pending_payment_error2"] + ): + raise NoPendingPaymentToUpdateError(payment_id, action) raise AlreadyRemindedPaymentError(payment_id=payment_id) return True - def cancel_payment(self, payment: Payment = None, payment_id: int = None) -> bool: - """ - Cancel the payment/payment_id provided. Only applicable to payments you have access to (requested payments) - :param payment: - :param payment_id: - :return: True or raises NoPendingPaymentToCancelError - """ - # if the reminder has already sent - payment_id = payment_id or payment.id - action = 'cancel' + def cancel_payment(self, payment_id: str) -> bool: + """Cancel the payment_id provided. Only applicable to payments you have access + to (requested payments). + + Args: + payment_id (str): the uuid for the payment - response = self.__update_payment(action=action, - payment_id=payment_id) + Raises: + NoPendingPaymentToUpdateError - if 'error' in response.get('body'): - raise NoPendingPaymentToUpdateError(payment_id=payment_id, - action=action) + Returns: + bool: True or raises NoPendingPaymentToCancelError + """ + action = "cancel" + response = self._update_payment(action=action, payment_id=payment_id) + if "error" in response.body: + raise NoPendingPaymentToUpdateError(payment_id, action) return True - def get_payment_methods(self, callback=None) -> Union[List[PaymentMethod], None]: + def get_payment_methods(self) -> Page[PaymentMethod]: """ Get a list of available payment_methods - :param callback: - :return: """ + response = self._api_client.call_api( + resource_path="/payment-methods", method="GET" + ) + return deserialize(response=response, data_type=PaymentMethod) - wrapped_callback = wrap_callback(callback=callback, - data_type=PaymentMethod) - - resource_path = '/payment-methods' - response = self.__api_client.call_api(resource_path=resource_path, - method='GET', - callback=wrapped_callback) - # return the thread - if callback: - return + def send_money( + self, + amount: float, + note: str, + target_user_id: str, + funding_source_id: str = None, + privacy_setting: PaymentPrivacy = PaymentPrivacy.PRIVATE, + ) -> Payment: + """send [amount] money with [note] to the ([target_user_id] from the [funding_source_id] + If no [funding_source_id] is provided, it will find the default source_id and uses that. - return deserialize(response=response, data_type=PaymentMethod) + Args: + amount (float): Amount in US dollars, gets rounded to 2 decimals internally. + note (str): descriptive note required with payment. + target_user_id (str): uuid of recipient user, as returned by User.id. + funding_source_id (str, optional): uuid of funding source. Defaults to None. + privacy_setting (PaymentPrivacy, optional): PRIVATE/FRIENDS/PUBLIC . + Defaults to PaymentPrivacy.PRIVATE. - def send_money(self, amount: float, - note: str, - target_user_id: int = None, - funding_source_id: str = None, - target_user: User = None, - privacy_setting: PaymentPrivacy = PaymentPrivacy.PRIVATE, - callback=None) -> Union[bool, None]: + Returns: + Payment: Either the transaction was successful or an exception will rise. """ - send [amount] money with [note] to the ([target_user_id] or [target_user]) from the [funding_source_id] - If no [funding_source_id] is provided, it will find the default source_id and uses that. - :param amount: - :param note: - :param funding_source_id: Your payment_method id for this payment - :param privacy_setting: PRIVATE/FRIENDS/PUBLIC (enum) - :param target_user_id: - :param target_user: - :param callback: Passing callback will run it in a distinct thread, and returns Thread - :return: Either the transaction was successful or an exception will rise. + return self._send_or_request_money( + amount=amount, + note=note, + is_send_money=True, + funding_source_id=funding_source_id, + target_user_id=target_user_id, + privacy_setting=privacy_setting.value, + ) + + def request_money( + self, + amount: float, + note: str, + target_user_id: str, + privacy_setting: PaymentPrivacy = PaymentPrivacy.PRIVATE, + ) -> Payment: + """Request [amount] money with [note] from [target_user_id]. + + Args: + amount (float): Amount in US dollars, gets rounded to 2 decimals internally. + note (str): descriptive note required with payment. + target_user_id (str): uuid of recipient user, as returned by User.id. + privacy_setting (PaymentPrivacy, optional): PRIVATE/FRIENDS/PUBLIC . + Defaults to PaymentPrivacy.PRIVATE. + + Returns: + Payment: Either the transaction was successful or an exception will rise. """ - - return self.__send_or_request_money(amount=amount, - note=note, - is_send_money=True, - funding_source_id=funding_source_id, - privacy_setting=privacy_setting.value, - target_user_id=target_user_id, - target_user=target_user, - callback=callback) - - def request_money(self, amount: float, - note: str, - target_user_id: int = None, - privacy_setting: PaymentPrivacy = PaymentPrivacy.PRIVATE, - target_user: User = None, - callback=None) -> Union[bool, None]: + return self._send_or_request_money( + amount=amount, + note=note, + is_send_money=False, + funding_source_id=None, + target_user_id=target_user_id, + privacy_setting=privacy_setting.value, + ) + + def get_transfer_destinations( + self, trans_type: Literal["standard", "instant"] + ) -> Page[TransferDestination]: + """Get a list of available transfer destination options from your Venmo balance + for the given type. + + Args: + trans_type (Literal["standard", "instant"]): 'standard' is + the free transfer that takes longer, 'instant' is the quicker transfer + that charges a fee. + + Returns: + Page[TransferDestination]: list of eligible destinations. """ - Request [amount] money with [note] from the ([target_user_id] or [target_user]) - :param amount: amount of money to be requested - :param note: message/note of the transaction - :param privacy_setting: PRIVATE/FRIENDS/PUBLIC (enum) - :param target_user_id: the user id of the person you are asking the money from - :param target_user: The user object or user_id is required - :param callback: callback function - :return: Either the transaction was successful or an exception will rise. + response = self._api_client.call_api( + resource_path="/transfers/options", method="GET" + ) + return deserialize( + response, TransferDestination, [trans_type, "eligible_destinations"] + ) + + def initiate_transfer( + self, + destination_id: str, + amount: float | None = None, + trans_type: Literal["standard", "instant"] = "standard", + ) -> TransferPostResponse: + """Initiate a transfer from your Venmo balance. + + Args: + destination_id (str): uuid of transfer destination, as returned by + TransferDestination.id. + amount (float | None, optional): Amount in US dollars, gets rounded to 2 + decimals internally. Defaults to None, in which case the entire Venmo + balance determined at initialization is used. + trans_type (Literal["standard", "instant"], optional): + 'standard' is the free transfer that takes longer, 'instant' is the + quicker transfer that charges a fee. Defaults to "standard". + + Raises: + ValueError + + Returns: + TransferPostResponse: object signifying successful transfer with details. """ - return self.__send_or_request_money(amount=amount, - note=note, - is_send_money=False, - funding_source_id=None, - privacy_setting=privacy_setting.value, - target_user_id=target_user_id, - target_user=target_user, - callback=callback) - - def __update_payment(self, action, payment_id): + if amount is None: + if self._balance is not None: + amount = self._balance + else: + raise ValueError("must pass a transfer amount if no balance available") - if not payment_id: - raise ArgumentMissingError(arguments=('payment', 'payment_id')) - - resource_path = f'/payments/{payment_id}' + amount_cents = round(amount * 100) body = { - "action": action, + "amount": amount_cents, + "destination_id": destination_id, + "transfer_type": trans_type, + # TODO should this have a fee subtracted? don't feel like testing + "final_amount": amount_cents, } - return self.__api_client.call_api(resource_path=resource_path, - body=body, - method='PUT', - ok_error_codes=list(self.__payment_error_codes.values())[:-1]) + response = self._api_client.call_api( + resource_path="/transfers", body=body, method="POST" + ) + return deserialize(response, TransferPostResponse) - def __get_payments(self, action, limit, callback=None): + def get_default_payment_method(self) -> PaymentMethod: """ - Get a list of ongoing payments with the given action - :return: + Search in all payment_methods and find the one that has payment_role of Default """ - wrapped_callback = wrap_callback(callback=callback, - data_type=Payment) + payment_methods = self.get_payment_methods() - resource_path = '/payments' - parameters = { + for p_method in payment_methods: + if not p_method: + continue + + if p_method.peer_payment_role == PaymentMethodRole.DEFAULT: + return p_method + + raise NoPaymentMethodFoundError() + + # --- HELPERS --- + + def _get_eligibility_token( + self, + amount: float, + note: str, + target_id: str, + action: str = "pay", + country_code: str = "1", + target_type: str = "user_id", + ) -> EligibilityToken: + """Generate eligibility token which is needed in payment requests + + Args: + amount (float): Amount in US dollars, gets rounded to 2 decimals internally. + note (str): descriptive note required with payment. + target_id (str): uuid of recipient user, as returned by User.id. + action (str, optional): "pay" is currently the only valid argument observed. + Defaults to "pay". + country_code (str, optional): "1" is currently the only valid argument + observed. Defaults to "1", presumably for USA. + target_type (str, optional): "user_id" is currently the only valid argument + observed. Defaults to "user_id". + + Returns: + EligibilityToken: ephemeral token that must be passed in payment payload. + """ + body = { + "funding_source_id": "", # api leaves this blank currently "action": action, - "actor": self.__profile.id, - "limit": limit + "country_code": country_code, + "target_type": target_type, + "note": note, + "target_id": target_id, + "amount": round(amount * 100), } - response = self.__api_client.call_api(resource_path=resource_path, - params=parameters, - method='GET', - callback=wrapped_callback) - if callback: - return - + response = self._api_client.call_api( + resource_path="/protection/eligibility", body=body, method="POST" + ) + return deserialize(response=response, data_type=EligibilityToken) + + def _update_payment( + self, action: Literal["remind", "cancel"], payment_id: str + ) -> ValidatedResponse: + return self._api_client.call_api( + resource_path=f"/payments/{payment_id}", + body={"action": action}, + method="PUT", + ok_error_codes=list(self._payment_error_codes.values())[:-1], + ) + + def _get_payments(self, action: PaymentAction, limit: int) -> Page[Payment]: + """ + Helper method for getting a list of ongoing payments with the given action + """ + parameters = {"action": action, "actor": self._profile.id, "limit": limit} + # TODO other params `status: pending,held` + response = self._api_client.call_api( + resource_path="/payments", + params=parameters, + method="GET", + ) return deserialize(response=response, data_type=Payment) - def __send_or_request_money(self, amount: float, - note: str, - is_send_money, - funding_source_id: str = None, - privacy_setting: str = PaymentPrivacy.PRIVATE.value, - target_user_id: int = None, target_user: User = None, - callback=None) -> Union[bool, None]: + def _send_or_request_money( + self, + amount: float, + note: str, + is_send_money: bool, + funding_source_id: str, + target_user_id: str, + privacy_setting: PaymentPrivacy = PaymentPrivacy.PRIVATE, + eligibility_token: str | None = None, + ) -> Payment: """ - Generic method for sending and requesting money - :param amount: - :param note: - :param is_send_money: - :param funding_source_id: - :param privacy_setting: - :param target_user_id: - :param target_user: - :param callback: - :return: + Helper method for sending and requesting money. """ - target_user_id = str(get_user_id(target_user, target_user_id)) amount = abs(amount) if not is_send_money: amount = -amount body = { + "uuid": str(uuid.uuid4()), "user_id": target_user_id, "audience": privacy_setting, - "amount": amount, - "note": note + "amount": round(amount, 2), + "note": note, } if is_send_money: if not funding_source_id: funding_source_id = self.get_default_payment_method().id + if not eligibility_token: + eligibility_token = self._get_eligibility_token( + amount, note, target_user_id + ).eligibility_token + body.update({"eligibility_token": eligibility_token}) body.update({"funding_source_id": funding_source_id}) - resource_path = '/payments' - - wrapped_callback = wrap_callback(callback=callback, - data_type=None) + response = self._api_client.call_api( + resource_path="/payments", + method="POST", + body=body, + ok_error_codes=[ + self._payment_error_codes["otp_step_up_required_error"], + self._payment_error_codes["not_enough_balance_error"], + ], + ) - result = self.__api_client.call_api(resource_path=resource_path, - method='POST', - body=body, - callback=wrapped_callback) # handle 200 status code errors - error_code = result['body']['data'].get('error_code') + error_code = None + try: + error_code = response.body.get("error").get("code") + except: + pass + if error_code: - if error_code == self.__payment_error_codes['not_enough_balance_error']: + if error_code == self._payment_error_codes["otp_step_up_required_error"]: + raise RuntimeError( + "OTP step-up required for payment to go through, log out and try " + "again on an actual device." + ) + + elif error_code == self._payment_error_codes["not_enough_balance_error"]: raise NotEnoughBalanceError(amount, target_user_id) - error = result['body']['data'] + error = response.body["data"] raise GeneralPaymentError(f"{error.get('title')}\n{error.get('error_msg')}") - if callback: - return # if no exception raises, then it was successful - return True - - def get_default_payment_method(self) -> PaymentMethod: - """ - Search in all payment_methods and find the one that has payment_role of Default - :return: - """ - payment_methods = self.get_payment_methods() - - for p_method in payment_methods: - if not p_method: - continue - - if p_method.role == PaymentRole.DEFAULT: - return p_method - - raise NoPaymentMethodFoundError() + return deserialize(response, Payment, nested_response=["payment"]) diff --git a/venmo_api/apis/user_api.py b/venmo_api/apis/user_api.py index d231608..350c761 100644 --- a/venmo_api/apis/user_api.py +++ b/venmo_api/apis/user_api.py @@ -1,99 +1,116 @@ -from venmo_api import User, Page, Transaction, deserialize, wrap_callback, get_user_id -from typing import List, Union +from venmo_api.apis.api_client import ApiClient +from venmo_api.apis.api_util import ValidatedResponse, deserialize +from venmo_api.models.page import Page +from venmo_api.models.transaction import Transaction +from venmo_api.models.user import User -class UserApi(object): - def __init__(self, api_client): - super().__init__() +class UserApi: + """API for querying users and transactions. + + Args: + api_client (ApiClient): Logged in client instance to use for requests. + """ + + def __init__(self, api_client: ApiClient): self.__api_client = api_client - self.__profile = None + self._profile = None + self._balance = None - def get_my_profile(self, callback=None, force_update=False) -> Union[User, None]: - """ - Get my profile info and return as a - :return my_profile: - """ - if self.__profile and not force_update: - return self.__profile - - # Prepare the request - resource_path = '/account' - nested_response = ['user'] - wrapped_callback = wrap_callback(callback=callback, - data_type=User, - nested_response=nested_response) - # Make the request - response = self.__api_client.call_api(resource_path=resource_path, - method='GET', - callback=wrapped_callback) - # Return None if threaded - if callback: - return - - self.__profile = deserialize(response=response, data_type=User, nested_response=nested_response) - return self.__profile - - def search_for_users(self, query: str, callback=None, - offset: int = 0, limit: int = 50, username=False) -> Union[List[User], None]: + def get_my_profile(self, force_update=False) -> User: + """Get your profile info and return as a User. + + Args: + force_update (bool, optional): Whether to require fetching updated data from + API. Defaults to False. + + Returns: + User: Your profile. """ - search for [query] in users - :param query: - :param callback: - :param offset: - :param limit: - :param username: default: False; Pass True if search is by username - :return users_list: A list of objects or empty + if self._profile and not force_update: + return self._profile + + response = self.__api_client.call_api(resource_path="/account", method="GET") + self._profile = deserialize(response, User, nested_response=["user"]) + return self._profile + + def get_my_balance(self, force_update=False) -> float: + """Get your current balance info and return as a float. + + Args: + force_update (bool, optional): Whether to require fetching updated data from + API. Defaults to False. + + Returns: + float: Your balance """ + if self._balance and not force_update: + return self._balance + + response = self.__api_client.call_api(resource_path="/account", method="GET") + self._balance = deserialize(response, float, nested_response=["balance"]) + return self._balance + + # --- USERS --- - resource_path = '/users' - wrapped_callback = wrap_callback(callback=callback, - data_type=User) + def search_for_users( + self, + query: str, + offset: int = 0, + limit: int = 50, + username: bool = False, + ) -> Page[User]: + """search for [query] in users - params = {'query': query, 'limit': limit, 'offset': offset} + Args: + query (str): user search terms. + offset (int, optional): Page offset. Defaults to 0. + limit (int, optional): Maximum number of entries to return. Defaults to 50. + username (bool, optional): Pass True if search is by username. Defaults to False. + + Returns: + Page[User]: A list of User objects or empty + """ + + params = {"query": query, "limit": limit, "offset": offset} # update params for querying by username - if username or '@' in query: - params.update({'query': query.replace('@', ''), 'type': 'username'}) + if username or "@" in query: + params.update({"query": query.replace("@", ""), "type": "username"}) - response = self.__api_client.call_api(resource_path=resource_path, params=params, - method='GET', callback=wrapped_callback) - # Return None if threaded - if callback: - return + response = self.__api_client.call_api( + resource_path="/users", params=params, method="GET" + ) + return deserialize(response=response, data_type=User).set_method( + method=self.search_for_users, + kwargs={"query": query, "limit": limit}, + current_offset=offset, + ) - return deserialize(response=response, - data_type=User).set_method(method=self.search_for_users, - kwargs={"query": query, "limit": limit}, - current_offset=offset - ) + def get_user(self, user_id: str) -> User | None: + """Get the user profile with [user_id] + Args: + user_id (str): uuid for user, as returned by User.id. - def get_user(self, user_id: str, callback=None) -> Union[User, None]: - """ - Get the user profile with [user_id] - :param user_id: , example: '2859950549165568970' - :param callback: - :return user: + Returns: + User | None: the corresponding User, if any. """ + response = self.__api_client.call_api( + resource_path=f"/users/{user_id}", method="GET" + ) + try: + return deserialize(response=response, data_type=User) + except Exception: + return None - # Prepare the request - resource_path = f'/users/{user_id}' - wrapped_callback = wrap_callback(callback=callback, - data_type=User) - # Make the request - response = self.__api_client.call_api(resource_path=resource_path, - method='GET', - callback=wrapped_callback) - # Return None if threaded - if callback: - return + def get_user_by_username(self, username: str) -> User | None: + """Search for the user profile with [username] - return deserialize(response=response, data_type=User) + Args: + username (str): username of User. - def get_user_by_username(self, username: str) -> Union[User, None]: - """ - Get the user profile with [username] - :param username: - :return user: + Returns: + User | None: The corresponding User, if any. """ users = self.search_for_users(query=username, username=True) for user in users: @@ -103,113 +120,171 @@ def get_user_by_username(self, username: str) -> Union[User, None]: # username not found return None - def get_user_friends_list(self, user_id: str = None, - user: User = None, - callback=None, - offset: int = 0, - limit: int = 3337) -> Union[Page, None]: - """ - Get ([user_id]'s or [user]'s) friends list as a list of s - :return users_list: A list of objects or empty + def get_user_friends_list( + self, + user_id: str, + offset: int = 0, + limit: int = 3337, + ) -> Page[User]: + """Get [user_id]'s friends list as a list of Users + + Args: + user_id (str): uuid for user, as returned by User.id. + offset (int, optional): Page offset. Defaults to 0. + limit (int, optional): Maximum number of entries to return. Defaults to 3337. + + Returns: + Page[User]: A list of User objects or empty if no friends :( """ - user_id = get_user_id(user, user_id) params = {"limit": limit, "offset": offset} + response = self.__api_client.call_api( + resource_path=f"/users/{user_id}/friends", method="GET", params=params + ) + return deserialize(response=response, data_type=User).set_method( + method=self.get_user_friends_list, + kwargs={"user_id": user_id, "limit": limit}, + current_offset=offset, + ) - # Prepare the request - resource_path = f'/users/{user_id}/friends' - wrapped_callback = wrap_callback(callback=callback, - data_type=User) - # Make the request - response = self.__api_client.call_api(resource_path=resource_path, - method='GET', params=params, - callback=wrapped_callback) - # Return None if threaded - if callback: - return - - return deserialize( - response=response, - data_type=User).set_method(method=self.get_user_friends_list, - kwargs={"user_id": user_id, "limit": limit}, - current_offset=offset - ) - - def get_user_transactions(self, user_id: str = None, user: User = None, - callback=None, - limit: int = 50, - before_id=None) -> Union[Page, None]: - """ - Get ([user_id]'s or [user]'s) transactions visible to yourself as a list of s - :param user_id: - :param user: - :param callback: - :param limit: - :param before_id: - :return: + # --- TRANSACTIONS --- + + def get_user_transactions( + self, + user_id: str, + social_only: bool = False, + public_only: bool = True, + limit: int = 50, + before_id: str | None = None, + ) -> Page[Transaction]: + """Get [user_id]'s transactions visible to you as a list of Transactions + + Args: + user_id (str): uuid for user, as returned by User.id. + social_only (bool, optional): I think this means show only transactions + between personal accounts, not business/charity ones, but haven't + verified. Defaults to False. + public_only (bool, optional): I think this means show only transactions + the user has made public, but haven't verified. Defaults to True. + limit (int, optional): Maximum number of entries to return. Defaults to 50. + before_id (str | None, optional): Index for determining the page returned. + Defaults to None. + + Returns: + Page[Transaction]: A list of Transaction objects. """ - user_id = get_user_id(user, user_id) + response = self._get_transactions( + user_id, social_only, public_only, limit, before_id + ) + return deserialize(response, Transaction).set_method( + method=self.get_user_transactions, + kwargs={ + "user_id": user_id, + "social_only": social_only, + "public_only": public_only, + "limit": limit, + }, + ) - params = {'limit': limit} - if before_id: - params['before_id'] = before_id + def get_friends_transactions( + self, + social_only: bool = False, + public_only: bool = True, + limit: int = 50, + before_id: str | None = None, + ) -> Page[Transaction]: + """Get your friends' transactions visible to you as a list of Transactions - # Prepare the request - resource_path = f'/stories/target-or-actor/{user_id}' + Args: + social_only (bool, optional): I think this means show only transactions + between personal accounts, not business/charity ones, but haven't + verified. Defaults to False. + public_only (bool, optional): I think this means show only transactions + the user has made public, but haven't verified. Defaults to True. + limit (int, optional): Maximum number of entries to return. Defaults to 50. + before_id (str | None, optional): Index for determining the page returned. + Defaults to None. - wrapped_callback = wrap_callback(callback=callback, - data_type=Transaction) - # Make the request - response = self.__api_client.call_api(resource_path=resource_path, - method='GET', params=params, - callback=wrapped_callback) - # Return None if threaded - if callback: - return - - return deserialize(response=response, - data_type=Transaction).set_method(method=self.get_user_transactions, - kwargs={"user_id": user_id}) - - def get_transaction_between_two_users(self, user_id_one: str = None, - user_id_two: str = None, - user_one: User = None, - user_two: User = None, - callback=None, - limit: int = 50, - before_id=None) -> Union[Page, None]: + Returns: + Page[Transaction]: A list of Transaction objects. """ - Get the transactions between two users. Note that user_one must be the owner of the access token. - Otherwise it raises an unauthorized error. - :param user_id_one: - :param user_id_two: - :param user_one: - :param user_two: - :param callback: - :param limit: - :param before_id: - :return: + response = self._get_transactions( + "friends", social_only, public_only, limit, before_id + ) + return deserialize(response, Transaction).set_method( + method=self.get_friends_transactions, + kwargs={ + "social_only": social_only, + "public_only": public_only, + "limit": limit, + }, + ) + + def get_transaction_between_two_users( + self, + user_id_one: str, + user_id_two: str, + social_only: bool = False, + public_only: bool = True, + limit: int = 50, + before_id: str | None = None, + ) -> Page[Transaction] | None: + """Get the transactions between two users. Note that user_one_id must be the owner + of the access token. Otherwise it raises an unauthorized error. + + Args: + user_id_one (str): Your user uuid. + user_id_two (str): uuid of the other person + social_only (bool, optional): I think this means show only transactions + between personal accounts, not business/charity ones, but haven't + verified. Defaults to False. + public_only (bool, optional): I think this means show only transactions + the user has made public, but haven't verified. Defaults to True. + limit (int, optional): Maximum number of entries to return. Defaults to 50. + before_id (str | None, optional): Index for determining the page returned. + Defaults to None. + + Returns: + Page[Transaction]: A list of Transaction objects. """ - user_id_one = get_user_id(user_one, user_id_one) - user_id_two = get_user_id(user_two, user_id_two) + response = self._get_transactions( + f"{user_id_one}/target-or-actor/{user_id_two}", + social_only, + public_only, + limit, + before_id, + ) + return deserialize(response, Transaction).set_method( + method=self.get_transaction_between_two_users, + kwargs={ + "user_id_one": user_id_one, + "user_id_two": user_id_two, + "social_only": social_only, + "public_only": public_only, + "limit": limit, + }, + ) - params = {'limit': limit} + def _get_transactions( + self, + endpoint_suffix: str, + social_only: bool, + public_only: bool, + limit: int, + before_id: str | None, + ) -> ValidatedResponse | None: + """ """ + params = { + "limit": limit, + "social_only": str(social_only).lower(), + "only_public_stories": str(public_only).lower(), + } if before_id: - params['before_id'] = before_id - - # Prepare the request - resource_path = f'/stories/target-or-actor/{user_id_one}/target-or-actor/{user_id_two}' + params["before_id"] = before_id - wrapped_callback = wrap_callback(callback=callback, - data_type=Transaction) # Make the request - response = self.__api_client.call_api(resource_path=resource_path, - method='GET', params=params, - callback=wrapped_callback) - # Return None if threaded - if callback: - return - - return deserialize(response=response, - data_type=Transaction).set_method(method=self.get_transaction_between_two_users, - kwargs={"user_id_one": user_id_one, - "user_id_two": user_id_two}) + response = self.__api_client.call_api( + resource_path=f"/stories/target-or-actor/{endpoint_suffix}", + method="GET", + params=params, + ) + return response diff --git a/venmo_api/models/base_model.py b/venmo_api/models/base_model.py deleted file mode 100644 index 3228181..0000000 --- a/venmo_api/models/base_model.py +++ /dev/null @@ -1,13 +0,0 @@ -class BaseModel(object): - def __init__(self): - self._json = None - - def __str__(self): - return f"{type(self).__name__}:" \ - f" ({', '.join('%s=%s' % item for item in vars(self).items() if not item[0].startswith('_'))})" - - def to_json(self, original=True): - if self._json and original: - return self._json - - return dict(filter(lambda x: not x[0].startswith('_'), vars(self).items())) diff --git a/venmo_api/models/comment.py b/venmo_api/models/comment.py deleted file mode 100644 index 396cf60..0000000 --- a/venmo_api/models/comment.py +++ /dev/null @@ -1,48 +0,0 @@ -from venmo_api import string_to_timestamp, BaseModel, User, Mention, JSONSchema - - -class Comment(BaseModel): - - def __init__(self, id_, message, date_created, mentions, user, json=None): - """ - Comment model - :param id_: - :param message: - :param date_created: - :param mentions: - :param user: - :param json: - """ - super().__init__() - - self.id = id_ - self.message = message - self.user = user - - self.date_created = date_created - - self.mentions = mentions - self._json = json - - @classmethod - def from_json(cls, json): - """ - Create a new Comment from the given json. - :param json: - :return: - """ - - if not json: - return - - parser = JSONSchema.comment(json) - - mentions_list = parser.get_mentions() - mentions = [Mention.from_json(mention) for mention in mentions_list] if mentions_list else [] - - return cls(id_=parser.get_id(), - message=parser.get_message(), - date_created=string_to_timestamp(parser.get_date_created()), - mentions=mentions, - user=User.from_json(parser.get_user()), - json=json) diff --git a/venmo_api/models/json_schema.py b/venmo_api/models/json_schema.py deleted file mode 100644 index 6d3dab8..0000000 --- a/venmo_api/models/json_schema.py +++ /dev/null @@ -1,326 +0,0 @@ -class JSONSchema: - - @staticmethod - def transaction(json): - return TransactionParser(json=json) - - @staticmethod - def user(json, is_profile=None): - return UserParser(json=json, is_profile=is_profile) - - @staticmethod - def payment_method(json): - return PaymentMethodParser(json) - - @staticmethod - def payment(json): - return PaymentParser(json) - - @staticmethod - def comment(json): - return CommentParser(json) - - @staticmethod - def mention(json): - return MentionParser(json) - - -class TransactionParser: - - def __init__(self, json): - if not json: - return - - self.json = json - self.payment = json.get(transaction_json_format['payment']) - - def get_story_id(self): - return self.json.get(transaction_json_format['story_id']) - - def get_date_created(self): - return self.json.get(transaction_json_format['date_created']) - - def get_date_updated(self): - return self.json.get(transaction_json_format['date_updated']) - - def get_actor_app(self): - return self.json.get(transaction_json_format['app']) - - def get_audience(self): - return self.json.get(transaction_json_format['aud']) - - def get_likes(self): - return self.json.get(transaction_json_format['likes']) - - def get_comments(self): - comments = self.json.get(transaction_json_format['comments']) - return comments.get(transaction_json_format['comments_list']) if comments else comments - - def get_transaction_type(self): - return self.json.get(transaction_json_format['transaction_type']) - - def get_payment_id(self): - return self.payment.get(payment_json_format['payment_id']) - - def get_type(self): - return self.payment.get(payment_json_format['type']) - - def get_date_completed(self): - return self.payment.get(payment_json_format['date_completed']) - - def get_story_note(self): - return self.payment.get(payment_json_format['note']) - - def get_actor(self): - return self.payment.get(payment_json_format['actor']) - - def get_target(self): - return self.payment.get(payment_json_format['target']).get('user') - - def get_status(self): - return self.payment.get(payment_json_format['status']) - - def get_amount(self): - return self.payment.get(payment_json_format['amount']) - - -transaction_json_format = { - "story_id": "id", - "date_created": "date_created", - "date_updated": "date_updated", - "aud": "audience", - "note": "note", - "app": "app", - "payment": "payment", - "comments": "comments", - "comments_list": "data", - "likes": "likes", - "transaction_type": "type" -} -payment_json_format = { - "status": "status", - "payment_id": "id", - "date_completed": "date_completed", - "target": "target", - "actor": "actor", - "note": "note", - 'type': 'action', - 'amount': 'amount' -} - - -class UserParser: - - def __init__(self, json, is_profile=False): - - if not json: - return - - self.json = json - self.is_profile = is_profile - - if is_profile: - self.parser = profile_json_format - else: - self.parser = user_json_format - - def get_user_id(self): - return self.json.get(self.parser.get('user_id')) - - def get_username(self): - return self.json.get(self.parser.get('username')) - - def get_first_name(self): - return self.json.get(self.parser.get('first_name')) - - def get_last_name(self): - return self.json.get(self.parser.get('last_name')) - - def get_full_name(self): - return self.json.get(self.parser.get('full_name')) - - def get_phone(self): - return self.json.get(self.parser.get('phone')) - - def get_picture_url(self): - return self.json.get(self.parser.get('picture_url')) - - def get_about(self): - return self.json.get(self.parser.get('about')) - - def get_date_created(self): - return self.json.get(self.parser.get('date_created')) - - def get_is_group(self): - if self.is_profile: - return False - return self.json.get(self.parser.get('is_group')) - - def get_is_active(self): - if self.is_profile: - return False - return self.json.get(self.parser.get('is_active')) - - -user_json_format = { - 'user_id': 'id', - 'username': 'username', - 'first_name': 'first_name', - 'last_name': 'last_name', - 'full_name': 'display_name', - 'phone': 'phone', - 'picture_url': 'profile_picture_url', - 'about': 'about', - 'date_created': 'date_joined', - 'is_group': 'is_group', - 'is_active': 'is_active' -} - -profile_json_format = { - 'user_id': 'external_id', - 'username': 'username', - 'first_name': 'firstname', - 'last_name': 'lastname', - 'full_name': 'name', - 'phone': 'phone', - 'picture_url': 'picture', - 'about': 'about', - 'date_created': 'date_created', - 'is_business': 'is_business' -} - - -class PaymentMethodParser: - - def __init__(self, json): - self.json = json - - def get_id(self): - return self.json.get(payment_method_json_format['id']) - - def get_payment_method_role(self): - return self.json.get(payment_method_json_format['payment_role']) - - def get_payment_method_name(self): - return self.json.get(payment_method_json_format['name']) - - def get_payment_method_type(self): - return self.json.get(payment_method_json_format['type']) - - -payment_method_json_format = {'id': 'id', - 'payment_role': 'peer_payment_role', - 'name': 'name', - 'type': 'type' - } - - -class PaymentParser: - - def __init__(self, json): - self.json = json - - def get_id(self): - return self.json.get(payment_request_json_format['id']) - - def get_actor(self): - return self.json.get(payment_request_json_format['actor']) - - def get_target(self): - return self.json.get(payment_request_json_format['target']) \ - .get(payment_request_json_format['target_user']) - - def get_action(self): - return self.json.get(payment_request_json_format['action']) - - def get_amount(self): - return self.json.get(payment_request_json_format['amount']) - - def get_audience(self): - return self.json.get(payment_request_json_format['audience']) - - def get_date_authorized(self): - return self.json.get(payment_request_json_format['date_authorized']) - - def get_date_completed(self): - return self.json.get(payment_request_json_format['date_completed']) - - def get_date_created(self): - return self.json.get(payment_request_json_format['date_created']) - - def get_date_reminded(self): - return self.json.get(payment_request_json_format['date_reminded']) - - def get_note(self): - return self.json.get(payment_request_json_format['note']) - - def get_status(self): - return self.json.get(payment_request_json_format['status']) - - -payment_request_json_format = { - 'id': 'id', - 'actor': 'actor', - 'target': 'target', - 'target_user': 'user', - 'action': 'action', - 'amount': 'amount', - 'audience': 'audience', - 'date_authorized': 'date_authorized', - 'date_completed': 'date_completed', - 'date_created': 'date_created', - 'date_reminded': 'date_reminded', - 'note': 'note', - 'status': 'status' -} - - -class CommentParser: - - def __init__(self, json): - self.json = json - - def get_date_created(self): - return self.json.get(comment_json_format['date_created']) - - def get_message(self): - return self.json.get(comment_json_format['message']) - - def get_mentions(self): - mentions = self.json.get(comment_json_format['mentions']) - return mentions.get(comment_json_format['mentions_list']) if mentions else mentions - - def get_id(self): - return self.json.get(comment_json_format['id']) - - def get_user(self): - return self.json.get(comment_json_format['user']) - - -comment_json_format = { - "date_created": "date_created", - "message": "message", - "message_list": "data", - "mentions": "mentions", - "mentions_list": "data", - "id": "id", - "user": "user" -} - - -class MentionParser: - - def __init__(self, json): - self.json = json - - def get_username(self): - return self.json.get(mention_json_format['username']) - - def get_user(self): - return self.json.get(mention_json_format['user']) - - -mention_json_format = { - "username": "username", - "user": "user" -} diff --git a/venmo_api/models/mention.py b/venmo_api/models/mention.py deleted file mode 100644 index 71d9c2e..0000000 --- a/venmo_api/models/mention.py +++ /dev/null @@ -1,34 +0,0 @@ -from venmo_api import BaseModel, User, JSONSchema - - -class Mention(BaseModel): - - def __init__(self, username, user, json=None): - """ - Mention model - :param username: - :param user: - """ - super().__init__() - - self.username = username - self.user = user - - self._json = json - - @classmethod - def from_json(cls, json): - """ - Create a new Mention from the given json. - :param json: - :return: - """ - - if not json: - return - - parser = JSONSchema.mention(json) - - return cls(username=parser.get_username(), - user=User.from_json(parser.get_user()), - json=json) diff --git a/venmo_api/models/page.py b/venmo_api/models/page.py index 9fc714c..fab213c 100644 --- a/venmo_api/models/page.py +++ b/venmo_api/models/page.py @@ -1,4 +1,9 @@ +from collections.abc import Callable +from typing import Self + + class Page(list): + """fancy list that calls it's own next-in-line""" def __init__(self): super().__init__() @@ -6,13 +11,16 @@ def __init__(self): self.kwargs = {} self.current_offset = -1 - def set_method(self, method, kwargs, current_offset=-1): - """ - set the method and kwargs for paging. current_offset is provided for routes that require offset. - :param method: - :param kwargs: - :param current_offset: - :return: + def set_method(self, method: Callable, kwargs: dict, current_offset=-1) -> Self: + """set the method and kwargs for paging. current_offset is provided for routes that require offset. + + Args: + method (Callable): function to call to fetch next page. + kwargs (dict): function kwargs to call with. + current_offset (int, optional): Page offset. Defaults to -1. + + Returns: + Page: this object """ self.method = method self.kwargs = kwargs @@ -22,15 +30,14 @@ def set_method(self, method, kwargs, current_offset=-1): def get_next_page(self): """ Get the next page of data. Returns empty Page if none exists - :return: """ if not self.kwargs or not self.method or len(self) == 0: return self.__init__() # use offset or before_id for paging, depending on the route if self.current_offset > -1: - self.kwargs['offset'] = self.current_offset + len(self) + self.kwargs["offset"] = self.current_offset + len(self) else: - self.kwargs['before_id'] = self[-1].id + self.kwargs["before_id"] = self[-1].id return self.method(**self.kwargs) diff --git a/venmo_api/models/payment.py b/venmo_api/models/payment.py index 57f3138..e84693f 100644 --- a/venmo_api/models/payment.py +++ b/venmo_api/models/payment.py @@ -1,71 +1,125 @@ -from venmo_api import string_to_timestamp, User, BaseModel, JSONSchema -from enum import Enum +from datetime import datetime +from enum import StrEnum, auto +from typing import Annotated, Any, Literal + +from pydantic import AfterValidator, AliasPath, BaseModel, Field + +from venmo_api.models.user import PaymentPrivacy, User + +UsDollarsFloat = Annotated[float, AfterValidator(lambda v: round(v, 2))] + + +# --- ENUMS --- +class PaymentStatus(StrEnum): + SETTLED = auto() + CANCELLED = auto() + PENDING = auto() + HELD = auto() + FAILED = auto() + EXPIRED = auto() + + +class PaymentAction(StrEnum): + PAY = auto() + CHARGE = auto() + + +class PaymentMethodRole(StrEnum): + DEFAULT = auto() + BACKUP = auto() + NONE = auto() + + +class PaymentMethodType(StrEnum): + BANK = auto() + BALANCE = auto() + CARD = auto() + + +# --- MODELS --- + + +class Fee(BaseModel): + """bundled with EligilityToken and PaymentMethod responses. I don't pay fees so IDK + really what's up there.""" + + product_uri: str + applied_to: str + base_fee_amount: UsDollarsFloat + fee_percentage: float + calculated_fee_amount_in_cents: int + fee_token: str + + +class EligibilityToken(BaseModel): + """required for sending payments""" + + eligibility_token: str + eligible: bool + fees: list[Fee] + fee_disclaimer: str + ineligible_reason: str | None = Field( + None, + description="If your eligibility is denied, you'll get this cryptic string.", + ) class Payment(BaseModel): + """object returned by a successful payment/request""" + + id: str + status: PaymentStatus + action: PaymentAction + amount: UsDollarsFloat | None + date_created: datetime + audience: PaymentPrivacy | None = None + note: str + target: User = Field(validation_alias=AliasPath("target", "user")) + actor: User + date_completed: datetime | None + date_reminded: datetime | None + # TODO figure these out + refund: Any | None = None + fee: Fee | None = None + + +class PaymentMethod(BaseModel): + id: str + type: PaymentMethodType + name: str + last_four: str | None + peer_payment_role: PaymentMethodRole + merchant_payment_role: PaymentMethodRole + top_up_role: Literal["eligible", "none"] + default_transfer_destination: Literal["default", "eligible", "none"] + fee: Fee | None + # TODO maybe bank_account: BankAccount | None + # card: Card | None + # add_funds_eligible: bool, + # is_preferred_payment_method_for_add_funds: bool + + +class TransferDestination(BaseModel): + """variant of PaymentMethod specifically for transfers to/from your Venmo balance""" + + id: int + type: PaymentMethodType + name: str + last_four: str | None + is_default: bool + transfer_to_estimate: datetime + account_status: Literal["verified"] | Any + + +class TransferPostResponse(BaseModel): + """object returned by a successful transfer""" - def __init__(self, id_, actor, target, action, amount, audience, date_created, date_reminded, date_completed, - note, status, json=None): - """ - Payment model - :param id_: - :param actor: - :param target: - :param action: - :param amount: - :param audience: - :param date_created: - :param date_reminded: - :param date_completed: - :param note: - :param status: - :param json: - """ - super().__init__() - self.id = id_ - self.actor = actor - self.target = target - self.action = action - self.amount = amount - self.audience = audience - self.date_created = date_created - self.date_reminded = date_reminded - self.date_completed = date_completed - self.note = note - self.status = status - self._json = json - - @classmethod - def from_json(cls, json): - """ - init a new Payment form JSON - :param json: - :return: - """ - if not json: - return - - parser = JSONSchema.payment(json) - - return cls( - id_=parser.get_id(), - actor=User.from_json(parser.get_actor()), - target=User.from_json(parser.get_target()), - action=parser.get_action(), - amount=parser.get_amount(), - audience=parser.get_audience(), - date_created=string_to_timestamp(parser.get_date_created()), - date_reminded=string_to_timestamp(parser.get_date_reminded()), - date_completed=string_to_timestamp(parser.get_date_completed()), - note=parser.get_note(), - status=PaymentStatus(parser.get_status()), - json=json - ) - - -class PaymentStatus(Enum): - SETTLED = 'settled' - CANCELLED = 'cancelled' - PENDING = 'pending' - FAILED = 'failed' - EXPIRED = 'expired' + id: int + amount: UsDollarsFloat + amount_cents: int + amount_fee_cents: int + amount_requested_cents: int + date_requested: datetime + destination: TransferDestination + status: Literal["pending"] + type: Literal["standard", "instant"] diff --git a/venmo_api/models/payment_method.py b/venmo_api/models/payment_method.py deleted file mode 100644 index c4ad79f..0000000 --- a/venmo_api/models/payment_method.py +++ /dev/null @@ -1,73 +0,0 @@ -from venmo_api import JSONSchema, BaseModel -from typing import Dict -from enum import Enum -import logging - - -class PaymentMethod(BaseModel): - def __init__(self, pid: str, p_role: str, p_name: str, p_type: str, json=None): - """ - Payment method model (with different types like, venmo balance, bank account, ...) - :param pid: - :param p_role: - :param p_name: - :param p_type: - :param json: - """ - super().__init__() - - self.id = pid - self.role = PaymentRole(p_role) - self.name = p_name - self.type = payment_type.get(p_type) - self._json = json - - @classmethod - def from_json(cls, json: Dict): - - payment_parser = JSONSchema.payment_method(json) - - pid = payment_parser.get_id() - p_role = payment_parser.get_payment_method_role() - p_name = payment_parser.get_payment_method_name() - p_type = payment_parser.get_payment_method_type() - - # Get the class for this payment, must be either VenmoBalance or BankAccount - payment_class = payment_type.get(p_type) - if not payment_class: - logging.warning(f"Skipped a payment_method; No schema existed for the payment_method: {p_type}") - return - - return payment_class(pid=pid, - p_role=p_role, - p_name=p_name, - p_type=p_type, - json=json) - - -class VenmoBalance(PaymentMethod, BaseModel): - def __init__(self, pid, p_role, p_name, p_type, json=None): - super().__init__(pid, p_role, p_name, p_type, json) - - -class BankAccount(PaymentMethod, BaseModel): - def __init__(self, pid, p_role, p_name, p_type, json=None): - super().__init__(pid, p_role, p_name, p_type, json) - -class Card(PaymentMethod, BaseModel): - def __init__(self, pid, p_role, p_name, p_type, json=None): - super().__init__(pid, p_role, p_name, p_type, json) - -class PaymentRole(Enum): - DEFAULT = 'default' - BACKUP = 'backup' - NONE = 'none' - - -class PaymentPrivacy(Enum): - PRIVATE = 'private' - PUBLIC = 'public' - FRIENDS = 'friends' - - -payment_type = {'bank': BankAccount, 'balance': VenmoBalance, 'card': Card} diff --git a/venmo_api/models/transaction.py b/venmo_api/models/transaction.py index 08040a4..6139722 100644 --- a/venmo_api/models/transaction.py +++ b/venmo_api/models/transaction.py @@ -1,109 +1,71 @@ -from venmo_api import string_to_timestamp, BaseModel, User, Comment, get_phone_model_from_json, JSONSchema +from datetime import datetime from enum import Enum +from typing import Annotated, Literal +from pydantic import AliasPath, BaseModel, BeforeValidator, Field -class Transaction(BaseModel): +from venmo_api.models.payment import Payment +from venmo_api.models.user import PaymentPrivacy, User + +DEVICE_MAP = {1: "iPhone", 4: "Android", 10: "Desktop Browser", 0: "Other"} + + +def get_device_model_from_json(app_json: dict): + """ + extract the phone model from app_info json. + :param app_json: + :return: + """ + _id = 0 + if app_json: + _id = app_json["id"] - def __init__(self, story_id, payment_id, date_completed, date_created, - date_updated, payment_type, amount, audience, status, - note, device_used, actor, target, comments, json=None): - """ - Transaction model - :param story_id: - :param payment_id: - :param date_completed: - :param date_created: - :param date_updated: - :param payment_type: - :param amount: - :param audience: - :param status: - :param note: - :param device_used: - :param actor: - :param target: - :param comments: - :param json: - """ - super().__init__() - - self.id = story_id - self.payment_id = payment_id - - self.date_completed = date_completed - self.date_created = date_created - self.date_updated = date_updated - - self.payment_type = payment_type - self.amount = amount - self.audience = audience - self.status = status - - self.note = note - self.device_used = device_used - self.comments = comments - - self.actor = actor - self.target = target - self._json = json - - @classmethod - def from_json(cls, json): - """ - Create a new Transaction from the given json. - This only works for transactions, skipping refunds and bank transfers. - :param json: - :return: - """ - - if not json: - return - - parser = JSONSchema.transaction(json) - transaction_type = TransactionType(parser.get_transaction_type()) - - # Currently only handles Payment-type transactions - if transaction_type is not TransactionType.PAYMENT: - return - - date_created = string_to_timestamp(parser.get_date_created()) - date_updated = string_to_timestamp(parser.get_date_updated()) - date_completed = string_to_timestamp(parser.get_date_completed()) - target = User.from_json(json=parser.get_target()) - actor = User.from_json(json=parser.get_actor()) - device_used = get_phone_model_from_json(parser.get_actor_app()) - - comments_list = parser.get_comments() - comments = [Comment.from_json(json=comment) for comment in comments_list] if comments_list else [] - - return cls(story_id=parser.get_story_id(), - payment_id=parser.get_payment_id(), - date_completed=date_completed, - date_created=date_created, - date_updated=date_updated, - payment_type=parser.get_type(), - amount=parser.get_amount(), - audience=parser.get_audience(), - note=parser.get_story_note(), - status=parser.get_status(), - device_used=device_used, - actor=actor, - target=target, - comments=comments, - json=json) + return DEVICE_MAP.get(int(_id)) + + +DeviceModel = Annotated[ + Literal["iPhone", "Android", "Other"], BeforeValidator(get_device_model_from_json) +] class TransactionType(Enum): - PAYMENT = 'payment' + PAYMENT = "payment" # merchant refund - REFUND = 'refund' + REFUND = "refund" # to/from bank account - TRANSFER = 'transfer' - # add money to debit card - TOP_UP = 'top_up' + TRANSFER = "transfer" + # add money to debit cards + TOP_UP = "top_up" # debit card purchase - AUTHORIZATION = 'authorization' + AUTHORIZATION = "authorization" # debit card atm withdrawal - ATM_WITHDRAWAL = 'atm_withdrawal' + ATM_WITHDRAWAL = "atm_withdrawal" + + DISBURSEMENT = "disbursement" - DISBURSEMENT = 'disbursement' + +class Mention(BaseModel): + username: str + user: User + + +class Comment(BaseModel): + id: str + message: str + date_created: datetime + mentions: list[Mention] = Field(validation_alias=AliasPath("mentions", "data")) + user: User + + +class Transaction(BaseModel): + """wrapper around Payment returned when you fetch your "stories" feeds""" + + type: TransactionType + id: str + note: str + date_created: datetime + date_updated: datetime | None + payment: Payment + audience: PaymentPrivacy + device_used: DeviceModel = Field(validation_alias="app") + comments: list[Comment] = Field(validation_alias=AliasPath("comments", "data")) diff --git a/venmo_api/models/user.py b/venmo_api/models/user.py index b333015..0efaca4 100644 --- a/venmo_api/models/user.py +++ b/venmo_api/models/user.py @@ -1,65 +1,55 @@ -from venmo_api import string_to_timestamp, BaseModel, JSONSchema +from datetime import datetime +from enum import StrEnum, auto +from pydantic import BaseModel, EmailStr -class User(BaseModel): - def __init__(self, user_id, username, first_name, last_name, display_name, phone, - profile_picture_url, about, date_joined, is_group, is_active, json=None): - """ - User model - :param user_id: - :param username: - :param first_name: - :param last_name: - :param display_name: - :param phone: - :param profile_picture_url: - :param about: - :param date_joined: - :param is_group: - :param is_active: - :param json: full_json - :return: - """ - super().__init__() +class PaymentPrivacy(StrEnum): + PRIVATE = auto() + PUBLIC = auto() + FRIENDS = auto() - self.id = user_id - self.username = username - self.first_name = first_name - self.last_name = last_name - self.display_name = display_name - self.phone = phone - self.profile_picture_url = profile_picture_url - self.about = about - self.date_joined = date_joined - self.is_group = is_group - self.is_active = is_active - self._json = json - @classmethod - def from_json(cls, json, is_profile=False): - """ - init a new user form JSON - :param json: - :param is_profile: - :return: - """ - if not json: - return +class FriendStatus(StrEnum): + FRIEND = auto() + NOT_FRIEND = auto() + - parser = JSONSchema.user(json, is_profile=is_profile) +# TODO verify stuff that isn't personal +class IdentityType(StrEnum): + PERSONAL = auto() + BUSINESS = auto() + CHARITY = auto() + UNKNOWN = auto() + + @classmethod + def _missing_(cls, value): # type: ignore[override] + """Gracefully handle new/unknown identity types coming from the API.""" + if isinstance(value, str): + for member in cls: + if member.value == value.lower(): + return member + return cls.UNKNOWN + return None - date_joined_timestamp = string_to_timestamp(parser.get_date_created()) - return cls(user_id=parser.get_user_id(), - username=parser.get_username(), - first_name=parser.get_first_name(), - last_name=parser.get_last_name(), - display_name=parser.get_full_name(), - phone=parser.get_phone(), - profile_picture_url=parser.get_picture_url(), - about=parser.get_about(), - date_joined=date_joined_timestamp, - is_group=parser.get_is_group(), - is_active=parser.get_is_active(), - json=json) +class User(BaseModel): + about: str + date_joined: datetime + friends_count: int | None + is_active: bool + is_blocked: bool + friend_status: FriendStatus | None + profile_picture_url: str + username: str + trust_request: str | None # TODO, so far just None + display_name: str + email: EmailStr | None = None + first_name: str + id: str + identity_type: IdentityType + is_group: bool + last_name: str + phone: str | None = None + is_payable: bool + audience: PaymentPrivacy diff --git a/venmo_api/utils/__init__.py b/venmo_api/utils/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/venmo_api/utils/api_client.py b/venmo_api/utils/api_client.py deleted file mode 100644 index b06aff7..0000000 --- a/venmo_api/utils/api_client.py +++ /dev/null @@ -1,170 +0,0 @@ -from venmo_api import ResourceNotFoundError, InvalidHttpMethodError, HttpCodeError, validate_access_token -from json import JSONDecodeError -from typing import List -import requests -import threading - - -class ApiClient(object): - """ - Generic API Client for the Venmo API - """ - - def __init__(self, access_token=None): - """ - :param access_token: access token you received for your account. - """ - super().__init__() - - access_token = validate_access_token(access_token=access_token) - - self.access_token = access_token - self.configuration = {"host": "https://api.venmo.com/v1"} - - self.default_headers = {"User-Agent": "Venmo/7.44.0 (iPhone; iOS 13.0; Scale/2.0)"} - if self.access_token: - self.default_headers.update({"Authorization": self.access_token}) - - self.session = requests.Session() - self.session.headers.update(self.default_headers) - - def update_access_token(self, access_token): - self.access_token = validate_access_token(access_token=access_token) - self.default_headers.update({"Authorization": self.access_token}) - self.session.headers.update({"Authorization": self.access_token}) - - def call_api(self, resource_path: str, method: str, - header_params: dict = None, - params: dict = None, - body: dict = None, - callback=None, - ok_error_codes: List[int] = None): - - """ - Makes the HTTP request (Synchronous) and return the deserialized data. - To make it async multi-threaded, define a callback function. - - :param resource_path: Specific Venmo API path - :param method: HTTP request method - :param header_params: request headers - :param params: request parameters (?=) - :param body: request body will be send as JSON - :param callback: Needs to be provided for async - :param ok_error_codes: A list of integer error codes that you don't want an exception for. - :return: response: {'status_code': , 'headers': , 'body': } - """ - - if callback is None: - return self.__call_api(resource_path=resource_path, method=method, - header_params=header_params, params=params, - body=body, callback=callback, - ok_error_codes=ok_error_codes) - else: - thread = threading.Thread(target=self.__call_api, - args=(resource_path, method, header_params, - params, body, callback)) - thread.start() - return thread - - def __call_api(self, resource_path, method, - header_params=None, params=None, - body=None, callback=None, - ok_error_codes: List[int] = None): - """ - Calls API on the provided path - - :param resource_path: Specific Venmo API path - :param method: HTTP request method - :param header_params: request headers - :param body: request body will be send as JSON - :param callback: Needs to be provided for async - :param ok_error_codes: A list of integer error codes that you don't want an exception for. - - :return: response: {'status_code': , 'headers': , 'body': } - """ - - # Update the header with the required values - header_params = header_params or {} - - if body: - header_params.update({"Content-Type": "application/json"}) - - url = self.configuration['host'] + resource_path - - # Use a new session for multi-threaded - if callback: - session = requests.Session() - session.headers.update(self.default_headers) - - else: - session = self.session - - # perform request and return response - processed_response = self.request(method, url, session, - header_params=header_params, params=params, - body=body, ok_error_codes=ok_error_codes) - - self.last_response = processed_response - - if callback: - callback(processed_response) - else: - return processed_response - - def request(self, method, url, session, - header_params=None, - params=None, - body=None, - ok_error_codes: List[int] = None): - """ - Make a request with the provided information using a requests.session - :param method: - :param url: - :param session: - :param header_params: - :param params: - :param body: - :param ok_error_codes: A list of integer error codes that you don't want an exception for. - - :return: - """ - - if method not in ['POST', 'PUT', 'GET', 'DELETE']: - raise InvalidHttpMethodError() - - response = session.request( - method=method, url=url, headers=header_params, params=params, json=body) - - # Only accepts the 20x status codes. - validated_response = self.__validate_response(response, ok_error_codes=ok_error_codes) - - return validated_response - - @staticmethod - def __validate_response(response, ok_error_codes: List[int] = None): - """ - Validate and build a new validated response. - :param response: - :param ok_error_codes: A list of integer error codes that you don't want an exception for. - :return: - """ - try: - body = response.json() - headers = response.headers - except JSONDecodeError: - body = {} - headers = {} - - built_response = {"status_code": response.status_code, "headers": headers, "body": body} - - if response.status_code in range(200, 205) and response.json: - return built_response - - elif response.status_code == 400 and response.json().get('error').get('code') == 283: - raise ResourceNotFoundError() - - else: - if body and ok_error_codes and body.get('error').get('code') in ok_error_codes: - return built_response - - raise HttpCodeError(response=response) diff --git a/venmo_api/utils/api_util.py b/venmo_api/utils/api_util.py deleted file mode 100644 index c081ecf..0000000 --- a/venmo_api/utils/api_util.py +++ /dev/null @@ -1,131 +0,0 @@ -from venmo_api import ArgumentMissingError, User, Page -from enum import Enum -from typing import Dict, List -import re - - -def validate_access_token(access_token): - """ - Validate the access_token - :param access_token: - :return: - """ - token_re = r'^(Bearer)?(.+)$' - if not access_token: - return - - access_token = re.findall(token_re, access_token)[0][1].replace(' ', '') - - return f"Bearer {access_token}" - - -def deserialize(response: Dict, data_type, nested_response: List[str] = None): - """Extract one or a list of Objects from the api_client structured response. - :param response: - :param data_type: - :param nested_response: Optional. Loop through the body - :return: a single or a of objects (Objects can be User/Transaction/Payment/PaymentMethod) - """ - - body = response.get('body') - if not body: - raise Exception("Can't get an empty response body.") - - data = body.get('data') - nested_response = nested_response or [] - for nested in nested_response: - temp = data.get(nested) - if not temp: - raise ValueError(f"Couldn't find {nested} in the {data}.") - data = temp - - # Return a list of data_type - if isinstance(data, list): - return __get_objs_from_json_list(json_list=data, data_type=data_type) - - return data_type.from_json(json=data) - - -def wrap_callback(callback, data_type, nested_response: List[str] = None): - """ - :param callback: Function that was provided by the user - :param data_type: It can be either User or Transaction - :param nested_response: Optional. Loop through the body - :return wrapped_callback: or The user callback wrapped for json parsing. - """ - if not callback: - return None - - def wrapper(response): - - if not data_type: - return callback(True) - - deserialized_data = deserialize(response=response, data_type=data_type, nested_response=nested_response) - return callback(deserialized_data) - - return wrapper - - -def __get_objs_from_json_list(json_list, data_type): - """Process JSON for User/Transaction - :param json_list: a list of objs - :param data_type: User/Transaction/Payment/PaymentMethod - :return: - """ - result = Page() - for obj in json_list: - data_obj = data_type.from_json(obj) - if not data_obj: - continue - result.append(data_obj) - - return result - - -class Colors(Enum): - HEADER = '\033[95m' - OKBLUE = '\033[94m' - OKGREEN = '\033[92m' - WARNING = '\033[93m' - FAIL = '\033[91m' - ENDC = '\033[0m' - BOLD = '\033[1m' - UNDERLINE = '\033[4m' - - -def warn(message): - """ - print message in Red Color - :param message: - :return: - """ - print(Colors.WARNING.value + message + Colors.ENDC.value) - - -def confirm(message): - """ - print message in Blue Color - :param message: - :return: - """ - print(Colors.OKBLUE.value + message + Colors.ENDC.value) - - -def get_user_id(user, user_id): - """ - Checks at least one user_id exists and returns it - :param user_id: - :param user: - :return user_id: - """ - if not user and not user_id: - raise ArgumentMissingError(arguments=('target_user_id', 'target_user')) - - if not user_id: - if type(user) != User: - raise ArgumentMissingError(f"Expected {User} for target_user, but received {type(user)}") - - user_id = user.id - - return user_id diff --git a/venmo_api/utils/model_util.py b/venmo_api/utils/model_util.py deleted file mode 100644 index 879e4c1..0000000 --- a/venmo_api/utils/model_util.py +++ /dev/null @@ -1,53 +0,0 @@ -from datetime import datetime -from random import randint, choice -from string import ascii_uppercase - - -def string_to_timestamp(utc): - """ - Convert UTC string format by Venmo, to timestamp - :param utc: String, Format "2019-02-07T18:04:18" or "2019-02-07T18:04:18.474000" - :return: int, timestamp - """ - if not utc: - return - try: - _date = datetime.strptime(utc, '%Y-%m-%dT%H:%M:%S') - # This except was added for comments (on transactions) - they display the date_created down to the microsecond - except ValueError: - _date = datetime.strptime(utc, '%Y-%m-%dT%H:%M:%S.%f') - return int(_date.timestamp()) - - -def get_phone_model_from_json(app_json): - """ - extract the phone model from app_info json. - :param app_json: - :return: - """ - app = {1: "iPhone", 4: "Android", 0: "Other"} - _id = 0 - if app_json: - _id = app_json['id'] - - return app.get(int(_id)) - - -def random_device_id(): - """ - Generate a random device id that can be used for logging in. - :return: - """ - BASE_DEVICE_ID = "88884260-05O3-8U81-58I1-2WA76F357GR9" - - result = [] - for char in BASE_DEVICE_ID: - - if char.isdigit(): - result.append(str(randint(0, 9))) - elif char == '-': - result.append('-') - else: - result.append(choice(ascii_uppercase)) - - return "".join(result) diff --git a/venmo_api/venmo.py b/venmo_api/venmo.py index a8ea1fb..552476f 100644 --- a/venmo_api/venmo.py +++ b/venmo_api/venmo.py @@ -1,50 +1,158 @@ -from venmo_api import ApiClient, UserApi, PaymentApi, AuthenticationApi, validate_access_token +import os +from typing import Self +from venmo_api import ApiClient, AuthenticationApi, PaymentApi, UserApi +from venmo_api.models.user import User -class Client(object): - def __init__(self, access_token: str): +class Client: + """User-friendly VenmoAPI Client. `Client.login()` is the recommended way to + instantiate rather than calling `Client()` directly. + + ``` + with Client.login(user, pw, dev_id) as client: + client.pay_your_people() + # now you're automatically logged out, no worries of active access tokens floating + # around in the ether to keep you up at night. + ``` + """ + + @staticmethod + def login_from_env( + username_env: str, password_env: str, device_id_env: str + ) -> Self: + """Convenience method to login from loaded environment variables. + + Args: + username_env (str): Env var for username. + password_env (str): Env var for password. + device_id_env (str): Env var for device ID. + + Returns: + Self: Logged in Client instance. + """ + + return Client.login( + os.getenv(username_env), os.getenv(password_env), os.getenv(device_id_env) + ) + + @staticmethod + def login(username: str, password: str, device_id: str | None = None) -> Self: + """Log in using your credentials and get an access_token to use in the API. + Recommended way to instantiate a Client. + + Args: + username (str): Can be username, phone number (without +1) or email address. + password (str): Account's password. + device_id (str | None, optional): A valid device-id. Defaults to None. FYI I + think it's not actually optional anymore. + + Returns: + Self: Logged in Client instance. """ - VenmoAPI Client - :param access_token: Need access_token to work with the API. + api_client = ApiClient(device_id=device_id) + # api_client is updated with access_token internally + access_token = AuthenticationApi(api_client).login_with_credentials_cli( + username=username, password=password + ) + return Client(api_client=api_client) + + @staticmethod + def logout(access_token) -> bool: + """Revoke your access_token. Log out, in other words. + + Args: + access_token (_type_): Token for current session. + + Returns: + bool: True or raises exception. """ - super().__init__() - self.__access_token = validate_access_token(access_token=access_token) - self.__api_client = ApiClient(access_token=access_token) - self.user = UserApi(self.__api_client) - self.__profile = self.user.get_my_profile() - self.payment = PaymentApi(profile=self.__profile, - api_client=self.__api_client) + return AuthenticationApi.log_out(access_token=access_token) - def my_profile(self, force_update=False): + def __init__( + self, + access_token: str | None = None, + device_id: str | None = None, + api_client: ApiClient | None = None, + ): """ - Get your profile info. It can be cached from the prev time. - :return: + Args: + access_token (str | None, optional): Token for already logged in session, if + available. Defaults to None. This is only optional because you can + choose to pass an initialized ApiClient holding the token instead. + device_id (str | None, optional): A valid device-id. Defaults to None. This + is only optional because you can choose to pass an initialized ApiClient + holding the id instead. + api_client (ApiClient | None, optional): Alternative to the above 2. + Defaults to None. """ - if force_update: - self.__profile = self.user.get_my_profile(force_update=force_update) + if api_client is None: + self.__api_client = ApiClient( + access_token=access_token, device_id=device_id + ) + else: + # NOTE: for anything sensitive, makes sense to pass ApiClient instance that + # you logged in with, since it stores the original csrf_token set at login. + # Haven't verified that this is absolutely necessary, but seems sensible to + # align with the app's behavior. + self.__api_client = api_client + if access_token is not None: + # don't allow the possibility of clearing an already set token + self.__api_client.update_access_token(access_token) - return self.__profile + self.user = UserApi(self.__api_client) + self._profile = self.user.get_my_profile() + self._balance = self.user.get_my_balance() + self.payment = PaymentApi( + profile=self._profile, api_client=self.__api_client, balance=self._balance + ) - @staticmethod - def get_access_token(username: str, password: str, device_id: str = None) -> str: - """ - Log in using your credentials and get an access_token to use in the API - :param username: Can be username, phone number (without +1) or email address. - :param password: Account's password - :param device_id: [optional] A valid device-id. + def my_profile(self, force_update=False) -> User: + """Get your profile info. It can be cached from the previous time. - :return: access_token + Args: + force_update (bool, optional): Whether to force fetching an updated user. + Defaults to False. + + Returns: + User: your profile. """ - authn_api = AuthenticationApi(api_client=ApiClient(), device_id=device_id) - return authn_api.login_with_credentials_cli(username=username, password=password) + if force_update: + self._profile = self.user.get_my_profile(force_update=force_update) - @staticmethod - def log_out(access_token) -> bool: + return self._profile + + def my_balance(self, force_update=False) -> float: + """Get your Venmo balance. It can be cached from the previous time. + + Args: + force_update (bool, optional): Whether to force fetching an updated balance. + Defaults to False. + + Returns: + float: your balance. """ - Revoke your access_token. Log out, in other words. - :param access_token: - :return: + if force_update: + self._balance = self.user.get_my_balance(force_update=force_update) + + return self._balance + + @property + def access_token(self) -> str | None: + return self.__api_client.access_token + + # context manager dunder methods for `with` block logout using stored token + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.log_out_instance() + + def log_out_instance(self) -> bool: + """Convenience instance method for logging out using stored access token. Called + automatically at conclusion of a with block. + + Returns: + bool: True or exceptionm raised """ - access_token = validate_access_token(access_token=access_token) - return AuthenticationApi.log_out(access_token=access_token) + return AuthenticationApi.log_out(self.access_token)