From 53c764918ccb022f14acc1b0744ec6c9374ec8c7 Mon Sep 17 00:00:00 2001 From: Joseph Charles <30187981+j027@users.noreply.github.com> Date: Sat, 31 Aug 2024 23:09:35 -0400 Subject: [PATCH 01/23] update user agent to match newer version of mobile app --- venmo_api/utils/api_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/venmo_api/utils/api_client.py b/venmo_api/utils/api_client.py index b06aff7..95d17ad 100644 --- a/venmo_api/utils/api_client.py +++ b/venmo_api/utils/api_client.py @@ -21,7 +21,7 @@ def __init__(self, access_token=None): 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)"} + self.default_headers = {"User-Agent": "Venmo/10.48.0 (iPhone; iOS 17.6.1; Scale/3.0)"} if self.access_token: self.default_headers.update({"Authorization": self.access_token}) From ce99b0e27fc47719a4242ebed042eff56a3139c5 Mon Sep 17 00:00:00 2001 From: Joseph Charles <30187981+j027@users.noreply.github.com> Date: Sun, 1 Sep 2024 23:52:57 -0400 Subject: [PATCH 02/23] Add initial support for getting the eligibility token. This might need some adjustment and may need to add in the models, but it should be usable to grab the token for payments --- venmo_api/apis/payment_api.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/venmo_api/apis/payment_api.py b/venmo_api/apis/payment_api.py index bf95493..33ece2e 100644 --- a/venmo_api/apis/payment_api.py +++ b/venmo_api/apis/payment_api.py @@ -155,6 +155,34 @@ def request_money(self, amount: float, target_user=target_user, callback=callback) + def __get_eligibility_token(self, amount: float, note: str, target_id: int = None, funding_source_id: str = None, + action: str = "pay", + country_code: str = "1", target_type: str = "user_id", callback=None): + """ + Generate eligibility token which is needed in payment requests + :param amount: amount of money to be requested + :param note: message/note of the transaction + :param target_id: the user id of the person you are sending money to + :param funding_source_id: Your payment_method id for this payment + :param action: action that eligibility token is used for + :param country_code: country code, not sure what this is for + :param target_type: set by default to user_id, but there are probably other target types + """ + resource_path = '/protection/eligibility' + body = { + "funding_source_id": self.get_default_payment_method().id if not funding_source_id else funding_source_id, + "action": action, + "country_code": country_code, + "target_type": target_type, + "note": note, + "target_id": get_user_id(user=None, user_id=target_id), + "amount": amount, + } + + return self.__api_client.call_api(resource_path=resource_path, + body=body, + method='POST') + def __update_payment(self, action, payment_id): if not payment_id: From 7051504093ec63e05a40b03060ed4d776c906ed1 Mon Sep 17 00:00:00 2001 From: Joseph Charles <30187981+j027@users.noreply.github.com> Date: Thu, 5 Sep 2024 12:52:50 -0400 Subject: [PATCH 03/23] add models and json handling for eligibility token, including the fees array inside the eligibility token object --- venmo_api/models/eligibility_token.py | 36 ++++++++++++++++ venmo_api/models/fee.py | 37 ++++++++++++++++ venmo_api/models/json_schema.py | 62 +++++++++++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 venmo_api/models/eligibility_token.py create mode 100644 venmo_api/models/fee.py diff --git a/venmo_api/models/eligibility_token.py b/venmo_api/models/eligibility_token.py new file mode 100644 index 0000000..112ef9e --- /dev/null +++ b/venmo_api/models/eligibility_token.py @@ -0,0 +1,36 @@ +from venmo_api import BaseModel, JSONSchema +from venmo_api.models.fee import Fee + + +class EligibilityToken(BaseModel): + def __init__(self, eligibility_token, eligible, fees, fee_disclaimer, json=None): + super().__init__() + + self.eligibility_token = eligibility_token + self.eligible = eligible + self.fees = fees + self.fee_disclaimer = fee_disclaimer + self._json = json + + @classmethod + def from_json(cls, json): + """ + Initialize a new eligibility token object from JSON. + :param json: JSON data to parse. + :return: EligibilityToken object. + """ + if not json: + return None + + parser = JSONSchema.eligibility_token(json) + + fees = parser.get_fees() + fee_objects = [Fee.from_json(fee) for fee in fees] if fees else [] + + return cls( + eligibility_token=parser.get_eligibility_token(), + eligible=parser.get_eligible(), + fees=fee_objects, + fee_disclaimer=parser.get_fee_disclaimer(), + json=json + ) diff --git a/venmo_api/models/fee.py b/venmo_api/models/fee.py new file mode 100644 index 0000000..59ef580 --- /dev/null +++ b/venmo_api/models/fee.py @@ -0,0 +1,37 @@ +from venmo_api import BaseModel, JSONSchema + + +class Fee(BaseModel): + def __init__(self, product_uri, applied_to, base_fee_amount, fee_percentage, calculated_fee_amount_in_cents, + fee_token, json=None): + super().__init__() + + self.product_uri = product_uri + self.applied_to = applied_to + self.base_fee_amount = base_fee_amount + self.fee_percentage = fee_percentage + self.calculated_fee_amount_in_cents = calculated_fee_amount_in_cents + self.fee_token = fee_token + self._json = json + + @classmethod + def from_json(cls, json): + """ + Initialize a new Fee object from JSON using the FeeParser. + :param json: JSON data to parse. + :return: Fee object. + """ + if not json: + return None + + parser = JSONSchema.fee(json) + + return cls( + product_uri=parser.get_product_uri(), + applied_to=parser.get_applied_to(), + base_fee_amount=parser.get_base_fee_amount(), + fee_percentage=parser.get_fee_percentage(), + calculated_fee_amount_in_cents=parser.get_calculated_fee_amount_in_cents(), + fee_token=parser.get_fee_token(), + json=json + ) diff --git a/venmo_api/models/json_schema.py b/venmo_api/models/json_schema.py index 6d3dab8..6f74d27 100644 --- a/venmo_api/models/json_schema.py +++ b/venmo_api/models/json_schema.py @@ -24,6 +24,14 @@ def comment(json): def mention(json): return MentionParser(json) + @staticmethod + def eligibility_token(json): + return EligibilityTokenParser(json) + + @staticmethod + def fee(json): + return FeeParser(json) + class TransactionParser: @@ -324,3 +332,57 @@ def get_user(self): "username": "username", "user": "user" } + +class EligibilityTokenParser: + def __init__(self, json): + self.json = json + + def get_eligibility_token(self): + return self.json.get(eligibility_token_json_format['eligibility_token']) + + def get_eligible(self): + return self.json.get(eligibility_token_json_format['eligible']) + + def get_fees(self): + return self.json.get(eligibility_token_json_format['fees']) + + def get_fee_disclaimer(self): + return self.json.get(eligibility_token_json_format['fee_disclaimer']) + +eligibility_token_json_format = { + 'eligibility_token': 'eligibility_token', + 'eligible': 'eligible', + 'fees': 'fees', + 'fee_disclaimer': 'fee_disclaimer' +} + +class FeeParser: + def __init__(self, json): + self.json = json + + def get_product_uri(self): + return self.json.get(fee_json_format['product_uri']) + + def get_applied_to(self): + return self.json.get(fee_json_format['applied_to']) + + def get_base_fee_amount(self): + return self.json.get(fee_json_format['base_fee_amount']) + + def get_fee_percentage(self): + return self.json.get(fee_json_format['fee_percentage']) + + def get_calculated_fee_amount_in_cents(self): + return self.json.get(fee_json_format['calculated_fee_amount_in_cents']) + + def get_fee_token(self): + return self.json.get(fee_json_format['fee_token']) + +fee_json_format = { + 'product_uri': 'product_uri', + 'applied_to': 'applied_to', + 'base_fee_amount': 'base_fee_amount', + 'fee_percentage': 'fee_percentage', + 'calculated_fee_amount_in_cents': 'calculated_fee_amount_in_cents', + 'fee_token': 'fee_token' +} \ No newline at end of file From c935bf9ff0f180ec995d3d7def5bd15595596569 Mon Sep 17 00:00:00 2001 From: Joseph Charles <30187981+j027@users.noreply.github.com> Date: Thu, 5 Sep 2024 12:54:21 -0400 Subject: [PATCH 04/23] add handling for eligibility token --- venmo_api/apis/payment_api.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/venmo_api/apis/payment_api.py b/venmo_api/apis/payment_api.py index 33ece2e..ea078a9 100644 --- a/venmo_api/apis/payment_api.py +++ b/venmo_api/apis/payment_api.py @@ -3,6 +3,8 @@ User, PaymentMethod, PaymentRole, PaymentPrivacy, deserialize, wrap_callback, get_user_id from typing import List, Union +from venmo_api.models.eligibility_token import EligibilityToken + class PaymentApi(object): @@ -179,9 +181,13 @@ def __get_eligibility_token(self, amount: float, note: str, target_id: int = Non "amount": amount, } - return self.__api_client.call_api(resource_path=resource_path, + response = self.__api_client.call_api(resource_path=resource_path, body=body, method='POST') + if callback: + return + + return deserialize(response=response, data_type=EligibilityToken) def __update_payment(self, action, payment_id): @@ -226,6 +232,7 @@ def __send_or_request_money(self, amount: float, funding_source_id: str = None, privacy_setting: str = PaymentPrivacy.PRIVATE.value, target_user_id: int = None, target_user: User = None, + eligibility_token: str = None, callback=None) -> Union[bool, None]: """ Generic method for sending and requesting money @@ -236,6 +243,7 @@ def __send_or_request_money(self, amount: float, :param privacy_setting: :param target_user_id: :param target_user: + :param eligibility_token: :param callback: :return: """ @@ -255,6 +263,10 @@ def __send_or_request_money(self, amount: float, 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, int(target_user_id)).eligibility_token + + body.update({"eligibility_token": eligibility_token}) body.update({"funding_source_id": funding_source_id}) resource_path = '/payments' From d60f28ce452f5c4345e59ff8c5f71af5ddc2dac3 Mon Sep 17 00:00:00 2001 From: Joseph Charles <30187981+j027@users.noreply.github.com> Date: Fri, 20 Sep 2024 15:22:57 -0400 Subject: [PATCH 05/23] bump version in user agent to match latest version of venmo app --- venmo_api/utils/api_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/venmo_api/utils/api_client.py b/venmo_api/utils/api_client.py index 95d17ad..719c786 100644 --- a/venmo_api/utils/api_client.py +++ b/venmo_api/utils/api_client.py @@ -21,7 +21,7 @@ def __init__(self, access_token=None): self.access_token = access_token self.configuration = {"host": "https://api.venmo.com/v1"} - self.default_headers = {"User-Agent": "Venmo/10.48.0 (iPhone; iOS 17.6.1; Scale/3.0)"} + self.default_headers = {"User-Agent": "Venmo/10.50.0 (iPhone; iOS 18.0; Scale/3.0)"} if self.access_token: self.default_headers.update({"Authorization": self.access_token}) From 9ad1706e29f2fbefb096cff1cfd63c59be7196c3 Mon Sep 17 00:00:00 2001 From: joshhubert-dsp <102703352+joshhubert-dsp@users.noreply.github.com> Date: Wed, 24 Sep 2025 18:54:30 -0700 Subject: [PATCH 06/23] getting close with headers.json i think? --- .gitignore | 2 + pyproject.toml | 30 ++++ uv.lock | 103 +++++++++++++ venmo_api/apis/payment_api.py | 266 ++++++++++++++++++++-------------- venmo_api/utils/__init__.py | 3 + venmo_api/utils/api_client.py | 124 +++++++++++----- setup.py => zsetup.py | 0 7 files changed, 383 insertions(+), 145 deletions(-) create mode 100644 pyproject.toml create mode 100644 uv.lock rename setup.py => zsetup.py (100%) diff --git a/.gitignore b/.gitignore index fb0226e..f669f73 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +headers.json + .idea/ # Created by https://www.gitignore.io/api/macos,linux,django,python,pycharm diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..25a03fc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "venmo-api" +version = "0.4.0" +description = "Venmo API client for Python" +readme = "README.md" +authors = [ + { name = "Mark Mohades"}, +] +license = "GPL-3.0-only" +license-files = [ + "LICENSE" +] +requires-python = ">=3.12,<3.13" +dependencies = [ + "orjson>=3.11.3", + "requests>=2.19.0", +] + +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.setuptools] +package-dir = { "" = "." } + +[tool.setuptools.packages.find] +where = ["."] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..d6ec421 --- /dev/null +++ b/uv.lock @@ -0,0 +1,103 @@ +version = 1 +revision = 2 +requires-python = "==3.12.*" + +[[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/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/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 = "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 = "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/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" }, +] + +[[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 = "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 = "0.4.0" +source = { editable = "." } +dependencies = [ + { name = "orjson" }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "orjson", specifier = ">=3.11.3" }, + { name = "requests", specifier = ">=2.19.0" }, +] diff --git a/venmo_api/apis/payment_api.py b/venmo_api/apis/payment_api.py index ea078a9..8eb7827 100644 --- a/venmo_api/apis/payment_api.py +++ b/venmo_api/apis/payment_api.py @@ -1,13 +1,27 @@ -from venmo_api import ApiClient, Payment, ArgumentMissingError, AlreadyRemindedPaymentError, \ - NoPendingPaymentToUpdateError, NoPaymentMethodFoundError, NotEnoughBalanceError, GeneralPaymentError, \ - User, PaymentMethod, PaymentRole, PaymentPrivacy, deserialize, wrap_callback, get_user_id +import uuid from typing import List, Union +from venmo_api import ( + AlreadyRemindedPaymentError, + ApiClient, + ArgumentMissingError, + GeneralPaymentError, + NoPaymentMethodFoundError, + NoPendingPaymentToUpdateError, + NotEnoughBalanceError, + Payment, + PaymentMethod, + PaymentPrivacy, + PaymentRole, + User, + deserialize, + get_user_id, + wrap_callback, +) from venmo_api.models.eligibility_token import EligibilityToken class PaymentApi(object): - def __init__(self, profile, api_client: ApiClient): super().__init__() self.__profile = profile @@ -16,7 +30,7 @@ def __init__(self, profile, api_client: ApiClient): "already_reminded_error": 2907, "no_pending_payment_error": 2901, "no_pending_payment_error2": 2905, - "not_enough_balance_error": 13006 + "not_enough_balance_error": 13006, } def get_charge_payments(self, limit=100000, callback=None): @@ -26,9 +40,7 @@ def get_charge_payments(self, limit=100000, callback=None): :param callback: :return: """ - return self.__get_payments(action="charge", - limit=limit, - callback=callback) + return self.__get_payments(action="charge", limit=limit, callback=callback) def get_pay_payments(self, limit=100000, callback=None): """ @@ -37,9 +49,7 @@ def get_pay_payments(self, limit=100000, callback=None): :param callback: :return: """ - return self.__get_payments(action="pay", - limit=limit, - callback=callback) + return self.__get_payments(action="pay", limit=limit, callback=callback) def remind_payment(self, payment: Payment = None, payment_id: int = None) -> bool: """ @@ -51,16 +61,19 @@ def remind_payment(self, payment: Payment = None, payment_id: int = None) -> boo # if the reminder has already sent payment_id = payment_id or payment.id - action = 'remind' + action = "remind" - response = self.__update_payment(action=action, - payment_id=payment_id) + 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.get("body"): + if ( + response["body"]["error"]["code"] + == self.__payment_error_codes["no_pending_payment_error2"] + ): + raise NoPendingPaymentToUpdateError( + payment_id=payment_id, action=action + ) raise AlreadyRemindedPaymentError(payment_id=payment_id) return True @@ -73,14 +86,12 @@ def cancel_payment(self, payment: Payment = None, payment_id: int = None) -> boo """ # if the reminder has already sent payment_id = payment_id or payment.id - action = 'cancel' + action = "cancel" - response = self.__update_payment(action=action, - payment_id=payment_id) + response = self.__update_payment(action=action, payment_id=payment_id) - if 'error' in response.get('body'): - raise NoPendingPaymentToUpdateError(payment_id=payment_id, - action=action) + if "error" in response.get("body"): + raise NoPendingPaymentToUpdateError(payment_id=payment_id, action=action) return True def get_payment_methods(self, callback=None) -> Union[List[PaymentMethod], None]: @@ -90,26 +101,28 @@ def get_payment_methods(self, callback=None) -> Union[List[PaymentMethod], None] :return: """ - wrapped_callback = wrap_callback(callback=callback, - 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) + 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 return deserialize(response=response, data_type=PaymentMethod) - 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]: + 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]: """ 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. @@ -123,21 +136,26 @@ def send_money(self, amount: float, :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, - 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=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]: """ Request [amount] money with [note] from the ([target_user_id] or [target_user]) :param amount: amount of money to be requested @@ -148,18 +166,28 @@ def request_money(self, amount: float, :param callback: callback function :return: Either the transaction was successful or an exception will rise. """ - 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 __get_eligibility_token(self, amount: float, note: str, target_id: int = None, funding_source_id: str = None, - action: str = "pay", - country_code: str = "1", target_type: str = "user_id", callback=None): + 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 __get_eligibility_token( + self, + amount: float, + note: str, + target_id: int = None, + funding_source_id: str = None, + action: str = "pay", + country_code: str = "1", + target_type: str = "user_id", + callback=None, + ): """ Generate eligibility token which is needed in payment requests :param amount: amount of money to be requested @@ -170,9 +198,11 @@ def __get_eligibility_token(self, amount: float, note: str, target_id: int = Non :param country_code: country code, not sure what this is for :param target_type: set by default to user_id, but there are probably other target types """ - resource_path = '/protection/eligibility' + resource_path = "/protection/eligibility" body = { - "funding_source_id": self.get_default_payment_method().id if not funding_source_id else funding_source_id, + "funding_source_id": self.get_default_payment_method().id + if not funding_source_id + else funding_source_id, "action": action, "country_code": country_code, "target_type": target_type, @@ -181,59 +211,61 @@ def __get_eligibility_token(self, amount: float, note: str, target_id: int = Non "amount": amount, } - response = self.__api_client.call_api(resource_path=resource_path, - body=body, - method='POST') + response = self.__api_client.call_api( + resource_path=resource_path, body=body, method="POST" + ) if callback: return return deserialize(response=response, data_type=EligibilityToken) def __update_payment(self, action, payment_id): - if not payment_id: - raise ArgumentMissingError(arguments=('payment', 'payment_id')) + raise ArgumentMissingError(arguments=("payment", "payment_id")) - resource_path = f'/payments/{payment_id}' + resource_path = f"/payments/{payment_id}" body = { "action": action, } - return self.__api_client.call_api(resource_path=resource_path, - body=body, - method='PUT', - ok_error_codes=list(self.__payment_error_codes.values())[:-1]) + return self.__api_client.call_api( + resource_path=resource_path, + body=body, + method="PUT", + ok_error_codes=list(self.__payment_error_codes.values())[:-1], + ) def __get_payments(self, action, limit, callback=None): """ Get a list of ongoing payments with the given action :return: """ - wrapped_callback = wrap_callback(callback=callback, - data_type=Payment) - - resource_path = '/payments' - parameters = { - "action": action, - "actor": self.__profile.id, - "limit": limit - } - response = self.__api_client.call_api(resource_path=resource_path, - params=parameters, - method='GET', - callback=wrapped_callback) + wrapped_callback = wrap_callback(callback=callback, data_type=Payment) + + resource_path = "/payments" + parameters = {"action": action, "actor": self.__profile.id, "limit": limit} + response = self.__api_client.call_api( + resource_path=resource_path, + params=parameters, + method="GET", + callback=wrapped_callback, + ) if callback: return 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, - eligibility_token: str = None, - callback=None) -> Union[bool, None]: + 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, + eligibility_token: str = None, + callback=None, + ) -> Union[bool, None]: """ Generic method for sending and requesting money :param amount: @@ -253,38 +285,54 @@ def __send_or_request_money(self, amount: float, if not is_send_money: amount = -amount + uid = uuid.uuid4().hex + uid_dashed = ( + uid[:8] + + "-" + + uid[8:12] + + "-" + + uid[12:16] + + "-" + + uid[16:20] + + "-" + + uid[20:32] + ) body = { + "uuid": uid_dashed, "user_id": target_user_id, "audience": privacy_setting, "amount": amount, - "note": note + "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, int(target_user_id)).eligibility_token + eligibility_token = self.__get_eligibility_token( + amount, note, int(target_user_id) + ).eligibility_token body.update({"eligibility_token": eligibility_token}) body.update({"funding_source_id": funding_source_id}) - resource_path = '/payments' + resource_path = "/payments" - wrapped_callback = wrap_callback(callback=callback, - data_type=None) + wrapped_callback = wrap_callback(callback=callback, data_type=None) - result = self.__api_client.call_api(resource_path=resource_path, - method='POST', - body=body, - callback=wrapped_callback) + 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 = result["body"]["data"].get("error_code") if error_code: - if error_code == self.__payment_error_codes['not_enough_balance_error']: + if error_code == self.__payment_error_codes["not_enough_balance_error"]: raise NotEnoughBalanceError(amount, target_user_id) - error = result['body']['data'] + error = result["body"]["data"] raise GeneralPaymentError(f"{error.get('title')}\n{error.get('error_msg')}") if callback: diff --git a/venmo_api/utils/__init__.py b/venmo_api/utils/__init__.py index e69de29..bde2635 100644 --- a/venmo_api/utils/__init__.py +++ b/venmo_api/utils/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parents[2] diff --git a/venmo_api/utils/api_client.py b/venmo_api/utils/api_client.py index 719c786..be5eb9e 100644 --- a/venmo_api/utils/api_client.py +++ b/venmo_api/utils/api_client.py @@ -1,8 +1,17 @@ -from venmo_api import ResourceNotFoundError, InvalidHttpMethodError, HttpCodeError, validate_access_token +import threading from json import JSONDecodeError from typing import List + +import orjson import requests -import threading + +from venmo_api import ( + HttpCodeError, + InvalidHttpMethodError, + ResourceNotFoundError, + validate_access_token, +) +from venmo_api.utils import PROJECT_ROOT class ApiClient(object): @@ -14,6 +23,7 @@ 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) @@ -21,7 +31,9 @@ def __init__(self, access_token=None): self.access_token = access_token self.configuration = {"host": "https://api.venmo.com/v1"} - self.default_headers = {"User-Agent": "Venmo/10.50.0 (iPhone; iOS 18.0; Scale/3.0)"} + self.default_headers = orjson.loads( + (PROJECT_ROOT / "headers.json").read_bytes() + ) if self.access_token: self.default_headers.update({"Authorization": self.access_token}) @@ -33,13 +45,16 @@ def update_access_token(self, 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): - + 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. @@ -55,21 +70,33 @@ def call_api(self, resource_path: str, method: str, """ 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) + 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 = 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): + 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 @@ -89,7 +116,7 @@ def __call_api(self, resource_path, method, if body: header_params.update({"Content-Type": "application/json"}) - url = self.configuration['host'] + resource_path + url = self.configuration["host"] + resource_path # Use a new session for multi-threaded if callback: @@ -100,9 +127,15 @@ def __call_api(self, resource_path, method, 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) + processed_response = self.request( + method, + url, + session, + header_params=session.headers, + params=params, + body=body, + ok_error_codes=ok_error_codes, + ) self.last_response = processed_response @@ -111,11 +144,16 @@ def __call_api(self, resource_path, method, else: return processed_response - def request(self, method, url, session, - header_params=None, - params=None, - body=None, - ok_error_codes: List[int] = None): + 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: @@ -129,14 +167,17 @@ def request(self, method, url, session, :return: """ - if method not in ['POST', 'PUT', 'GET', 'DELETE']: + if method not in ["POST", "PUT", "GET", "DELETE"]: raise InvalidHttpMethodError() response = session.request( - method=method, url=url, headers=header_params, params=params, json=body) + 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) + validated_response = self.__validate_response( + response, ok_error_codes=ok_error_codes + ) return validated_response @@ -155,16 +196,27 @@ def __validate_response(response, ok_error_codes: List[int] = None): body = {} headers = {} - built_response = {"status_code": response.status_code, "headers": headers, "body": body} + 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: + 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: + 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/setup.py b/zsetup.py similarity index 100% rename from setup.py rename to zsetup.py From 4b0de5d13bfe4ea676d1ddd2cdf9abb20691ef15 Mon Sep 17 00:00:00 2001 From: joshhubert-dsp <102703352+joshhubert-dsp@users.noreply.github.com> Date: Fri, 7 Nov 2025 04:51:09 -0800 Subject: [PATCH 07/23] ruffed up --- pyproject.toml | 4 +- uv.lock | 92 ++++++++- venmo_api/__init__.py | 84 ++++++--- venmo_api/apis/auth_api.py | 152 +++++++++------ venmo_api/apis/user_api.py | 199 +++++++++++--------- venmo_api/models/base_model.py | 8 +- venmo_api/models/comment.py | 25 ++- venmo_api/models/eligibility_token.py | 2 +- venmo_api/models/exception.py | 61 ++++-- venmo_api/models/fee.py | 14 +- venmo_api/models/json_schema.py | 261 +++++++++++++------------- venmo_api/models/mention.py | 11 +- venmo_api/models/page.py | 5 +- venmo_api/models/payment.py | 33 +++- venmo_api/models/payment_method.py | 36 ++-- venmo_api/models/transaction.py | 86 ++++++--- venmo_api/models/user.py | 46 +++-- venmo_api/utils/api_client.py | 7 +- venmo_api/utils/api_util.py | 40 ++-- venmo_api/utils/logging_session.py | 64 +++++++ venmo_api/utils/model_util.py | 13 +- venmo_api/venmo.py | 16 +- 22 files changed, 813 insertions(+), 446 deletions(-) create mode 100644 venmo_api/utils/logging_session.py diff --git a/pyproject.toml b/pyproject.toml index 25a03fc..1bc3bf4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "venmo-api" -version = "0.4.0" +version = "1.0.0" description = "Venmo API client for Python" readme = "README.md" authors = [ @@ -12,6 +12,8 @@ license-files = [ ] requires-python = ">=3.12,<3.13" dependencies = [ + "devtools>=0.12.2", + "loguru>=0.7.3", "orjson>=3.11.3", "requests>=2.19.0", ] diff --git a/uv.lock b/uv.lock index d6ec421..ca5c250 100644 --- a/uv.lock +++ b/uv.lock @@ -1,7 +1,19 @@ version = 1 -revision = 2 +revision = 3 requires-python = "==3.12.*" +[[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" @@ -31,6 +43,38 @@ wheels = [ { 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 = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[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" @@ -40,6 +84,19 @@ 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 = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + [[package]] name = "orjson" version = "3.11.3" @@ -63,6 +120,15 @@ wheels = [ { 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" }, ] +[[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" @@ -78,6 +144,15 @@ 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 = "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 = "urllib3" version = "2.5.0" @@ -89,15 +164,28 @@ wheels = [ [[package]] name = "venmo-api" -version = "0.4.0" +version = "1.0.0" source = { editable = "." } dependencies = [ + { name = "devtools" }, + { name = "loguru" }, { name = "orjson" }, { name = "requests" }, ] [package.metadata] requires-dist = [ + { name = "devtools", specifier = ">=0.12.2" }, + { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.3" }, { name = "requests", specifier = ">=2.19.0" }, ] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] diff --git a/venmo_api/__init__.py b/venmo_api/__init__.py index af9b0f1..3d2fbdc 100644 --- a/venmo_api/__init__.py +++ b/venmo_api/__init__.py @@ -1,29 +1,69 @@ -from .utils.model_util import (string_to_timestamp, get_phone_model_from_json, random_device_id) -from .models.exception import * +from .apis.auth_api import AuthenticationApi +from .apis.payment_api import PaymentApi +from .apis.user_api import UserApi from .models.base_model import BaseModel +from .models.comment import Comment +from .models.exception import * 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 .models.page import Page -from .utils.api_util import (deserialize, wrap_callback, warn, get_user_id, confirm, validate_access_token) +from .models.payment import Payment, PaymentStatus +from .models.payment_method import PaymentMethod, PaymentPrivacy, PaymentRole +from .models.transaction import Transaction +from .models.user import User from .utils.api_client import ApiClient -from .apis.auth_api import AuthenticationApi -from .apis.payment_api import PaymentApi -from .apis.user_api import UserApi +from .utils.api_util import ( + confirm, + deserialize, + get_user_id, + validate_access_token, + warn, + wrap_callback, +) +from .utils.model_util import ( + get_phone_model_from_json, + random_device_id, + string_to_timestamp, +) 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__ = [ + "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", +] diff --git a/venmo_api/apis/auth_api.py b/venmo_api/apis/auth_api.py index 22fa22b..1e6a41a 100644 --- a/venmo_api/apis/auth_api.py +++ b/venmo_api/apis/auth_api.py @@ -1,8 +1,13 @@ -from venmo_api import random_device_id, warn, confirm, AuthenticationFailedError, ApiClient +from venmo_api import ( + ApiClient, + AuthenticationFailedError, + confirm, + random_device_id, + warn, +) class AuthenticationApi(object): - TWO_FACTOR_ERROR_CODE = 81109 def __init__(self, api_client: ApiClient = None, device_id: str = None): @@ -20,23 +25,26 @@ def login_with_credentials_cli(self, username: str, password: str) -> str: """ # 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.") + 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") + 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'): + if response.get("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.__device_id}") return access_token @@ -48,13 +56,12 @@ def log_out(access_token: str) -> bool: :return: """ - resource_path = '/oauth/access_token' + resource_path = "/oauth/access_token" api_client = ApiClient(access_token=access_token) - api_client.call_api(resource_path=resource_path, - method='DELETE') + api_client.call_api(resource_path=resource_path, method="DELETE") - confirm(f"Successfully logged out.") + confirm("Successfully logged out.") return True def __two_factor_process_cli(self, response: dict) -> str: @@ -64,10 +71,12 @@ def __two_factor_process_cli(self, response: dict) -> str: :return: access_token """ - otp_secret = response['headers'].get('venmo-otp-secret') + 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() @@ -77,7 +86,9 @@ def __two_factor_process_cli(self, response: dict) -> str: return access_token - def authenticate_using_username_password(self, username: str, password: str) -> dict: + 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: @@ -88,18 +99,25 @@ def authenticate_using_username_password(self, username: str, password: str) -> :return: """ - 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 - } - - 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]) + 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, + } + + 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], + ) def send_text_otp(self, otp_secret: str) -> dict: """ @@ -108,23 +126,30 @@ def send_text_otp(self, otp_secret: str) -> dict: :return: """ - resource_path = '/account/two-factor/token' - header_params = {'device-id': self.__device_id, - 'Content-Type': 'application/json', - 'venmo-otp-secret': otp_secret - } + 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') + response = self.__api_client.call_api( + resource_path=resource_path, + header_params=header_params, + body=body, + method="POST", + ) - if response['status_code'] != 200: + 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 @@ -136,17 +161,21 @@ def authenticate_using_otp(self, user_otp: str, otp_secret: str) -> str: :return: access_token """ - 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} - - response = self.__api_client.call_api(resource_path=resource_path, header_params=header_params, - params=params, - method='POST') - return response['body']['access_token'] + 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} + + 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): """ @@ -154,15 +183,17 @@ def trust_this_device(self, device_id=None): :return: """ device_id = device_id or self.__device_id - header_params = {'device-id': device_id} - resource_path = '/users/devices' + 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') + 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.") + confirm("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." + ) def get_device_id(self): return self.__device_id @@ -172,9 +203,10 @@ def set_access_token(self, access_token): @staticmethod 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/apis/user_api.py b/venmo_api/apis/user_api.py index d231608..ed70fe8 100644 --- a/venmo_api/apis/user_api.py +++ b/venmo_api/apis/user_api.py @@ -1,6 +1,7 @@ -from venmo_api import User, Page, Transaction, deserialize, wrap_callback, get_user_id from typing import List, Union +from venmo_api import Page, Transaction, User, deserialize, get_user_id, wrap_callback + class UserApi(object): def __init__(self, api_client): @@ -17,24 +18,32 @@ def get_my_profile(self, callback=None, force_update=False) -> Union[User, None] 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) + 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) + 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) + 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 search_for_users( + self, + query: str, + callback=None, + offset: int = 0, + limit: int = 50, + username=False, + ) -> Union[List[User], None]: """ search for [query] in users :param query: @@ -45,27 +54,29 @@ def search_for_users(self, query: str, callback=None, :return users_list: A list of objects or empty """ - resource_path = '/users' - wrapped_callback = wrap_callback(callback=callback, - data_type=User) + resource_path = "/users" + wrapped_callback = wrap_callback(callback=callback, data_type=User) - params = {'query': query, 'limit': limit, 'offset': offset} + 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'}) - - response = self.__api_client.call_api(resource_path=resource_path, params=params, - method='GET', callback=wrapped_callback) + 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 - 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, callback=None) -> Union[User, None]: """ @@ -76,13 +87,12 @@ def get_user(self, user_id: str, callback=None) -> Union[User, None]: """ # Prepare the request - resource_path = f'/users/{user_id}' - wrapped_callback = wrap_callback(callback=callback, - data_type=User) + 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) + response = self.__api_client.call_api( + resource_path=resource_path, method="GET", callback=wrapped_callback + ) # Return None if threaded if callback: return @@ -103,11 +113,14 @@ 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]: + 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 @@ -116,28 +129,33 @@ def get_user_friends_list(self, user_id: str = None, params = {"limit": limit, "offset": offset} # Prepare the request - resource_path = f'/users/{user_id}/friends' - wrapped_callback = wrap_callback(callback=callback, - data_type=User) + 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) + 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]: + 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: @@ -149,34 +167,39 @@ def get_user_transactions(self, user_id: str = None, user: User = None, """ user_id = get_user_id(user, user_id) - params = {'limit': limit} + params = {"limit": limit} if before_id: - params['before_id'] = before_id + params["before_id"] = before_id # Prepare the request - resource_path = f'/stories/target-or-actor/{user_id}' + resource_path = f"/stories/target-or-actor/{user_id}" - wrapped_callback = wrap_callback(callback=callback, - data_type=Transaction) + 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) + 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]: + 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]: """ Get the transactions between two users. Note that user_one must be the owner of the access token. Otherwise it raises an unauthorized error. @@ -192,24 +215,28 @@ def get_transaction_between_two_users(self, user_id_one: str = None, user_id_one = get_user_id(user_one, user_id_one) user_id_two = get_user_id(user_two, user_id_two) - params = {'limit': limit} + params = {"limit": limit} if before_id: - params['before_id'] = 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}' + resource_path = ( + f"/stories/target-or-actor/{user_id_one}/target-or-actor/{user_id_two}" + ) - wrapped_callback = wrap_callback(callback=callback, - data_type=Transaction) + 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) + 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}) + 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}, + ) diff --git a/venmo_api/models/base_model.py b/venmo_api/models/base_model.py index 3228181..332f04d 100644 --- a/venmo_api/models/base_model.py +++ b/venmo_api/models/base_model.py @@ -3,11 +3,13 @@ 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('_'))})" + 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())) + 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 index 396cf60..0eb8316 100644 --- a/venmo_api/models/comment.py +++ b/venmo_api/models/comment.py @@ -1,8 +1,7 @@ -from venmo_api import string_to_timestamp, BaseModel, User, Mention, JSONSchema +from venmo_api import BaseModel, JSONSchema, Mention, User, string_to_timestamp class Comment(BaseModel): - def __init__(self, id_, message, date_created, mentions, user, json=None): """ Comment model @@ -38,11 +37,17 @@ def from_json(cls, json): 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) + 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/eligibility_token.py b/venmo_api/models/eligibility_token.py index 112ef9e..d1a2c02 100644 --- a/venmo_api/models/eligibility_token.py +++ b/venmo_api/models/eligibility_token.py @@ -32,5 +32,5 @@ def from_json(cls, json): eligible=parser.get_eligible(), fees=fee_objects, fee_disclaimer=parser.get_fee_disclaimer(), - json=json + json=json, ) diff --git a/venmo_api/models/exception.py b/venmo_api/models/exception.py index a6136c2..dca904e 100644 --- a/venmo_api/models/exception.py +++ b/venmo_api/models/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,19 +48,25 @@ 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 "") + self.msg = msg or f"Invalid argument {argument_name} was passed. " + ( + reason or "" + ) super(InvalidArgumentError, self).__init__(self.msg) @@ -61,12 +74,15 @@ 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 "") + 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 +103,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 +119,17 @@ 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", + "InvalidArgumentError", + "InvalidHttpMethodError", + "ArgumentMissingError", + "JSONDecodeError", + "ResourceNotFoundError", + "HttpCodeError", + "NoPaymentMethodFoundError", + "AlreadyRemindedPaymentError", + "NoPendingPaymentToUpdateError", + "NotEnoughBalanceError", + "GeneralPaymentError", +] diff --git a/venmo_api/models/fee.py b/venmo_api/models/fee.py index 59ef580..64a2444 100644 --- a/venmo_api/models/fee.py +++ b/venmo_api/models/fee.py @@ -2,8 +2,16 @@ class Fee(BaseModel): - def __init__(self, product_uri, applied_to, base_fee_amount, fee_percentage, calculated_fee_amount_in_cents, - fee_token, json=None): + def __init__( + self, + product_uri, + applied_to, + base_fee_amount, + fee_percentage, + calculated_fee_amount_in_cents, + fee_token, + json=None, + ): super().__init__() self.product_uri = product_uri @@ -33,5 +41,5 @@ def from_json(cls, json): fee_percentage=parser.get_fee_percentage(), calculated_fee_amount_in_cents=parser.get_calculated_fee_amount_in_cents(), fee_token=parser.get_fee_token(), - json=json + json=json, ) diff --git a/venmo_api/models/json_schema.py b/venmo_api/models/json_schema.py index 6f74d27..2befb53 100644 --- a/venmo_api/models/json_schema.py +++ b/venmo_api/models/json_schema.py @@ -1,5 +1,4 @@ class JSONSchema: - @staticmethod def transaction(json): return TransactionParser(json=json) @@ -34,62 +33,65 @@ def fee(json): class TransactionParser: - def __init__(self, json): if not json: return self.json = json - self.payment = json.get(transaction_json_format['payment']) + self.payment = json.get(transaction_json_format["payment"]) def get_story_id(self): - return self.json.get(transaction_json_format['story_id']) + return self.json.get(transaction_json_format["story_id"]) def get_date_created(self): - return self.json.get(transaction_json_format['date_created']) + return self.json.get(transaction_json_format["date_created"]) def get_date_updated(self): - return self.json.get(transaction_json_format['date_updated']) + return self.json.get(transaction_json_format["date_updated"]) def get_actor_app(self): - return self.json.get(transaction_json_format['app']) + return self.json.get(transaction_json_format["app"]) def get_audience(self): - return self.json.get(transaction_json_format['aud']) + return self.json.get(transaction_json_format["aud"]) def get_likes(self): - return self.json.get(transaction_json_format['likes']) + 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 + 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']) + return self.json.get(transaction_json_format["transaction_type"]) def get_payment_id(self): - return self.payment.get(payment_json_format['payment_id']) + return self.payment.get(payment_json_format["payment_id"]) def get_type(self): - return self.payment.get(payment_json_format['type']) + return self.payment.get(payment_json_format["type"]) def get_date_completed(self): - return self.payment.get(payment_json_format['date_completed']) + return self.payment.get(payment_json_format["date_completed"]) def get_story_note(self): - return self.payment.get(payment_json_format['note']) + return self.payment.get(payment_json_format["note"]) def get_actor(self): - return self.payment.get(payment_json_format['actor']) + return self.payment.get(payment_json_format["actor"]) def get_target(self): - return self.payment.get(payment_json_format['target']).get('user') + return self.payment.get(payment_json_format["target"]).get("user") def get_status(self): - return self.payment.get(payment_json_format['status']) + return self.payment.get(payment_json_format["status"]) def get_amount(self): - return self.payment.get(payment_json_format['amount']) + return self.payment.get(payment_json_format["amount"]) transaction_json_format = { @@ -103,7 +105,7 @@ def get_amount(self): "comments": "comments", "comments_list": "data", "likes": "likes", - "transaction_type": "type" + "transaction_type": "type", } payment_json_format = { "status": "status", @@ -112,15 +114,13 @@ def get_amount(self): "target": "target", "actor": "actor", "note": "note", - 'type': 'action', - 'amount': 'amount' + "type": "action", + "amount": "amount", } class UserParser: - def __init__(self, json, is_profile=False): - if not json: return @@ -133,176 +133,177 @@ def __init__(self, json, is_profile=False): self.parser = user_json_format def get_user_id(self): - return self.json.get(self.parser.get('user_id')) + return self.json.get(self.parser.get("user_id")) def get_username(self): - return self.json.get(self.parser.get('username')) + return self.json.get(self.parser.get("username")) def get_first_name(self): - return self.json.get(self.parser.get('first_name')) + return self.json.get(self.parser.get("first_name")) def get_last_name(self): - return self.json.get(self.parser.get('last_name')) + return self.json.get(self.parser.get("last_name")) def get_full_name(self): - return self.json.get(self.parser.get('full_name')) + return self.json.get(self.parser.get("full_name")) def get_phone(self): - return self.json.get(self.parser.get('phone')) + return self.json.get(self.parser.get("phone")) def get_picture_url(self): - return self.json.get(self.parser.get('picture_url')) + return self.json.get(self.parser.get("picture_url")) def get_about(self): - return self.json.get(self.parser.get('about')) + return self.json.get(self.parser.get("about")) def get_date_created(self): - return self.json.get(self.parser.get('date_created')) + 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')) + 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')) + 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' + "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' + "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']) + 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']) + 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']) + return self.json.get(payment_method_json_format["name"]) def get_payment_method_type(self): - return self.json.get(payment_method_json_format['type']) + return self.json.get(payment_method_json_format["type"]) -payment_method_json_format = {'id': 'id', - 'payment_role': 'peer_payment_role', - 'name': 'name', - 'type': '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']) + return self.json.get(payment_request_json_format["id"]) def get_actor(self): - return self.json.get(payment_request_json_format['actor']) + 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']) + 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']) + return self.json.get(payment_request_json_format["action"]) def get_amount(self): - return self.json.get(payment_request_json_format['amount']) + return self.json.get(payment_request_json_format["amount"]) def get_audience(self): - return self.json.get(payment_request_json_format['audience']) + return self.json.get(payment_request_json_format["audience"]) def get_date_authorized(self): - return self.json.get(payment_request_json_format['date_authorized']) + 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']) + 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']) + 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']) + return self.json.get(payment_request_json_format["date_reminded"]) def get_note(self): - return self.json.get(payment_request_json_format['note']) + return self.json.get(payment_request_json_format["note"]) def get_status(self): - return self.json.get(payment_request_json_format['status']) + 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' + "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']) + return self.json.get(comment_json_format["date_created"]) def get_message(self): - return self.json.get(comment_json_format['message']) + 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 + 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']) + return self.json.get(comment_json_format["id"]) def get_user(self): - return self.json.get(comment_json_format['user']) + return self.json.get(comment_json_format["user"]) comment_json_format = { @@ -312,77 +313,77 @@ def get_user(self): "mentions": "mentions", "mentions_list": "data", "id": "id", - "user": "user" + "user": "user", } class MentionParser: - def __init__(self, json): self.json = json def get_username(self): - return self.json.get(mention_json_format['username']) + return self.json.get(mention_json_format["username"]) def get_user(self): - return self.json.get(mention_json_format['user']) + return self.json.get(mention_json_format["user"]) -mention_json_format = { - "username": "username", - "user": "user" -} +mention_json_format = {"username": "username", "user": "user"} + class EligibilityTokenParser: def __init__(self, json): self.json = json def get_eligibility_token(self): - return self.json.get(eligibility_token_json_format['eligibility_token']) + return self.json.get(eligibility_token_json_format["eligibility_token"]) def get_eligible(self): - return self.json.get(eligibility_token_json_format['eligible']) + return self.json.get(eligibility_token_json_format["eligible"]) def get_fees(self): - return self.json.get(eligibility_token_json_format['fees']) + return self.json.get(eligibility_token_json_format["fees"]) def get_fee_disclaimer(self): - return self.json.get(eligibility_token_json_format['fee_disclaimer']) + return self.json.get(eligibility_token_json_format["fee_disclaimer"]) + eligibility_token_json_format = { - 'eligibility_token': 'eligibility_token', - 'eligible': 'eligible', - 'fees': 'fees', - 'fee_disclaimer': 'fee_disclaimer' + "eligibility_token": "eligibility_token", + "eligible": "eligible", + "fees": "fees", + "fee_disclaimer": "fee_disclaimer", } + class FeeParser: def __init__(self, json): self.json = json def get_product_uri(self): - return self.json.get(fee_json_format['product_uri']) + return self.json.get(fee_json_format["product_uri"]) def get_applied_to(self): - return self.json.get(fee_json_format['applied_to']) + return self.json.get(fee_json_format["applied_to"]) def get_base_fee_amount(self): - return self.json.get(fee_json_format['base_fee_amount']) + return self.json.get(fee_json_format["base_fee_amount"]) def get_fee_percentage(self): - return self.json.get(fee_json_format['fee_percentage']) + return self.json.get(fee_json_format["fee_percentage"]) def get_calculated_fee_amount_in_cents(self): - return self.json.get(fee_json_format['calculated_fee_amount_in_cents']) + return self.json.get(fee_json_format["calculated_fee_amount_in_cents"]) def get_fee_token(self): - return self.json.get(fee_json_format['fee_token']) + return self.json.get(fee_json_format["fee_token"]) + fee_json_format = { - 'product_uri': 'product_uri', - 'applied_to': 'applied_to', - 'base_fee_amount': 'base_fee_amount', - 'fee_percentage': 'fee_percentage', - 'calculated_fee_amount_in_cents': 'calculated_fee_amount_in_cents', - 'fee_token': 'fee_token' -} \ No newline at end of file + "product_uri": "product_uri", + "applied_to": "applied_to", + "base_fee_amount": "base_fee_amount", + "fee_percentage": "fee_percentage", + "calculated_fee_amount_in_cents": "calculated_fee_amount_in_cents", + "fee_token": "fee_token", +} diff --git a/venmo_api/models/mention.py b/venmo_api/models/mention.py index 71d9c2e..8702723 100644 --- a/venmo_api/models/mention.py +++ b/venmo_api/models/mention.py @@ -1,8 +1,7 @@ -from venmo_api import BaseModel, User, JSONSchema +from venmo_api import BaseModel, JSONSchema, User class Mention(BaseModel): - def __init__(self, username, user, json=None): """ Mention model @@ -29,6 +28,8 @@ def from_json(cls, json): parser = JSONSchema.mention(json) - return cls(username=parser.get_username(), - user=User.from_json(parser.get_user()), - json=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..b5a967c 100644 --- a/venmo_api/models/page.py +++ b/venmo_api/models/page.py @@ -1,5 +1,4 @@ class Page(list): - def __init__(self): super().__init__() self.method = None @@ -29,8 +28,8 @@ def get_next_page(self): # 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..8c1c4fe 100644 --- a/venmo_api/models/payment.py +++ b/venmo_api/models/payment.py @@ -1,11 +1,24 @@ -from venmo_api import string_to_timestamp, User, BaseModel, JSONSchema from enum import Enum +from venmo_api import BaseModel, JSONSchema, User, string_to_timestamp -class Payment(BaseModel): - def __init__(self, id_, actor, target, action, amount, audience, date_created, date_reminded, date_completed, - note, status, json=None): +class Payment(BaseModel): + def __init__( + self, + id_, + actor, + target, + action, + amount, + audience, + date_created, + date_reminded, + date_completed, + note, + status, + json=None, + ): """ Payment model :param id_: @@ -59,13 +72,13 @@ def from_json(cls, json): date_completed=string_to_timestamp(parser.get_date_completed()), note=parser.get_note(), status=PaymentStatus(parser.get_status()), - json=json + json=json, ) class PaymentStatus(Enum): - SETTLED = 'settled' - CANCELLED = 'cancelled' - PENDING = 'pending' - FAILED = 'failed' - EXPIRED = 'expired' + SETTLED = "settled" + CANCELLED = "cancelled" + PENDING = "pending" + FAILED = "failed" + EXPIRED = "expired" diff --git a/venmo_api/models/payment_method.py b/venmo_api/models/payment_method.py index c4ad79f..8a5f4be 100644 --- a/venmo_api/models/payment_method.py +++ b/venmo_api/models/payment_method.py @@ -1,7 +1,8 @@ -from venmo_api import JSONSchema, BaseModel -from typing import Dict -from enum import Enum import logging +from enum import Enum +from typing import Dict + +from venmo_api import BaseModel, JSONSchema class PaymentMethod(BaseModel): @@ -24,7 +25,6 @@ def __init__(self, pid: str, p_role: str, p_name: str, p_type: str, json=None): @classmethod def from_json(cls, json: Dict): - payment_parser = JSONSchema.payment_method(json) pid = payment_parser.get_id() @@ -35,14 +35,14 @@ def from_json(cls, json: Dict): # 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}") + 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) + return payment_class( + pid=pid, p_role=p_role, p_name=p_name, p_type=p_type, json=json + ) class VenmoBalance(PaymentMethod, BaseModel): @@ -54,20 +54,22 @@ 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' + DEFAULT = "default" + BACKUP = "backup" + NONE = "none" class PaymentPrivacy(Enum): - PRIVATE = 'private' - PUBLIC = 'public' - FRIENDS = 'friends' + PRIVATE = "private" + PUBLIC = "public" + FRIENDS = "friends" -payment_type = {'bank': BankAccount, 'balance': VenmoBalance, 'card': Card} +payment_type = {"bank": BankAccount, "balance": VenmoBalance, "card": Card} diff --git a/venmo_api/models/transaction.py b/venmo_api/models/transaction.py index 08040a4..12fce3d 100644 --- a/venmo_api/models/transaction.py +++ b/venmo_api/models/transaction.py @@ -1,12 +1,34 @@ -from venmo_api import string_to_timestamp, BaseModel, User, Comment, get_phone_model_from_json, JSONSchema from enum import Enum +from venmo_api import ( + BaseModel, + Comment, + JSONSchema, + User, + get_phone_model_from_json, + string_to_timestamp, +) -class Transaction(BaseModel): - 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): +class Transaction(BaseModel): + 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: @@ -74,36 +96,42 @@ def from_json(cls, json): 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) + 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, + ) class TransactionType(Enum): - PAYMENT = 'payment' + PAYMENT = "payment" # merchant refund - REFUND = 'refund' + REFUND = "refund" # to/from bank account - TRANSFER = 'transfer' + TRANSFER = "transfer" # add money to debit card - TOP_UP = 'top_up' + 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" diff --git a/venmo_api/models/user.py b/venmo_api/models/user.py index b333015..5ba0611 100644 --- a/venmo_api/models/user.py +++ b/venmo_api/models/user.py @@ -1,10 +1,22 @@ -from venmo_api import string_to_timestamp, BaseModel, JSONSchema +from venmo_api import BaseModel, JSONSchema, string_to_timestamp 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): + 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: @@ -51,15 +63,17 @@ def from_json(cls, json, is_profile=False): 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) + 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, + ) diff --git a/venmo_api/utils/api_client.py b/venmo_api/utils/api_client.py index be5eb9e..db375c0 100644 --- a/venmo_api/utils/api_client.py +++ b/venmo_api/utils/api_client.py @@ -1,3 +1,4 @@ +import os import threading from json import JSONDecodeError from typing import List @@ -12,6 +13,7 @@ validate_access_token, ) from venmo_api.utils import PROJECT_ROOT +from venmo_api.utils.logging_session import LoggingSession class ApiClient(object): @@ -37,7 +39,10 @@ def __init__(self, access_token=None): if self.access_token: self.default_headers.update({"Authorization": self.access_token}) - self.session = requests.Session() + if os.getenv("LOGGING_SESSION"): + self.session = LoggingSession() + else: + self.session = requests.Session() self.session.headers.update(self.default_headers) def update_access_token(self, access_token): diff --git a/venmo_api/utils/api_util.py b/venmo_api/utils/api_util.py index c081ecf..6974c99 100644 --- a/venmo_api/utils/api_util.py +++ b/venmo_api/utils/api_util.py @@ -1,7 +1,8 @@ -from venmo_api import ArgumentMissingError, User, Page +import re from enum import Enum from typing import Dict, List -import re + +from venmo_api import ArgumentMissingError, Page, User def validate_access_token(access_token): @@ -10,11 +11,11 @@ def validate_access_token(access_token): :param access_token: :return: """ - token_re = r'^(Bearer)?(.+)$' + token_re = r"^(Bearer)?(.+)$" if not access_token: return - access_token = re.findall(token_re, access_token)[0][1].replace(' ', '') + access_token = re.findall(token_re, access_token)[0][1].replace(" ", "") return f"Bearer {access_token}" @@ -27,11 +28,11 @@ def deserialize(response: Dict, data_type, nested_response: List[str] = None): :return: a single or a of objects (Objects can be User/Transaction/Payment/PaymentMethod) """ - body = response.get('body') + body = response.get("body") if not body: raise Exception("Can't get an empty response body.") - data = body.get('data') + data = body.get("data") nested_response = nested_response or [] for nested in nested_response: temp = data.get(nested) @@ -57,11 +58,12 @@ def wrap_callback(callback, data_type, nested_response: List[str] = None): 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) + deserialized_data = deserialize( + response=response, data_type=data_type, nested_response=nested_response + ) return callback(deserialized_data) return wrapper @@ -84,14 +86,14 @@ def __get_objs_from_json_list(json_list, data_type): 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' + 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): @@ -120,11 +122,13 @@ def get_user_id(user, user_id): :return user_id: """ if not user and not user_id: - raise ArgumentMissingError(arguments=('target_user_id', 'target_user')) + 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)}") + raise ArgumentMissingError( + f"Expected {User} for target_user, but received {type(user)}" + ) user_id = user.id diff --git a/venmo_api/utils/logging_session.py b/venmo_api/utils/logging_session.py new file mode 100644 index 0000000..9f59751 --- /dev/null +++ b/venmo_api/utils/logging_session.py @@ -0,0 +1,64 @@ +import orjson +from devtools import pformat +from loguru import logger +from requests import PreparedRequest, Response, Session + +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): + if b is None: + return "" + 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): + def send(self, request: PreparedRequest, **kwargs) -> Response: + logger.info(f"→ {request.method} {request.url}") + logger.debug(f"→ Request headers: {pformat(dict(request.headers))}") + # request.body may be bytes, str, file-like, or generator. Try to show it safely. + body = request.body + # if hasattr(body, "read"): + # try: + # pos = body.tell() + # body.seek(0) + # content = body.read() + # body.seek(pos) + # logger.debug(f"→ Request body (file-like): {safe_text(content)}") + # except Exception: + # logger.debug("→ Request body: (file-like, unreadable)") + # else: + if isinstance(body, str): + logger.debug(f"→ Request body (str): {pformat(safe_text(body.encode()))}") + elif isinstance(body, bytes): + logger.debug(f"→ Request body (bytes): {pformat(safe_text(body))}") + elif body is None: + logger.debug("→ Request body: ") + else: + # could be generator/iterable (multipart streaming) + logger.debug(f"→ Request body: (type={type(body).__name__}) {repr(body)}") + + resp = super().send(request, **kwargs) + + logger.info(f"← {resp.status_code} {resp.reason}") + logger.debug(f"← Response headers: {pformat(dict(resp.headers))}") + + # careful: .content will load the whole response into memory + try: + content = resp.content + logger.debug(f"← Response body: {pformat(safe_text(content))}") + except Exception as e: + logger.debug(f"← Response body: ") + + return resp diff --git a/venmo_api/utils/model_util.py b/venmo_api/utils/model_util.py index 879e4c1..1fcfc53 100644 --- a/venmo_api/utils/model_util.py +++ b/venmo_api/utils/model_util.py @@ -1,5 +1,5 @@ from datetime import datetime -from random import randint, choice +from random import choice, randint from string import ascii_uppercase @@ -12,10 +12,10 @@ def string_to_timestamp(utc): if not utc: return try: - _date = datetime.strptime(utc, '%Y-%m-%dT%H:%M:%S') + _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') + _date = datetime.strptime(utc, "%Y-%m-%dT%H:%M:%S.%f") return int(_date.timestamp()) @@ -28,7 +28,7 @@ def get_phone_model_from_json(app_json): app = {1: "iPhone", 4: "Android", 0: "Other"} _id = 0 if app_json: - _id = app_json['id'] + _id = app_json["id"] return app.get(int(_id)) @@ -42,11 +42,10 @@ def random_device_id(): result = [] for char in BASE_DEVICE_ID: - if char.isdigit(): result.append(str(randint(0, 9))) - elif char == '-': - result.append('-') + elif char == "-": + result.append("-") else: result.append(choice(ascii_uppercase)) diff --git a/venmo_api/venmo.py b/venmo_api/venmo.py index a8ea1fb..938f153 100644 --- a/venmo_api/venmo.py +++ b/venmo_api/venmo.py @@ -1,8 +1,13 @@ -from venmo_api import ApiClient, UserApi, PaymentApi, AuthenticationApi, validate_access_token +from venmo_api import ( + ApiClient, + AuthenticationApi, + PaymentApi, + UserApi, + validate_access_token, +) class Client(object): - def __init__(self, access_token: str): """ VenmoAPI Client @@ -13,8 +18,7 @@ def __init__(self, access_token: str): 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) + self.payment = PaymentApi(profile=self.__profile, api_client=self.__api_client) def my_profile(self, force_update=False): """ @@ -37,7 +41,9 @@ def get_access_token(username: str, password: str, device_id: str = None) -> str :return: access_token """ authn_api = AuthenticationApi(api_client=ApiClient(), device_id=device_id) - return authn_api.login_with_credentials_cli(username=username, password=password) + return authn_api.login_with_credentials_cli( + username=username, password=password + ) @staticmethod def log_out(access_token) -> bool: From 22a759f3d841bc695eabab24010a7dfc78de9169 Mon Sep 17 00:00:00 2001 From: joshhubert-dsp <102703352+joshhubert-dsp@users.noreply.github.com> Date: Sun, 9 Nov 2025 04:17:48 -0800 Subject: [PATCH 08/23] cleanup --- .gitignore | 1 - default_headers.json | 8 ++++++ docs/conf.py | 23 +++++++++--------- requirements.txt | 1 - venmo_api/__init__.py | 39 ++++++++++++------------------ venmo_api/apis/payment_api.py | 23 ++++-------------- venmo_api/apis/user_api.py | 16 ++++++------ venmo_api/models/base_model.py | 3 +++ venmo_api/models/payment_method.py | 3 +-- venmo_api/utils/api_util.py | 27 ++++----------------- venmo_api/utils/logging_session.py | 11 --------- 11 files changed, 56 insertions(+), 99 deletions(-) create mode 100644 default_headers.json delete mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index f669f73..9209816 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -headers.json .idea/ diff --git a/default_headers.json b/default_headers.json new file mode 100644 index 0000000..0b65d51 --- /dev/null +++ b/default_headers.json @@ -0,0 +1,8 @@ +{ + "Host": "api.venmo.com", + "User-Agent": "Venmo/10.77.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/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/venmo_api/__init__.py b/venmo_api/__init__.py index 3d2fbdc..cd99998 100644 --- a/venmo_api/__init__.py +++ b/venmo_api/__init__.py @@ -1,30 +1,24 @@ -from .apis.auth_api import AuthenticationApi -from .apis.payment_api import PaymentApi -from .apis.user_api import UserApi -from .models.base_model import BaseModel -from .models.comment import Comment +# ruff: noqa: I001 +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.page import Page -from .models.payment import Payment, PaymentStatus -from .models.payment_method import PaymentMethod, PaymentPrivacy, PaymentRole +from .models.comment import Comment from .models.transaction import Transaction -from .models.user import User +from .models.payment import Payment, PaymentStatus +from .models.payment_method import PaymentMethod, PaymentRole, PaymentPrivacy +from .models.page import Page +from .utils.api_util import deserialize, wrap_callback, warn, get_user_id, confirm from .utils.api_client import ApiClient -from .utils.api_util import ( - confirm, - deserialize, - get_user_id, - validate_access_token, - warn, - wrap_callback, -) -from .utils.model_util import ( - get_phone_model_from_json, - random_device_id, - string_to_timestamp, -) +from .apis.auth_api import AuthenticationApi +from .apis.payment_api import PaymentApi +from .apis.user_api import UserApi from .venmo import Client __all__ = [ @@ -48,7 +42,6 @@ "warn", "confirm", "get_user_id", - "validate_access_token", "JSONSchema", "User", "Mention", diff --git a/venmo_api/apis/payment_api.py b/venmo_api/apis/payment_api.py index 8eb7827..92bf58f 100644 --- a/venmo_api/apis/payment_api.py +++ b/venmo_api/apis/payment_api.py @@ -1,5 +1,4 @@ import uuid -from typing import List, Union from venmo_api import ( AlreadyRemindedPaymentError, @@ -94,7 +93,7 @@ def cancel_payment(self, payment: Payment = None, payment_id: int = None) -> boo raise NoPendingPaymentToUpdateError(payment_id=payment_id, action=action) return True - def get_payment_methods(self, callback=None) -> Union[List[PaymentMethod], None]: + def get_payment_methods(self, callback=None) -> list[PaymentMethod] | None: """ Get a list of available payment_methods :param callback: @@ -122,7 +121,7 @@ def send_money( target_user: User = None, privacy_setting: PaymentPrivacy = PaymentPrivacy.PRIVATE, callback=None, - ) -> Union[bool, None]: + ) -> bool | None: """ 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. @@ -155,7 +154,7 @@ def request_money( privacy_setting: PaymentPrivacy = PaymentPrivacy.PRIVATE, target_user: User = None, callback=None, - ) -> Union[bool, None]: + ) -> bool | None: """ Request [amount] money with [note] from the ([target_user_id] or [target_user]) :param amount: amount of money to be requested @@ -265,7 +264,7 @@ def __send_or_request_money( target_user: User = None, eligibility_token: str = None, callback=None, - ) -> Union[bool, None]: + ) -> bool | None: """ Generic method for sending and requesting money :param amount: @@ -285,20 +284,8 @@ def __send_or_request_money( if not is_send_money: amount = -amount - uid = uuid.uuid4().hex - uid_dashed = ( - uid[:8] - + "-" - + uid[8:12] - + "-" - + uid[12:16] - + "-" - + uid[16:20] - + "-" - + uid[20:32] - ) body = { - "uuid": uid_dashed, + "uuid": str(uuid.uuid4()), "user_id": target_user_id, "audience": privacy_setting, "amount": amount, diff --git a/venmo_api/apis/user_api.py b/venmo_api/apis/user_api.py index ed70fe8..59e43ba 100644 --- a/venmo_api/apis/user_api.py +++ b/venmo_api/apis/user_api.py @@ -1,5 +1,3 @@ -from typing import List, Union - from venmo_api import Page, Transaction, User, deserialize, get_user_id, wrap_callback @@ -9,7 +7,7 @@ def __init__(self, api_client): self.__api_client = api_client self.__profile = None - def get_my_profile(self, callback=None, force_update=False) -> Union[User, None]: + def get_my_profile(self, callback=None, force_update=False) -> User | None: """ Get my profile info and return as a :return my_profile: @@ -43,7 +41,7 @@ def search_for_users( offset: int = 0, limit: int = 50, username=False, - ) -> Union[List[User], None]: + ) -> list[User] | None: """ search for [query] in users :param query: @@ -78,7 +76,7 @@ def search_for_users( current_offset=offset, ) - def get_user(self, user_id: str, callback=None) -> Union[User, None]: + def get_user(self, user_id: str, callback=None) -> User | None: """ Get the user profile with [user_id] :param user_id: , example: '2859950549165568970' @@ -99,7 +97,7 @@ def get_user(self, user_id: str, callback=None) -> Union[User, None]: return deserialize(response=response, data_type=User) - def get_user_by_username(self, username: str) -> Union[User, None]: + def get_user_by_username(self, username: str) -> User | None: """ Get the user profile with [username] :param username: @@ -120,7 +118,7 @@ def get_user_friends_list( callback=None, offset: int = 0, limit: int = 3337, - ) -> Union[Page, None]: + ) -> 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 @@ -155,7 +153,7 @@ def get_user_transactions( callback=None, limit: int = 50, before_id=None, - ) -> Union[Page, None]: + ) -> Page | None: """ Get ([user_id]'s or [user]'s) transactions visible to yourself as a list of s :param user_id: @@ -199,7 +197,7 @@ def get_transaction_between_two_users( callback=None, limit: int = 50, before_id=None, - ) -> Union[Page, None]: + ) -> Page | None: """ Get the transactions between two users. Note that user_one must be the owner of the access token. Otherwise it raises an unauthorized error. diff --git a/venmo_api/models/base_model.py b/venmo_api/models/base_model.py index 332f04d..d7dc3db 100644 --- a/venmo_api/models/base_model.py +++ b/venmo_api/models/base_model.py @@ -1,3 +1,6 @@ +# TODO Pydantic V2! + + class BaseModel(object): def __init__(self): self._json = None diff --git a/venmo_api/models/payment_method.py b/venmo_api/models/payment_method.py index 8a5f4be..b0e0bfe 100644 --- a/venmo_api/models/payment_method.py +++ b/venmo_api/models/payment_method.py @@ -1,6 +1,5 @@ import logging from enum import Enum -from typing import Dict from venmo_api import BaseModel, JSONSchema @@ -24,7 +23,7 @@ def __init__(self, pid: str, p_role: str, p_name: str, p_type: str, json=None): self._json = json @classmethod - def from_json(cls, json: Dict): + def from_json(cls, json: dict): payment_parser = JSONSchema.payment_method(json) pid = payment_parser.get_id() diff --git a/venmo_api/utils/api_util.py b/venmo_api/utils/api_util.py index 6974c99..85317f3 100644 --- a/venmo_api/utils/api_util.py +++ b/venmo_api/utils/api_util.py @@ -1,30 +1,13 @@ -import re from enum import Enum -from typing import Dict, List from venmo_api import ArgumentMissingError, Page, User -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): +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 response: :param data_type: - :param nested_response: Optional. Loop through the body + :param nested_response: Optional. Loop through the body :return: a single or a of objects (Objects can be User/Transaction/Payment/PaymentMethod) """ @@ -47,11 +30,11 @@ def deserialize(response: Dict, data_type, nested_response: List[str] = None): return data_type.from_json(json=data) -def wrap_callback(callback, data_type, nested_response: List[str] = None): +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 + :param nested_response: Optional. Loop through the body :return wrapped_callback: or The user callback wrapped for json parsing. """ if not callback: diff --git a/venmo_api/utils/logging_session.py b/venmo_api/utils/logging_session.py index 9f59751..a2d3c57 100644 --- a/venmo_api/utils/logging_session.py +++ b/venmo_api/utils/logging_session.py @@ -27,18 +27,7 @@ class LoggingSession(Session): def send(self, request: PreparedRequest, **kwargs) -> Response: logger.info(f"→ {request.method} {request.url}") logger.debug(f"→ Request headers: {pformat(dict(request.headers))}") - # request.body may be bytes, str, file-like, or generator. Try to show it safely. body = request.body - # if hasattr(body, "read"): - # try: - # pos = body.tell() - # body.seek(0) - # content = body.read() - # body.seek(pos) - # logger.debug(f"→ Request body (file-like): {safe_text(content)}") - # except Exception: - # logger.debug("→ Request body: (file-like, unreadable)") - # else: if isinstance(body, str): logger.debug(f"→ Request body (str): {pformat(safe_text(body.encode()))}") elif isinstance(body, bytes): From a44e2ce734dbd7839cad1abcf17f4197b623dcbe Mon Sep 17 00:00:00 2001 From: joshhubert-dsp <102703352+joshhubert-dsp@users.noreply.github.com> Date: Mon, 10 Nov 2025 20:52:57 -0800 Subject: [PATCH 09/23] eligibility token fix --- venmo_api/apis/payment_api.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/venmo_api/apis/payment_api.py b/venmo_api/apis/payment_api.py index 92bf58f..5b8b34c 100644 --- a/venmo_api/apis/payment_api.py +++ b/venmo_api/apis/payment_api.py @@ -180,13 +180,12 @@ def __get_eligibility_token( self, amount: float, note: str, - target_id: int = None, - funding_source_id: str = None, + target_id: str, action: str = "pay", country_code: str = "1", target_type: str = "user_id", callback=None, - ): + ) -> EligibilityToken: """ Generate eligibility token which is needed in payment requests :param amount: amount of money to be requested @@ -199,15 +198,13 @@ def __get_eligibility_token( """ resource_path = "/protection/eligibility" body = { - "funding_source_id": self.get_default_payment_method().id - if not funding_source_id - else funding_source_id, + "funding_source_id": "", "action": action, "country_code": country_code, "target_type": target_type, "note": note, - "target_id": get_user_id(user=None, user_id=target_id), - "amount": amount, + "target_id": target_id, + "amount": round(amount * 100), } response = self.__api_client.call_api( @@ -297,7 +294,7 @@ def __send_or_request_money( funding_source_id = self.get_default_payment_method().id if not eligibility_token: eligibility_token = self.__get_eligibility_token( - amount, note, int(target_user_id) + amount, note, target_user_id ).eligibility_token body.update({"eligibility_token": eligibility_token}) From 932b8ff5ee4a26988c2005d82f90357e4a6ccd00 Mon Sep 17 00:00:00 2001 From: joshhubert-dsp <102703352+joshhubert-dsp@users.noreply.github.com> Date: Mon, 10 Nov 2025 20:54:40 -0800 Subject: [PATCH 10/23] Client/ApiClient cleanup --- venmo_api/apis/auth_api.py | 20 +++---- venmo_api/utils/api_client.py | 66 +++++++++++++---------- venmo_api/utils/logging_session.py | 20 +++---- venmo_api/utils/model_util.py | 28 +++++----- venmo_api/venmo.py | 84 ++++++++++++++++++++---------- 5 files changed, 127 insertions(+), 91 deletions(-) diff --git a/venmo_api/apis/auth_api.py b/venmo_api/apis/auth_api.py index 1e6a41a..7ffa649 100644 --- a/venmo_api/apis/auth_api.py +++ b/venmo_api/apis/auth_api.py @@ -7,14 +7,18 @@ ) +# NOTE: it seems a device-id is required for payments now, so ApiClient should probably +# own it class AuthenticationApi(object): TWO_FACTOR_ERROR_CODE = 81109 - def __init__(self, api_client: ApiClient = None, device_id: str = None): + def __init__( + self, api_client: ApiClient | None = None, device_id: str | None = None + ): super().__init__() self.__device_id = device_id or random_device_id() - self.__api_client = api_client or ApiClient() + self.__api_client = api_client or ApiClient(device_id=self.__device_id) def login_with_credentials_cli(self, username: str, password: str) -> str: """ @@ -100,11 +104,7 @@ def authenticate_using_username_password( """ resource_path = "/oauth/access_token" - header_params = { - "device-id": self.__device_id, - "Content-Type": "application/json", - "Host": "api.venmo.com", - } + header_params = {"device-id": self.__device_id, "Host": "api.venmo.com"} body = { "phone_email_or_username": username, "client_id": "1", @@ -127,11 +127,7 @@ def send_text_otp(self, otp_secret: str) -> dict: """ resource_path = "/account/two-factor/token" - header_params = { - "device-id": self.__device_id, - "Content-Type": "application/json", - "venmo-otp-secret": otp_secret, - } + header_params = {"device-id": self.__device_id, "venmo-otp-secret": otp_secret} body = {"via": "sms"} response = self.__api_client.call_api( diff --git a/venmo_api/utils/api_client.py b/venmo_api/utils/api_client.py index db375c0..dd48d8d 100644 --- a/venmo_api/utils/api_client.py +++ b/venmo_api/utils/api_client.py @@ -1,7 +1,7 @@ import os import threading from json import JSONDecodeError -from typing import List +from random import getrandbits import orjson import requests @@ -10,7 +10,6 @@ HttpCodeError, InvalidHttpMethodError, ResourceNotFoundError, - validate_access_token, ) from venmo_api.utils import PROJECT_ROOT from venmo_api.utils.logging_session import LoggingSession @@ -21,34 +20,47 @@ class ApiClient(object): Generic API Client for the Venmo API """ - def __init__(self, access_token=None): + def __init__(self, access_token: str | None = None, device_id: str | None = None): """ - :param access_token: access token you received for your account. + :param access_token: access token you received for your account, not + including the 'Bearer ' prefix, that's added to the request header. """ 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 = orjson.loads( - (PROJECT_ROOT / "headers.json").read_bytes() + (PROJECT_ROOT / "default_headers.json").read_bytes() ) - if self.access_token: - self.default_headers.update({"Authorization": self.access_token}) - if os.getenv("LOGGING_SESSION"): self.session = LoggingSession() else: 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}) + self.access_token = access_token + if access_token: + self.update_access_token(access_token) + self.device_id = device_id + if 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, @@ -58,7 +70,7 @@ def call_api( params: dict = None, body: dict = None, callback=None, - ok_error_codes: List[int] = None, + ok_error_codes: list[int] = None, ): """ Makes the HTTP request (Synchronous) and return the deserialized data. @@ -70,7 +82,7 @@ def call_api( :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. + :param ok_error_codes: A list of integer error codes that you don't want an exception for. :return: response: {'status_code': , 'headers': , 'body': } """ @@ -100,7 +112,7 @@ def __call_api( params=None, body=None, callback=None, - ok_error_codes: List[int] = None, + ok_error_codes: list[int] = None, ): """ Calls API on the provided path @@ -110,7 +122,7 @@ def __call_api( :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. + :param ok_error_codes: A list of integer error codes that you don't want an exception for. :return: response: {'status_code': , 'headers': , 'body': } """ @@ -119,7 +131,7 @@ def __call_api( header_params = header_params or {} if body: - header_params.update({"Content-Type": "application/json"}) + header_params.update({"Content-Type": "application/json; charset=utf-8"}) url = self.configuration["host"] + resource_path @@ -136,7 +148,7 @@ def __call_api( method, url, session, - header_params=session.headers, + header_params=header_params, params=params, body=body, ok_error_codes=ok_error_codes, @@ -157,7 +169,7 @@ def request( header_params=None, params=None, body=None, - ok_error_codes: List[int] = None, + ok_error_codes: list[int] = None, ): """ Make a request with the provided information using a requests.session @@ -167,7 +179,7 @@ def request( :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. + :param ok_error_codes: A list of integer error codes that you don't want an exception for. :return: """ @@ -187,11 +199,11 @@ def request( return validated_response @staticmethod - def __validate_response(response, ok_error_codes: List[int] = None): + 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. + :param ok_error_codes: A list of integer error codes that you don't want an exception for. :return: """ try: diff --git a/venmo_api/utils/logging_session.py b/venmo_api/utils/logging_session.py index a2d3c57..fc76925 100644 --- a/venmo_api/utils/logging_session.py +++ b/venmo_api/utils/logging_session.py @@ -25,29 +25,29 @@ def safe_text(b: bytes | None, fallback_repr=True): class LoggingSession(Session): def send(self, request: PreparedRequest, **kwargs) -> Response: - logger.info(f"→ {request.method} {request.url}") - logger.debug(f"→ Request headers: {pformat(dict(request.headers))}") + logger.debug(f"→ {request.method} {request.url}") + logger.trace(f"→ Request headers: {pformat(dict(request.headers))}") body = request.body if isinstance(body, str): - logger.debug(f"→ Request body (str): {pformat(safe_text(body.encode()))}") + logger.trace(f"→ Request body (str): {pformat(safe_text(body.encode()))}") elif isinstance(body, bytes): - logger.debug(f"→ Request body (bytes): {pformat(safe_text(body))}") + logger.trace(f"→ Request body (bytes): {pformat(safe_text(body))}") elif body is None: - logger.debug("→ Request body: ") + logger.trace("→ Request body: ") else: # could be generator/iterable (multipart streaming) - logger.debug(f"→ Request body: (type={type(body).__name__}) {repr(body)}") + logger.trace(f"→ Request body: (type={type(body).__name__}) {repr(body)}") resp = super().send(request, **kwargs) - logger.info(f"← {resp.status_code} {resp.reason}") - logger.debug(f"← Response headers: {pformat(dict(resp.headers))}") + logger.debug(f"← {resp.status_code} {resp.reason}") + logger.trace(f"← Response headers: {pformat(dict(resp.headers))}") # careful: .content will load the whole response into memory try: content = resp.content - logger.debug(f"← Response body: {pformat(safe_text(content))}") + logger.trace(f"← Response body: {pformat(safe_text(content))}") except Exception as e: - logger.debug(f"← Response body: ") + logger.trace(f"← Response body: ") return resp diff --git a/venmo_api/utils/model_util.py b/venmo_api/utils/model_util.py index 1fcfc53..024be5d 100644 --- a/venmo_api/utils/model_util.py +++ b/venmo_api/utils/model_util.py @@ -1,6 +1,5 @@ +import uuid from datetime import datetime -from random import choice, randint -from string import ascii_uppercase def string_to_timestamp(utc): @@ -38,15 +37,16 @@ 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) + return str(uuid.uuid4()).upper() + # 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 938f153..23d9ded 100644 --- a/venmo_api/venmo.py +++ b/venmo_api/venmo.py @@ -1,21 +1,60 @@ -from venmo_api import ( - ApiClient, - AuthenticationApi, - PaymentApi, - UserApi, - validate_access_token, -) +from typing import Self + +from venmo_api import ApiClient, AuthenticationApi, PaymentApi, UserApi class Client(object): - def __init__(self, access_token: str): + @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 + :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. + + :return: access_token + """ + api_client = ApiClient(device_id=device_id) + access_token = AuthenticationApi( + api_client, device_id + ).login_with_credentials_cli(username=username, password=password) + api_client.update_access_token(access_token) + return Client(api_client=api_client) + + @staticmethod + def logout(access_token) -> bool: + """ + Revoke your access_token. Log out, in other words. + :param access_token: + :return: + """ + return AuthenticationApi.log_out(access_token=access_token) + + def __init__( + self, + access_token: str | None = None, + device_id: str | None = None, + api_client: ApiClient | None = None, + ): """ VenmoAPI Client :param access_token: Need access_token to work with the API. """ super().__init__() - self.__access_token = validate_access_token(access_token=access_token) - self.__api_client = ApiClient(access_token=access_token) + 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) + self.user = UserApi(self.__api_client) self.__profile = self.user.get_my_profile() self.payment = PaymentApi(profile=self.__profile, api_client=self.__api_client) @@ -30,27 +69,16 @@ def my_profile(self, force_update=False): return self.__profile - @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. + @property + def access_token(self) -> str | None: + return self.__api_client.access_token - :return: access_token - """ - authn_api = AuthenticationApi(api_client=ApiClient(), device_id=device_id) - return authn_api.login_with_credentials_cli( - username=username, password=password - ) - - @staticmethod - def log_out(access_token) -> bool: + def log_out_instance(self, token: str | None = None) -> bool: """ Revoke your access_token. Log out, in other words. :param access_token: :return: """ - access_token = validate_access_token(access_token=access_token) - return AuthenticationApi.log_out(access_token=access_token) + if token is None: + token = self.access_token + return AuthenticationApi.log_out(token) From 0bce707066460af13484dc946ced404c55cb48c8 Mon Sep 17 00:00:00 2001 From: joshhubert-dsp <102703352+joshhubert-dsp@users.noreply.github.com> Date: Mon, 10 Nov 2025 23:50:34 -0800 Subject: [PATCH 11/23] starting to convert to pydantic --- pyproject.toml | 1 + uv.lock | 76 +++++++++++++++++++ .../{apis => apis_prepydantic}/__init__.py | 0 .../{apis => apis_prepydantic}/auth_api.py | 0 .../{apis => apis_prepydantic}/payment_api.py | 0 .../{apis => apis_prepydantic}/user_api.py | 3 +- .../__init__.py | 0 .../base_model.py | 0 .../{models => models_prepydantic}/comment.py | 0 .../eligibility_token.py | 0 .../exception.py | 0 .../{models => models_prepydantic}/fee.py | 0 .../json_schema.py | 0 .../{models => models_prepydantic}/mention.py | 0 .../{models => models_prepydantic}/page.py | 0 .../{models => models_prepydantic}/payment.py | 0 .../payment_method.py | 0 .../transaction.py | 0 .../{models => models_prepydantic}/user.py | 0 venmo_api/utils/api_client.py | 2 +- venmo_api/utils/api_util.py | 8 +- 21 files changed, 86 insertions(+), 4 deletions(-) rename venmo_api/{apis => apis_prepydantic}/__init__.py (100%) rename venmo_api/{apis => apis_prepydantic}/auth_api.py (100%) rename venmo_api/{apis => apis_prepydantic}/payment_api.py (100%) rename venmo_api/{apis => apis_prepydantic}/user_api.py (98%) rename venmo_api/{models => models_prepydantic}/__init__.py (100%) rename venmo_api/{models => models_prepydantic}/base_model.py (100%) rename venmo_api/{models => models_prepydantic}/comment.py (100%) rename venmo_api/{models => models_prepydantic}/eligibility_token.py (100%) rename venmo_api/{models => models_prepydantic}/exception.py (100%) rename venmo_api/{models => models_prepydantic}/fee.py (100%) rename venmo_api/{models => models_prepydantic}/json_schema.py (100%) rename venmo_api/{models => models_prepydantic}/mention.py (100%) rename venmo_api/{models => models_prepydantic}/page.py (100%) rename venmo_api/{models => models_prepydantic}/payment.py (100%) rename venmo_api/{models => models_prepydantic}/payment_method.py (100%) rename venmo_api/{models => models_prepydantic}/transaction.py (100%) rename venmo_api/{models => models_prepydantic}/user.py (100%) diff --git a/pyproject.toml b/pyproject.toml index 1bc3bf4..989d8b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "devtools>=0.12.2", "loguru>=0.7.3", "orjson>=3.11.3", + "pydantic>=2.12.4", "requests>=2.19.0", ] diff --git a/uv.lock b/uv.lock index ca5c250..c9d26ce 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = "==3.12.*" +[[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" @@ -120,6 +129,50 @@ wheels = [ { 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" }, ] +[[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/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/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" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -153,6 +206,27 @@ 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" @@ -170,6 +244,7 @@ dependencies = [ { name = "devtools" }, { name = "loguru" }, { name = "orjson" }, + { name = "pydantic" }, { name = "requests" }, ] @@ -178,6 +253,7 @@ requires-dist = [ { name = "devtools", specifier = ">=0.12.2" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.3" }, + { name = "pydantic", specifier = ">=2.12.4" }, { name = "requests", specifier = ">=2.19.0" }, ] diff --git a/venmo_api/apis/__init__.py b/venmo_api/apis_prepydantic/__init__.py similarity index 100% rename from venmo_api/apis/__init__.py rename to venmo_api/apis_prepydantic/__init__.py diff --git a/venmo_api/apis/auth_api.py b/venmo_api/apis_prepydantic/auth_api.py similarity index 100% rename from venmo_api/apis/auth_api.py rename to venmo_api/apis_prepydantic/auth_api.py diff --git a/venmo_api/apis/payment_api.py b/venmo_api/apis_prepydantic/payment_api.py similarity index 100% rename from venmo_api/apis/payment_api.py rename to venmo_api/apis_prepydantic/payment_api.py diff --git a/venmo_api/apis/user_api.py b/venmo_api/apis_prepydantic/user_api.py similarity index 98% rename from venmo_api/apis/user_api.py rename to venmo_api/apis_prepydantic/user_api.py index 59e43ba..caca172 100644 --- a/venmo_api/apis/user_api.py +++ b/venmo_api/apis_prepydantic/user_api.py @@ -1,8 +1,9 @@ from venmo_api import Page, Transaction, User, deserialize, get_user_id, wrap_callback +from venmo_api.utils.api_client import ApiClient class UserApi(object): - def __init__(self, api_client): + def __init__(self, api_client: ApiClient): super().__init__() self.__api_client = api_client self.__profile = None diff --git a/venmo_api/models/__init__.py b/venmo_api/models_prepydantic/__init__.py similarity index 100% rename from venmo_api/models/__init__.py rename to venmo_api/models_prepydantic/__init__.py diff --git a/venmo_api/models/base_model.py b/venmo_api/models_prepydantic/base_model.py similarity index 100% rename from venmo_api/models/base_model.py rename to venmo_api/models_prepydantic/base_model.py diff --git a/venmo_api/models/comment.py b/venmo_api/models_prepydantic/comment.py similarity index 100% rename from venmo_api/models/comment.py rename to venmo_api/models_prepydantic/comment.py diff --git a/venmo_api/models/eligibility_token.py b/venmo_api/models_prepydantic/eligibility_token.py similarity index 100% rename from venmo_api/models/eligibility_token.py rename to venmo_api/models_prepydantic/eligibility_token.py diff --git a/venmo_api/models/exception.py b/venmo_api/models_prepydantic/exception.py similarity index 100% rename from venmo_api/models/exception.py rename to venmo_api/models_prepydantic/exception.py diff --git a/venmo_api/models/fee.py b/venmo_api/models_prepydantic/fee.py similarity index 100% rename from venmo_api/models/fee.py rename to venmo_api/models_prepydantic/fee.py diff --git a/venmo_api/models/json_schema.py b/venmo_api/models_prepydantic/json_schema.py similarity index 100% rename from venmo_api/models/json_schema.py rename to venmo_api/models_prepydantic/json_schema.py diff --git a/venmo_api/models/mention.py b/venmo_api/models_prepydantic/mention.py similarity index 100% rename from venmo_api/models/mention.py rename to venmo_api/models_prepydantic/mention.py diff --git a/venmo_api/models/page.py b/venmo_api/models_prepydantic/page.py similarity index 100% rename from venmo_api/models/page.py rename to venmo_api/models_prepydantic/page.py diff --git a/venmo_api/models/payment.py b/venmo_api/models_prepydantic/payment.py similarity index 100% rename from venmo_api/models/payment.py rename to venmo_api/models_prepydantic/payment.py diff --git a/venmo_api/models/payment_method.py b/venmo_api/models_prepydantic/payment_method.py similarity index 100% rename from venmo_api/models/payment_method.py rename to venmo_api/models_prepydantic/payment_method.py diff --git a/venmo_api/models/transaction.py b/venmo_api/models_prepydantic/transaction.py similarity index 100% rename from venmo_api/models/transaction.py rename to venmo_api/models_prepydantic/transaction.py diff --git a/venmo_api/models/user.py b/venmo_api/models_prepydantic/user.py similarity index 100% rename from venmo_api/models/user.py rename to venmo_api/models_prepydantic/user.py diff --git a/venmo_api/utils/api_client.py b/venmo_api/utils/api_client.py index dd48d8d..15ca3ff 100644 --- a/venmo_api/utils/api_client.py +++ b/venmo_api/utils/api_client.py @@ -15,7 +15,7 @@ from venmo_api.utils.logging_session import LoggingSession -class ApiClient(object): +class ApiClient: """ Generic API Client for the Venmo API """ diff --git a/venmo_api/utils/api_util.py b/venmo_api/utils/api_util.py index 85317f3..9f04540 100644 --- a/venmo_api/utils/api_util.py +++ b/venmo_api/utils/api_util.py @@ -1,9 +1,13 @@ from enum import Enum +from pydantic import BaseModel + from venmo_api import ArgumentMissingError, Page, User -def deserialize(response: dict, data_type, nested_response: list[str] = None): +def deserialize( + response: dict, data_type: type[BaseModel], nested_response: list[str] = None +) -> BaseModel: """Extract one or a list of Objects from the api_client structured response. :param response: :param data_type: @@ -27,7 +31,7 @@ def deserialize(response: dict, data_type, nested_response: list[str] = None): if isinstance(data, list): return __get_objs_from_json_list(json_list=data, data_type=data_type) - return data_type.from_json(json=data) + return data_type.model_validate(data) def wrap_callback(callback, data_type, nested_response: list[str] = None): From cc2a7a8a950488c5d6f6780fa47e4c366bbe3e02 Mon Sep 17 00:00:00 2001 From: joshhubert-dsp <102703352+joshhubert-dsp@users.noreply.github.com> Date: Tue, 11 Nov 2025 03:05:33 -0800 Subject: [PATCH 12/23] checkpoint converting to pydantic --- venmo_api/__init__.py | 4 +- venmo_api/apis/__init__.py | 0 venmo_api/apis/auth_api.py | 208 +++++++++++++++++ venmo_api/apis/payment_api.py | 396 ++++++++++++++++++++++++++++++++ venmo_api/apis/user_api.py | 239 +++++++++++++++++++ venmo_api/models/__init__.py | 0 venmo_api/models/comment.py | 13 ++ venmo_api/models/exception.py | 135 +++++++++++ venmo_api/models/json_schema.py | 389 +++++++++++++++++++++++++++++++ venmo_api/models/mention.py | 35 +++ venmo_api/models/page.py | 35 +++ venmo_api/models/payment.py | 125 ++++++++++ venmo_api/models/transaction.py | 137 +++++++++++ venmo_api/models/user.py | 27 +++ venmo_api/utils/api_client.py | 3 +- venmo_api/utils/api_util.py | 17 +- 16 files changed, 1753 insertions(+), 10 deletions(-) create mode 100644 venmo_api/apis/__init__.py create mode 100644 venmo_api/apis/auth_api.py create mode 100644 venmo_api/apis/payment_api.py create mode 100644 venmo_api/apis/user_api.py create mode 100644 venmo_api/models/__init__.py create mode 100644 venmo_api/models/comment.py create mode 100644 venmo_api/models/exception.py create mode 100644 venmo_api/models/json_schema.py create mode 100644 venmo_api/models/mention.py create mode 100644 venmo_api/models/page.py create mode 100644 venmo_api/models/payment.py create mode 100644 venmo_api/models/transaction.py create mode 100644 venmo_api/models/user.py diff --git a/venmo_api/__init__.py b/venmo_api/__init__.py index cd99998..0992ac7 100644 --- a/venmo_api/__init__.py +++ b/venmo_api/__init__.py @@ -11,8 +11,8 @@ 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 .models.payment import Payment, PaymentPrivacy, PaymentRole, PaymentStatus +from .models.payment import PaymentMethod from .models.page import Page from .utils.api_util import deserialize, wrap_callback, warn, get_user_id, confirm from .utils.api_client import ApiClient diff --git a/venmo_api/apis/__init__.py b/venmo_api/apis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/venmo_api/apis/auth_api.py b/venmo_api/apis/auth_api.py new file mode 100644 index 0000000..7ffa649 --- /dev/null +++ b/venmo_api/apis/auth_api.py @@ -0,0 +1,208 @@ +from venmo_api import ( + ApiClient, + AuthenticationFailedError, + confirm, + random_device_id, + warn, +) + + +# NOTE: it seems a device-id is required for payments now, so ApiClient should probably +# own it +class AuthenticationApi(object): + TWO_FACTOR_ERROR_CODE = 81109 + + def __init__( + self, api_client: ApiClient | None = None, device_id: str | None = None + ): + super().__init__() + + self.__device_id = device_id or random_device_id() + self.__api_client = api_client or ApiClient(device_id=self.__device_id) + + 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: + """ + + # 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" + ) + + 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) + self.trust_this_device() + else: + access_token = response["body"]["access_token"] + + confirm("Successfully logged in. Note your token and device-id") + print(f"access_token: {access_token}\ndevice-id: {self.__device_id}") + + return access_token + + @staticmethod + def log_out(access_token: str) -> bool: + """ + Revoke your access_token + :param access_token: + :return: + """ + + resource_path = "/oauth/access_token" + api_client = ApiClient(access_token=access_token) + + api_client.call_api(resource_path=resource_path, method="DELETE") + + confirm("Successfully logged out.") + return True + + 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 + """ + + 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)" + ) + + self.send_text_otp(otp_secret=otp_secret) + 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) + + 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: + """ + + resource_path = "/oauth/access_token" + header_params = {"device-id": self.__device_id, "Host": "api.venmo.com"} + body = { + "phone_email_or_username": username, + "client_id": "1", + "password": password, + } + + 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], + ) + + 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: + """ + + resource_path = "/account/two-factor/token" + header_params = {"device-id": self.__device_id, "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: + reason = None + try: + reason = response["body"]["error"]["message"] + finally: + 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 + """ + + 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} + + 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): + """ + Add device_id or self.device_id (if no device_id passed) to the trusted devices on Venmo + :return: + """ + 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("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." + ) + + 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) + + @staticmethod + 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" + ) + + return otp diff --git a/venmo_api/apis/payment_api.py b/venmo_api/apis/payment_api.py new file mode 100644 index 0000000..75a6b2a --- /dev/null +++ b/venmo_api/apis/payment_api.py @@ -0,0 +1,396 @@ +import uuid +from typing import Callable + +from typing_extensions import Literal + +from venmo_api import ( + AlreadyRemindedPaymentError, + ApiClient, + ArgumentMissingError, + GeneralPaymentError, + NoPaymentMethodFoundError, + NoPendingPaymentToUpdateError, + NotEnoughBalanceError, + Payment, + deserialize, + get_user_id, + wrap_callback, +) +from venmo_api.models.page import Page +from venmo_api.models.payment import ( + EligibilityToken, + PaymentAction, + PaymentMethod, + PaymentPrivacy, + PaymentRole, + PaymentUpdate, + TransferDestination, + TransferPostResponse, +) +from venmo_api.models.user import User + + +class PaymentApi(object): + def __init__(self, profile: User, api_client: ApiClient): + super().__init__() + self.__profile = profile + 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, + } + + def get_charge_payments(self, limit=100000, callback=None) -> Page[Payment]: + """ + 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_pay_payments(self, limit=100000, callback=None) -> Page[Payment]: + """ + 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) + + def remind_payment(self, payment: Payment = None, payment_id: int = None) -> bool: + """ + 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 + """ + + # if the reminder has already sent + payment_id = payment_id or payment.id + 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 + ) + 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" + + response = self.__update_payment(action=action, payment_id=payment_id) + + if "error" in response.get("body"): + raise NoPendingPaymentToUpdateError(payment_id=payment_id, action=action) + return True + + def get_payment_methods(self, callback=None) -> Page[PaymentMethod] | None: + """ + Get a list of available payment_methods + :param callback: + :return: + """ + + 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 + + return deserialize(response=response, data_type=PaymentMethod) + + def get_standard_transfer_destinations(self) -> Page[TransferDestination]: + return self.__get_transfer_destinations("standard") + + def get_instant_transfer_destinations(self) -> Page[TransferDestination]: + return self.__get_transfer_destinations("instant") + + 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, + ) -> bool | None: + """ + 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, + 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, + ) -> bool | None: + """ + 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. + """ + 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 initiate_transfer( + self, + amount: float, + destination_id: str, + trans_type: Literal["standard", "instant"] = "standard", + ) -> TransferPostResponse: + amount_cents = round(amount * 100) + body = { + "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, + } + + resource_path = "/transfers" + response = self.__api_client.call_api( + resource_path=resource_path, body=body, method="POST" + ) + return deserialize(response, TransferPostResponse) + + 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() + + def __get_eligibility_token( + self, + amount: float, + note: str, + target_id: str, + action: str = "pay", + country_code: str = "1", + target_type: str = "user_id", + callback=None, + ) -> EligibilityToken: + """ + Generate eligibility token which is needed in payment requests + :param amount: amount of money to be requested + :param note: message/note of the transaction + :param target_id: the user id of the person you are sending money to + :param funding_source_id: Your payment_method id for this payment + :param action: action that eligibility token is used for + :param country_code: country code, not sure what this is for + :param target_type: set by default to user_id, but there are probably other target types + """ + resource_path = "/protection/eligibility" + body = { + "funding_source_id": "", + "action": action, + "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, body=body, method="POST" + ) + if callback: + return + + return deserialize(response=response, data_type=EligibilityToken) + + def __update_payment(self, action: PaymentUpdate, payment_id: str) -> Payment: + if not payment_id: + raise ArgumentMissingError(arguments=("payment", "payment_id")) + + resource_path = f"/payments/{payment_id}" + body = {"action": action} + response = self.__api_client.call_api( + resource_path=resource_path, + body=body, + method="PUT", + ok_error_codes=list(self.__payment_error_codes.values())[:-1], + ) + return deserialize(response=response, data_type=Payment) + + def __get_payments( + self, action: PaymentAction, limit: int, callback: Callable | None = None + ) -> Page[Payment]: + """ + Get a list of ongoing payments with the given action + :return: + """ + wrapped_callback = wrap_callback(callback=callback, data_type=Payment) + + resource_path = "/payments" + parameters = {"action": action, "actor": self.__profile.id, "limit": limit} + # params `status: pending,held` + response = self.__api_client.call_api( + resource_path=resource_path, + params=parameters, + method="GET", + callback=wrapped_callback, + ) + if callback: + return + + return deserialize(response=response, data_type=Payment) + + def __send_or_request_money( + self, + amount: float, + note: str, + is_send_money: bool, + funding_source_id: str, + privacy_setting: PaymentPrivacy = PaymentPrivacy.PRIVATE, + target_user_id: str | None = None, + target_user: User | None = None, + eligibility_token: str | None = None, + callback=None, + ) -> Payment | None: + """ + 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 eligibility_token: + :param callback: + :return: + """ + 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, + } + + 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" + nested_response = ["payment"] + wrapped_callback = wrap_callback(callback, Payment, nested_response) + + response = self.__api_client.call_api( + resource_path=resource_path, + method="POST", + body=body, + callback=wrapped_callback, + ) + # handle 200 status code errors + error_code = response["body"]["data"].get("error_code") + if error_code: + if error_code == self.__payment_error_codes["not_enough_balance_error"]: + raise NotEnoughBalanceError(amount, target_user_id) + + 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 deserialize(response, Payment, nested_response) + + def __get_transfer_destinations( + self, trans_type: Literal["standard", "instant"] + ) -> Page[TransferDestination]: + """ + Get a list of available transfer destination options for the given type + :param callback: + :return: + """ + + resource_path = "/transfers/options" + response = self.__api_client.call_api( + resource_path=resource_path, + method="GET", + ) + return deserialize(response, TransferDestination, [trans_type]) diff --git a/venmo_api/apis/user_api.py b/venmo_api/apis/user_api.py new file mode 100644 index 0000000..1641019 --- /dev/null +++ b/venmo_api/apis/user_api.py @@ -0,0 +1,239 @@ +from venmo_api import Page, Transaction, User, deserialize, get_user_id, wrap_callback +from venmo_api.utils.api_client import ApiClient + + +class UserApi(object): + def __init__(self, api_client: ApiClient): + super().__init__() + self.__api_client = api_client + self.__profile = None + + def get_my_profile(self, callback=None, force_update=False) -> 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, User, nested_response) + return self.__profile + + def search_for_users( + self, + query: str, + callback=None, + offset: int = 0, + limit: int = 50, + username=False, + ) -> list[User] | None: + """ + 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 + """ + + resource_path = "/users" + wrapped_callback = wrap_callback(callback=callback, data_type=User) + + 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"}) + + response = self.__api_client.call_api( + resource_path=resource_path, + params=params, + method="GET", + callback=wrapped_callback, + ) + # Return None if threaded + if callback: + return + + 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, callback=None) -> User | None: + """ + Get the user profile with [user_id] + :param user_id: , example: '2859950549165568970' + :param callback: + :return user: + """ + + # 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 + + return deserialize(response=response, data_type=User) + + def get_user_by_username(self, username: str) -> User | None: + """ + Get the user profile with [username] + :param username: + :return user: + """ + users = self.search_for_users(query=username, username=True) + for user in users: + if user.username == username: + return user + + # 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, + ) -> 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 + """ + user_id = get_user_id(user, user_id) + params = {"limit": limit, "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, + ) -> 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: + """ + user_id = get_user_id(user, user_id) + + params = {"limit": limit} + if before_id: + params["before_id"] = before_id + + # Prepare the request + resource_path = f"/stories/target-or-actor/{user_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_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, + ) -> Page | None: + """ + 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: + """ + user_id_one = get_user_id(user_one, user_id_one) + user_id_two = get_user_id(user_two, user_id_two) + + params = {"limit": limit} + 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}" + ) + + 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}, + ) diff --git a/venmo_api/models/__init__.py b/venmo_api/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/venmo_api/models/comment.py b/venmo_api/models/comment.py new file mode 100644 index 0000000..dc65acf --- /dev/null +++ b/venmo_api/models/comment.py @@ -0,0 +1,13 @@ +from datetime import datetime + +from pydantic import BaseModel + +from venmo_api import Mention, User + + +class Comment(BaseModel): + id: str + message: str + date_created: datetime + mentions: list[Mention] + user: User diff --git a/venmo_api/models/exception.py b/venmo_api/models/exception.py new file mode 100644 index 0000000..dca904e --- /dev/null +++ b/venmo_api/models/exception.py @@ -0,0 +1,135 @@ +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 "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" + ) + super(InvalidHttpMethodError, self).__init__(self.msg) + + +class ResourceNotFoundError(Exception): + """Raise it for 400 HTTP status code, when resource is not found""" + + def __init__(self, msg: str = None): + self.msg = msg or "400 Bad Request. Couldn't find the requested resource." + super(ResourceNotFoundError, self).__init__(self.msg) + + +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." + ) + status_code = response.status_code or "NA" + reason = response.reason or "Unknown reason" + try: + json = response.json() + except JSONDecodeError: + json = "Invalid Json" + + 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) + super(NoPaymentMethodFoundError, self).__init__(self.msg) + + +class AlreadyRemindedPaymentError(Exception): + def __init__(self, payment_id: int): + self.msg = f"A reminder has already been sent to the recipient of this transaction: {payment_id}." + super(AlreadyRemindedPaymentError, self).__init__(self.msg) + + +class NoPendingPaymentToUpdateError(Exception): + def __init__(self, payment_id: int, action: str): + self.msg = f"There is no *pending* transaction with the specified id: {payment_id}, to be {action}ed." + super(NoPendingPaymentToUpdateError, self).__init__(self.msg) + + +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" + ) + super(NotEnoughBalanceError, self).__init__(self.msg) + + +class GeneralPaymentError(Exception): + def __init__(self, msg): + self.msg = f"Transaction failed. {msg}" + super(GeneralPaymentError, self).__init__(self.msg) + + +__all__ = [ + "AuthenticationFailedError", + "InvalidArgumentError", + "InvalidHttpMethodError", + "ArgumentMissingError", + "JSONDecodeError", + "ResourceNotFoundError", + "HttpCodeError", + "NoPaymentMethodFoundError", + "AlreadyRemindedPaymentError", + "NoPendingPaymentToUpdateError", + "NotEnoughBalanceError", + "GeneralPaymentError", +] diff --git a/venmo_api/models/json_schema.py b/venmo_api/models/json_schema.py new file mode 100644 index 0000000..aaf014c --- /dev/null +++ b/venmo_api/models/json_schema.py @@ -0,0 +1,389 @@ +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) + + @staticmethod + def eligibility_token(json): + return EligibilityTokenParser(json) + + @staticmethod + def fee(json): + return FeeParser(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", +# } + +# TODO what's up with mentions/mentions_list? +# 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"} + + +# class EligibilityTokenParser: +# def __init__(self, json): +# self.json = json + +# def get_eligibility_token(self): +# return self.json.get(eligibility_token_json_format["eligibility_token"]) + +# def get_eligible(self): +# return self.json.get(eligibility_token_json_format["eligible"]) + +# def get_fees(self): +# return self.json.get(eligibility_token_json_format["fees"]) + +# def get_fee_disclaimer(self): +# return self.json.get(eligibility_token_json_format["fee_disclaimer"]) + + +# eligibility_token_json_format = { +# "eligibility_token": "eligibility_token", +# "eligible": "eligible", +# "fees": "fees", +# "fee_disclaimer": "fee_disclaimer", +# } + + +# class FeeParser: +# def __init__(self, json): +# self.json = json + +# def get_product_uri(self): +# return self.json.get(fee_json_format["product_uri"]) + +# def get_applied_to(self): +# return self.json.get(fee_json_format["applied_to"]) + +# def get_base_fee_amount(self): +# return self.json.get(fee_json_format["base_fee_amount"]) + +# def get_fee_percentage(self): +# return self.json.get(fee_json_format["fee_percentage"]) + +# def get_calculated_fee_amount_in_cents(self): +# return self.json.get(fee_json_format["calculated_fee_amount_in_cents"]) + +# def get_fee_token(self): +# return self.json.get(fee_json_format["fee_token"]) + + +# fee_json_format = { +# "product_uri": "product_uri", +# "applied_to": "applied_to", +# "base_fee_amount": "base_fee_amount", +# "fee_percentage": "fee_percentage", +# "calculated_fee_amount_in_cents": "calculated_fee_amount_in_cents", +# "fee_token": "fee_token", +# } diff --git a/venmo_api/models/mention.py b/venmo_api/models/mention.py new file mode 100644 index 0000000..8702723 --- /dev/null +++ b/venmo_api/models/mention.py @@ -0,0 +1,35 @@ +from venmo_api import BaseModel, JSONSchema, User + + +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 new file mode 100644 index 0000000..b5a967c --- /dev/null +++ b/venmo_api/models/page.py @@ -0,0 +1,35 @@ +class Page(list): + def __init__(self): + super().__init__() + self.method = None + 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: + """ + self.method = method + self.kwargs = kwargs + self.current_offset = current_offset + return self + + 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) + else: + 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 new file mode 100644 index 0000000..eab65e0 --- /dev/null +++ b/venmo_api/models/payment.py @@ -0,0 +1,125 @@ +from datetime import datetime +from enum import StrEnum, auto +from typing import Any, Literal + +from pydantic import AliasPath, BaseModel, Field + +from venmo_api import BaseModel +from venmo_api.models.user import User + + +# --- REQUEST PARAM ENUMS --- +class PaymentStatus(StrEnum): + SETTLED = auto() + CANCELLED = auto() + PENDING = auto() + HELD = auto() + FAILED = auto() + EXPIRED = auto() + + +class PaymentAction: + PAY = auto() + CHARGE = auto() + + +class PaymentUpdate: + REMIND = auto() + CANCEL = auto() + + +# -- RESPONSE FIELD ENUMS --- + + +class PaymentRole(StrEnum): + DEFAULT = auto() + BACKUP = auto() + NONE = auto() + + +class PaymentPrivacy(StrEnum): + PRIVATE = auto() + PUBLIC = auto() + FRIENDS = auto() + + +class PaymentType(StrEnum): + BANK = auto() + BALANCE = auto() + CARDs = auto() + + +# --- MODELS --- + + +class Fee(BaseModel): + product_uri: str + applied_to: str + base_fee_amount: float + 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 + + +class Payment(BaseModel): + id: str + status: PaymentStatus + action: PaymentAction + amount: float + date_created: datetime + audience: PaymentPrivacy + note: str + target: User = Field(validation_alias=AliasPath("user")) + actor: User + date_completed: datetime | None + date_reminded: datetime | None + # TODO figure these out + refund: Any | None + fee: Fee | None + + +class PaymentMethod(BaseModel): + id: str + type: PaymentType + name: str + last_four: str | None + peer_payment_role: PaymentRole + merchant_payment_role: PaymentRole + top_up_role: PaymentRole + default_transfer_destination: Literal["default"] | None = 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): + id: str + type: PaymentType + name: str + last_four: str | None + is_default: bool + transfer_to_estimate: datetime + account_status: Literal["verified"] | Any + + +class TransferPostResponse(BaseModel): + id: str + amount: float + 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/transaction.py b/venmo_api/models/transaction.py new file mode 100644 index 0000000..12fce3d --- /dev/null +++ b/venmo_api/models/transaction.py @@ -0,0 +1,137 @@ +from enum import Enum + +from venmo_api import ( + BaseModel, + Comment, + JSONSchema, + User, + get_phone_model_from_json, + string_to_timestamp, +) + + +class Transaction(BaseModel): + 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, + ) + + +class TransactionType(Enum): + PAYMENT = "payment" + # merchant refund + REFUND = "refund" + # to/from bank account + TRANSFER = "transfer" + # add money to debit card + TOP_UP = "top_up" + # debit card purchase + AUTHORIZATION = "authorization" + # debit card atm withdrawal + ATM_WITHDRAWAL = "atm_withdrawal" + + DISBURSEMENT = "disbursement" diff --git a/venmo_api/models/user.py b/venmo_api/models/user.py new file mode 100644 index 0000000..fd47d9d --- /dev/null +++ b/venmo_api/models/user.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from pydantic import BaseModel, EmailStr + +from venmo_api.pydantic_models.payment_method import PaymentPrivacy + + +class User(BaseModel): + about: str + date_joined: datetime + friends_count: int + is_active: bool + is_blocked: bool + friend_status: str | None # TODO enum? + profile_picture_url: str + username: str + trust_request: str | None # TODO + display_name: str + email: EmailStr | None = None + first_name: str + id: str + identity_type: str # TODO enum? + is_group: bool + last_name: str + phone: str | None = None + is_payable: bool + audience: PaymentPrivacy diff --git a/venmo_api/utils/api_client.py b/venmo_api/utils/api_client.py index 15ca3ff..c2ed6d5 100644 --- a/venmo_api/utils/api_client.py +++ b/venmo_api/utils/api_client.py @@ -165,7 +165,7 @@ def request( self, method, url, - session, + session: requests.Session, header_params=None, params=None, body=None, @@ -192,6 +192,7 @@ def request( ) # Only accepts the 20x status codes. + # TODO any reason not to use response.raise_for_status validated_response = self.__validate_response( response, ok_error_codes=ok_error_codes ) diff --git a/venmo_api/utils/api_util.py b/venmo_api/utils/api_util.py index 9f04540..7fd45e1 100644 --- a/venmo_api/utils/api_util.py +++ b/venmo_api/utils/api_util.py @@ -1,4 +1,5 @@ from enum import Enum +from typing import Any from pydantic import BaseModel @@ -6,8 +7,8 @@ def deserialize( - response: dict, data_type: type[BaseModel], nested_response: list[str] = None -) -> BaseModel: + response: dict, data_type: type[BaseModel], nested_response: list[str] | None = None +) -> BaseModel | Page[BaseModel]: """Extract one or a list of Objects from the api_client structured response. :param response: :param data_type: @@ -34,7 +35,9 @@ def deserialize( return data_type.model_validate(data) -def wrap_callback(callback, data_type, nested_response: list[str] = None): +def wrap_callback( + callback, data_type: type[BaseModel], nested_response: list[str] | None = None +): """ :param callback: Function that was provided by the user :param data_type: It can be either User or Transaction @@ -56,7 +59,9 @@ def wrapper(response): return wrapper -def __get_objs_from_json_list(json_list, data_type): +def __get_objs_from_json_list( + json_list: list[Any], data_type: type[BaseModel] +) -> Page[BaseModel]: """Process JSON for User/Transaction :param json_list: a list of objs :param data_type: User/Transaction/Payment/PaymentMethod @@ -64,9 +69,7 @@ def __get_objs_from_json_list(json_list, data_type): """ result = Page() for obj in json_list: - data_obj = data_type.from_json(obj) - if not data_obj: - continue + data_obj = data_type.model_validate(obj) result.append(data_obj) return result From d2f75c16077783b8bb7901646137db7c4801e884 Mon Sep 17 00:00:00 2001 From: joshhubert-dsp <102703352+joshhubert-dsp@users.noreply.github.com> Date: Wed, 12 Nov 2025 04:58:03 -0800 Subject: [PATCH 13/23] pydantic everything --- README.md | 34 +- docs/index.rst | 17 +- pyproject.toml | 1 + uv.lock | 14 + venmo_api/__init__.py | 47 +-- venmo_api/{utils => apis}/api_client.py | 123 ++---- venmo_api/apis/api_util.py | 89 ++++ venmo_api/apis/auth_api.py | 60 +-- venmo_api/{models => apis}/exception.py | 0 venmo_api/{utils => apis}/logging_session.py | 0 venmo_api/apis/payment_api.py | 239 ++++------- venmo_api/apis/user_api.py | 240 ++++++----- venmo_api/apis_prepydantic/__init__.py | 0 venmo_api/apis_prepydantic/auth_api.py | 208 ---------- venmo_api/apis_prepydantic/payment_api.py | 341 --------------- venmo_api/apis_prepydantic/user_api.py | 241 ----------- venmo_api/models/comment.py | 13 - venmo_api/models/json_schema.py | 378 +---------------- venmo_api/models/mention.py | 35 -- venmo_api/models/page.py | 2 + venmo_api/models/payment.py | 60 ++- venmo_api/models/transaction.py | 169 +++----- venmo_api/models/us_dollars.py | 53 +++ venmo_api/models/user.py | 37 +- venmo_api/models_prepydantic/__init__.py | 0 venmo_api/models_prepydantic/base_model.py | 18 - venmo_api/models_prepydantic/comment.py | 53 --- .../models_prepydantic/eligibility_token.py | 36 -- venmo_api/models_prepydantic/exception.py | 135 ------ venmo_api/models_prepydantic/fee.py | 45 -- venmo_api/models_prepydantic/json_schema.py | 389 ------------------ venmo_api/models_prepydantic/mention.py | 35 -- venmo_api/models_prepydantic/page.py | 35 -- venmo_api/models_prepydantic/payment.py | 84 ---- .../models_prepydantic/payment_method.py | 74 ---- venmo_api/models_prepydantic/transaction.py | 137 ------ venmo_api/models_prepydantic/user.py | 79 ---- venmo_api/utils/__init__.py | 3 - venmo_api/utils/api_util.py | 125 ------ venmo_api/utils/model_util.py | 52 --- venmo_api/venmo.py | 25 +- 41 files changed, 578 insertions(+), 3148 deletions(-) rename venmo_api/{utils => apis}/api_client.py (58%) create mode 100644 venmo_api/apis/api_util.py rename venmo_api/{models => apis}/exception.py (100%) rename venmo_api/{utils => apis}/logging_session.py (100%) delete mode 100644 venmo_api/apis_prepydantic/__init__.py delete mode 100644 venmo_api/apis_prepydantic/auth_api.py delete mode 100644 venmo_api/apis_prepydantic/payment_api.py delete mode 100644 venmo_api/apis_prepydantic/user_api.py delete mode 100644 venmo_api/models/comment.py delete mode 100644 venmo_api/models/mention.py create mode 100644 venmo_api/models/us_dollars.py delete mode 100644 venmo_api/models_prepydantic/__init__.py delete mode 100644 venmo_api/models_prepydantic/base_model.py delete mode 100644 venmo_api/models_prepydantic/comment.py delete mode 100644 venmo_api/models_prepydantic/eligibility_token.py delete mode 100644 venmo_api/models_prepydantic/exception.py delete mode 100644 venmo_api/models_prepydantic/fee.py delete mode 100644 venmo_api/models_prepydantic/json_schema.py delete mode 100644 venmo_api/models_prepydantic/mention.py delete mode 100644 venmo_api/models_prepydantic/page.py delete mode 100644 venmo_api/models_prepydantic/payment.py delete mode 100644 venmo_api/models_prepydantic/payment_method.py delete mode 100644 venmo_api/models_prepydantic/transaction.py delete mode 100644 venmo_api/models_prepydantic/user.py delete mode 100644 venmo_api/utils/__init__.py delete mode 100644 venmo_api/utils/api_util.py delete mode 100644 venmo_api/utils/model_util.py diff --git a/README.md b/README.md index 9ebb76a..431f8fd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Venmo API -Disclaimer: This is an individual effort and is not PayPal/Venmo sponsored or maintained. +Disclaimer: This is an individual effort and is not PayPal/Venmo sponsored or maintained. ## Introduction @@ -41,7 +41,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 +50,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 +61,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 +91,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 +126,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/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 index 989d8b2..844fb0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ license-files = [ requires-python = ">=3.12,<3.13" dependencies = [ "devtools>=0.12.2", + "dinero>=0.4.0", "loguru>=0.7.3", "orjson>=3.11.3", "pydantic>=2.12.4", diff --git a/uv.lock b/uv.lock index c9d26ce..50952dc 100644 --- a/uv.lock +++ b/uv.lock @@ -75,6 +75,18 @@ 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 = "dinero" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/56/d5b5a2fd33c844be8c3a42ab42f95fb51a6297486966599fa25abc79776e/dinero-0.4.0.tar.gz", hash = "sha256:87b55cc17dd5a9a2acf7bd044d460c769f869100e3d5a286a7a94c065af84125", size = 20799, upload-time = "2025-05-18T21:43:02.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/0e/cca2c39fb429d465a1ea54038d6449a61ddd818d6b9886691b1aa184ba94/dinero-0.4.0-py3-none-any.whl", hash = "sha256:19f66e4fa7b1c7b9419bb1d942cf0a06c12c1f38b444b21cec510f5a9408c387", size = 52938, upload-time = "2025-05-18T21:43:01.004Z" }, +] + [[package]] name = "executing" version = "2.2.1" @@ -242,6 +254,7 @@ version = "1.0.0" source = { editable = "." } dependencies = [ { name = "devtools" }, + { name = "dinero" }, { name = "loguru" }, { name = "orjson" }, { name = "pydantic" }, @@ -251,6 +264,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "devtools", specifier = ">=0.12.2" }, + { name = "dinero", specifier = ">=0.4.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.3" }, { name = "pydantic", specifier = ">=2.12.4" }, diff --git a/venmo_api/__init__.py b/venmo_api/__init__.py index 0992ac7..05b7078 100644 --- a/venmo_api/__init__.py +++ b/venmo_api/__init__.py @@ -1,58 +1,27 @@ +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parents[1] + # ruff: noqa: I001 -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, PaymentPrivacy, PaymentRole, PaymentStatus +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 -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", - "JSONSchema", "User", "Mention", "Comment", "Transaction", "Payment", - "PaymentStatus", "PaymentMethod", - "PaymentRole", "Page", - "BaseModel", "PaymentPrivacy", "ApiClient", "AuthenticationApi", diff --git a/venmo_api/utils/api_client.py b/venmo_api/apis/api_client.py similarity index 58% rename from venmo_api/utils/api_client.py rename to venmo_api/apis/api_client.py index c2ed6d5..c075e6d 100644 --- a/venmo_api/utils/api_client.py +++ b/venmo_api/apis/api_client.py @@ -1,18 +1,22 @@ import os -import threading +from dataclasses import dataclass from json import JSONDecodeError from random import getrandbits import orjson import requests +from requests.structures import CaseInsensitiveDict -from venmo_api import ( - HttpCodeError, - InvalidHttpMethodError, - ResourceNotFoundError, -) -from venmo_api.utils import PROJECT_ROOT -from venmo_api.utils.logging_session import LoggingSession +from venmo_api import PROJECT_ROOT +from venmo_api.apis.exception import InvalidHttpMethodError, ResourceNotFoundError +from venmo_api.apis.logging_session import LoggingSession + + +@dataclass(frozen=True, slots=True) +class ValidatedResponse: + status_code: int + headers: CaseInsensitiveDict + body: list | dict class ApiClient: @@ -49,7 +53,7 @@ def __init__(self, access_token: str | None = None, device_id: str | None = None def update_session_id(self): self._session_id = str(getrandbits(64)) - # self.default_headers.update({"X-Session-ID": self._session_id}) + 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): @@ -69,51 +73,8 @@ def call_api( 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, - ): + ) -> ValidatedResponse: """ Calls API on the provided path @@ -121,7 +82,6 @@ def __call_api( :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': } @@ -135,31 +95,18 @@ def __call_api( 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, + self.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 + return processed_response def request( self, @@ -170,7 +117,7 @@ def request( params=None, body=None, ok_error_codes: list[int] = None, - ): + ) -> ValidatedResponse: """ Make a request with the provided information using a requests.session :param method: @@ -191,50 +138,38 @@ def request( method=method, url=url, headers=header_params, params=params, json=body ) - # Only accepts the 20x status codes. - # TODO any reason not to use response.raise_for_status - validated_response = self.__validate_response( + 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): + def _validate_response( + response: requests.Response, ok_error_codes: list[int] = None + ) -> ValidatedResponse: """ 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: """ + headers = response.headers try: body = response.json() - headers = response.headers except JSONDecodeError: body = {} - headers = {} - built_response = { - "status_code": response.status_code, - "headers": headers, - "body": body, - } + built_response = ValidatedResponse(response.status_code, headers, body) - if response.status_code in range(200, 205) and response.json: + 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 response.json().get("error").get("code") == 283 - ): + elif response.status_code == 400 and body.get.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) + response.raise_for_status() + # 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..5dd9e28 --- /dev/null +++ b/venmo_api/apis/api_util.py @@ -0,0 +1,89 @@ +from enum import Enum +from typing import Any + +from pydantic import BaseModel + +from venmo_api.apis.api_client import ValidatedResponse +from venmo_api.models.page import Page + + +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. + :param response: + :param data_type: if data of interest is a json object, should be a pydantic + BaseModel subclass. Otherwise can be a primitive class + :param nested_response: Optional. Loop through the body + :return: 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 JSON for User/Transaction + :param json_list: a list of objs + :param data_type: User/Transaction/Payment/PaymentMethod + :return: + """ + 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 + :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) diff --git a/venmo_api/apis/auth_api.py b/venmo_api/apis/auth_api.py index 7ffa649..eb11ddc 100644 --- a/venmo_api/apis/auth_api.py +++ b/venmo_api/apis/auth_api.py @@ -1,15 +1,21 @@ -from venmo_api import ( - ApiClient, - AuthenticationFailedError, - confirm, - random_device_id, - warn, -) +import uuid + +from venmo_api.apis.api_client import ApiClient, ValidatedResponse +from venmo_api.apis.api_util import confirm, warn +from venmo_api.apis.exception import AuthenticationFailedError + + +def random_device_id(): + """ + Generate a random device id that can be used for logging in. + :return: + """ + return str(uuid.uuid4()).upper() # NOTE: it seems a device-id is required for payments now, so ApiClient should probably # own it -class AuthenticationApi(object): +class AuthenticationApi: TWO_FACTOR_ERROR_CODE = 81109 def __init__( @@ -18,7 +24,7 @@ def __init__( super().__init__() self.__device_id = device_id or random_device_id() - self.__api_client = api_client or ApiClient(device_id=self.__device_id) + self._api_client = api_client or ApiClient(device_id=self.__device_id) def login_with_credentials_cli(self, username: str, password: str) -> str: """ @@ -41,11 +47,11 @@ def login_with_credentials_cli(self, username: str, password: str) -> str: 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}\ndevice-id: {self.__device_id}") @@ -68,14 +74,14 @@ def log_out(access_token: str) -> bool: confirm("Successfully logged out.") return True - def __two_factor_process_cli(self, response: dict) -> str: + def _two_factor_process_cli(self, response: ValidatedResponse) -> str: """ Get response from authenticate_with_username_password for a CLI two-factor process :param response: :return: access_token """ - otp_secret = response["headers"].get("venmo-otp-secret") + 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. " @@ -83,16 +89,16 @@ def __two_factor_process_cli(self, response: dict) -> str: ) 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=access_token) return access_token def authenticate_using_username_password( self, username: str, password: str - ) -> dict: + ) -> ValidatedResponse: """ Authenticate with username and password. Raises exception if either be incorrect. Check returned response: @@ -111,7 +117,7 @@ def authenticate_using_username_password( "password": password, } - return self.__api_client.call_api( + return self._api_client.call_api( resource_path=resource_path, header_params=header_params, body=body, @@ -119,7 +125,7 @@ def authenticate_using_username_password( ok_error_codes=[self.TWO_FACTOR_ERROR_CODE], ) - def send_text_otp(self, otp_secret: str) -> dict: + def send_text_otp(self, otp_secret: str) -> ValidatedResponse: """ Send one-time-password to user phone-number :param otp_secret: the otp-secret from response_headers.venmo-otp-secret @@ -130,17 +136,17 @@ def send_text_otp(self, otp_secret: str) -> dict: header_params = {"device-id": self.__device_id, "venmo-otp-secret": otp_secret} body = {"via": "sms"} - response = self.__api_client.call_api( + response = self._api_client.call_api( resource_path=resource_path, header_params=header_params, body=body, method="POST", ) - if response["status_code"] != 200: + 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" @@ -165,13 +171,13 @@ def authenticate_using_otp(self, user_otp: str, otp_secret: str) -> str: } params = {"client_id": 1} - response = self.__api_client.call_api( + response = self._api_client.call_api( resource_path=resource_path, header_params=header_params, params=params, method="POST", ) - return response["body"]["access_token"] + return response.body["access_token"] def trust_this_device(self, device_id=None): """ @@ -182,7 +188,7 @@ def trust_this_device(self, device_id=None): header_params = {"device-id": device_id} resource_path = "/users/devices" - self.__api_client.call_api( + self._api_client.call_api( resource_path=resource_path, header_params=header_params, method="POST" ) @@ -195,10 +201,10 @@ 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) + self._api_client.update_access_token(access_token=access_token) @staticmethod - def __ask_user_for_otp_password(): + def _ask_user_for_otp_password(): otp = "" while len(otp) < 6 or not otp.isdigit(): otp = input( diff --git a/venmo_api/models/exception.py b/venmo_api/apis/exception.py similarity index 100% rename from venmo_api/models/exception.py rename to venmo_api/apis/exception.py diff --git a/venmo_api/utils/logging_session.py b/venmo_api/apis/logging_session.py similarity index 100% rename from venmo_api/utils/logging_session.py rename to venmo_api/apis/logging_session.py diff --git a/venmo_api/apis/payment_api.py b/venmo_api/apis/payment_api.py index 75a6b2a..c7523a1 100644 --- a/venmo_api/apis/payment_api.py +++ b/venmo_api/apis/payment_api.py @@ -1,64 +1,59 @@ import uuid -from typing import Callable +from typing import Literal -from typing_extensions import Literal - -from venmo_api import ( +from venmo_api.apis.api_client import ApiClient, ValidatedResponse +from venmo_api.apis.api_util import deserialize +from venmo_api.apis.exception import ( AlreadyRemindedPaymentError, - ApiClient, ArgumentMissingError, GeneralPaymentError, NoPaymentMethodFoundError, NoPendingPaymentToUpdateError, NotEnoughBalanceError, - Payment, - deserialize, - get_user_id, - wrap_callback, ) from venmo_api.models.page import Page from venmo_api.models.payment import ( EligibilityToken, + Payment, PaymentAction, PaymentMethod, - PaymentPrivacy, - PaymentRole, - PaymentUpdate, + PaymentMethodRole, TransferDestination, TransferPostResponse, ) -from venmo_api.models.user import User +from venmo_api.models.user import PaymentPrivacy, User -class PaymentApi(object): - def __init__(self, profile: User, api_client: ApiClient): +class PaymentApi: + def __init__( + self, profile: User, api_client: ApiClient, balance: float | None = None + ): super().__init__() - self.__profile = profile - self.__api_client = api_client - self.__payment_error_codes = { + 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, } - def get_charge_payments(self, limit=100000, callback=None) -> Page[Payment]: + def get_charge_payments(self, limit=100000) -> Page[Payment]: """ 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) + return self._get_payments(action="charge", limit=limit) - def get_pay_payments(self, limit=100000, callback=None) -> Page[Payment]: + def get_pay_payments(self, limit=100000) -> Page[Payment]: """ 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) + return self._get_payments(action="pay", limit=limit) def remind_payment(self, payment: Payment = None, payment_id: int = None) -> bool: """ @@ -71,18 +66,15 @@ def remind_payment(self, payment: Payment = None, payment_id: int = None) -> boo # if the reminder has already sent payment_id = payment_id or payment.id action = "remind" - - response = self.__update_payment(action=action, payment_id=payment_id) + response = self._update_payment(action=action, payment_id=payment_id) # if the reminder has already sent - if "error" in response.get("body"): + if "error" in response.body: if ( - response["body"]["error"]["code"] - == self.__payment_error_codes["no_pending_payment_error2"] + response.body["error"]["code"] + == self._payment_error_codes["no_pending_payment_error2"] ): - raise NoPendingPaymentToUpdateError( - payment_id=payment_id, action=action - ) + raise NoPendingPaymentToUpdateError(payment_id, action) raise AlreadyRemindedPaymentError(payment_id=payment_id) return True @@ -94,50 +86,32 @@ def cancel_payment(self, payment: Payment = None, payment_id: int = None) -> boo :return: True or raises NoPendingPaymentToCancelError """ # if the reminder has already sent - payment_id = payment_id or payment.id action = "cancel" + payment_id = payment_id or payment.id + response = self._update_payment(action=action, payment_id=payment_id) - response = self.__update_payment(action=action, payment_id=payment_id) - - if "error" in response.get("body"): - raise NoPendingPaymentToUpdateError(payment_id=payment_id, action=action) + if "error" in response.body: + raise NoPendingPaymentToUpdateError(payment_id, action) return True - def get_payment_methods(self, callback=None) -> Page[PaymentMethod] | None: + def get_payment_methods(self) -> Page[PaymentMethod]: """ Get a list of available payment_methods - :param callback: :return: """ - - 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 + response = self._api_client.call_api( + resource_path="/payment-methods", method="GET" ) - # return the thread - if callback: - return - return deserialize(response=response, data_type=PaymentMethod) - def get_standard_transfer_destinations(self) -> Page[TransferDestination]: - return self.__get_transfer_destinations("standard") - - def get_instant_transfer_destinations(self) -> Page[TransferDestination]: - return self.__get_transfer_destinations("instant") - def send_money( self, amount: float, note: str, - target_user_id: int = None, + target_user_id: str, funding_source_id: str = None, - target_user: User = None, privacy_setting: PaymentPrivacy = PaymentPrivacy.PRIVATE, - callback=None, - ) -> bool | None: + ) -> Payment: """ 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. @@ -147,30 +121,25 @@ def send_money( :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( + 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, + privacy_setting=privacy_setting.value, ) def request_money( self, amount: float, note: str, - target_user_id: int = None, + target_user_id: str, privacy_setting: PaymentPrivacy = PaymentPrivacy.PRIVATE, - target_user: User = None, - callback=None, - ) -> bool | None: + ) -> Payment: """ Request [amount] money with [note] from the ([target_user_id] or [target_user]) :param amount: amount of money to be requested @@ -178,26 +147,41 @@ def request_money( :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. """ - return self.__send_or_request_money( + 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, + 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 for the given type + :return: + """ + resource_path = "/transfers/options" + response = self._api_client.call_api(resource_path=resource_path, method="GET") + return deserialize( + response, TransferDestination, [trans_type, "eligible_destinations"] ) def initiate_transfer( self, - amount: float, destination_id: str, + amount: float | None = None, trans_type: Literal["standard", "instant"] = "standard", ) -> TransferPostResponse: + if amount is None and self._balance is not None: + amount = self._balance + else: + raise ValueError("must pass a transfer amount if no balance available") + amount_cents = round(amount * 100) body = { "amount": amount_cents, @@ -206,10 +190,8 @@ def initiate_transfer( # TODO should this have a fee subtracted? don't feel like testing "final_amount": amount_cents, } - - resource_path = "/transfers" - response = self.__api_client.call_api( - resource_path=resource_path, body=body, method="POST" + response = self._api_client.call_api( + resource_path="/transfers", body=body, method="POST" ) return deserialize(response, TransferPostResponse) @@ -224,12 +206,14 @@ def get_default_payment_method(self) -> PaymentMethod: if not p_method: continue - if p_method.role == PaymentRole.DEFAULT: + if p_method.role == PaymentMethodRole.DEFAULT: return p_method raise NoPaymentMethodFoundError() - def __get_eligibility_token( + # --- HELPERS --- + + def _get_eligibility_token( self, amount: float, note: str, @@ -237,7 +221,6 @@ def __get_eligibility_token( action: str = "pay", country_code: str = "1", target_type: str = "user_id", - callback=None, ) -> EligibilityToken: """ Generate eligibility token which is needed in payment requests @@ -249,7 +232,6 @@ def __get_eligibility_token( :param country_code: country code, not sure what this is for :param target_type: set by default to user_id, but there are probably other target types """ - resource_path = "/protection/eligibility" body = { "funding_source_id": "", "action": action, @@ -259,63 +241,47 @@ def __get_eligibility_token( "target_id": target_id, "amount": round(amount * 100), } - - response = self.__api_client.call_api( - resource_path=resource_path, body=body, method="POST" + response = self._api_client.call_api( + resource_path="/protection/eligibility", body=body, method="POST" ) - if callback: - return - return deserialize(response=response, data_type=EligibilityToken) - def __update_payment(self, action: PaymentUpdate, payment_id: str) -> Payment: + def _update_payment( + self, action: Literal["remind", "cancel"], payment_id: str + ) -> ValidatedResponse: if not payment_id: raise ArgumentMissingError(arguments=("payment", "payment_id")) - resource_path = f"/payments/{payment_id}" - body = {"action": action} - response = self.__api_client.call_api( - resource_path=resource_path, - body=body, + 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], + ok_error_codes=list(self._payment_error_codes.values())[:-1], ) - return deserialize(response=response, data_type=Payment) - def __get_payments( - self, action: PaymentAction, limit: int, callback: Callable | None = None - ) -> Page[Payment]: + def _get_payments(self, action: PaymentAction, limit: int) -> Page[Payment]: """ Get a list of ongoing payments with the given action :return: """ - wrapped_callback = wrap_callback(callback=callback, data_type=Payment) - - resource_path = "/payments" - parameters = {"action": action, "actor": self.__profile.id, "limit": limit} - # params `status: pending,held` - response = self.__api_client.call_api( - resource_path=resource_path, + parameters = {"action": action, "actor": self._profile.id, "limit": limit} + # other params `status: pending,held` + response = self._api_client.call_api( + resource_path="/payments", params=parameters, method="GET", - callback=wrapped_callback, ) - if callback: - return - return deserialize(response=response, data_type=Payment) - def __send_or_request_money( + 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, - target_user_id: str | None = None, - target_user: User | None = None, eligibility_token: str | None = None, - callback=None, ) -> Payment | None: """ Generic method for sending and requesting money @@ -325,12 +291,9 @@ def __send_or_request_money( :param funding_source_id: :param privacy_setting: :param target_user_id: - :param target_user: :param eligibility_token: - :param callback: :return: """ - target_user_id = str(get_user_id(target_user, target_user_id)) amount = abs(amount) if not is_send_money: @@ -340,7 +303,7 @@ def __send_or_request_money( "uuid": str(uuid.uuid4()), "user_id": target_user_id, "audience": privacy_setting, - "amount": amount, + "amount": round(amount, 2), "note": note, } @@ -348,49 +311,23 @@ def __send_or_request_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( + 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" - nested_response = ["payment"] - wrapped_callback = wrap_callback(callback, Payment, nested_response) - - response = self.__api_client.call_api( - resource_path=resource_path, - method="POST", - body=body, - callback=wrapped_callback, + response = self._api_client.call_api( + resource_path="/payments", method="POST", body=body ) # handle 200 status code errors - error_code = response["body"]["data"].get("error_code") + error_code = response.body["data"].get("error_code") if error_code: - if error_code == self.__payment_error_codes["not_enough_balance_error"]: + if error_code == self._payment_error_codes["not_enough_balance_error"]: raise NotEnoughBalanceError(amount, target_user_id) - error = response["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 deserialize(response, Payment, nested_response) - - def __get_transfer_destinations( - self, trans_type: Literal["standard", "instant"] - ) -> Page[TransferDestination]: - """ - Get a list of available transfer destination options for the given type - :param callback: - :return: - """ - - resource_path = "/transfers/options" - response = self.__api_client.call_api( - resource_path=resource_path, - method="GET", - ) - return deserialize(response, TransferDestination, [trans_type]) + return deserialize(response, Payment, nested_response=["payment"]) diff --git a/venmo_api/apis/user_api.py b/venmo_api/apis/user_api.py index 1641019..ffb5093 100644 --- a/venmo_api/apis/user_api.py +++ b/venmo_api/apis/user_api.py @@ -1,99 +1,83 @@ -from venmo_api import Page, Transaction, User, deserialize, get_user_id, wrap_callback -from venmo_api.utils.api_client import ApiClient +from venmo_api.apis.api_client import ApiClient, ValidatedResponse +from venmo_api.apis.api_util import deserialize +from venmo_api.models.page import Page +from venmo_api.models.transaction import Transaction +from venmo_api.models.us_dollars import UsDollars +from venmo_api.models.user import User -class UserApi(object): +class UserApi: def __init__(self, api_client: ApiClient): super().__init__() self.__api_client = api_client - self.__profile = None + self._profile = None + self._balance = None - def get_my_profile(self, callback=None, force_update=False) -> User | None: + def get_my_profile(self, force_update=False) -> User: """ Get my profile info and return as a :return my_profile: """ - if self.__profile and not force_update: - return self.__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 + response = self.__api_client.call_api(resource_path="/account", method="GET") + self._profile = deserialize(response, User, nested_response=["user"]) + return self._profile - self.__profile = deserialize(response, User, nested_response) - return self.__profile + def get_my_balance(self, force_update=False) -> float: + """ + Get my current balance info and return as a float + :return my_profile: + """ + 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, UsDollars, nested_response=["balance"]) + return self._balance + + # --- USERS --- def search_for_users( self, query: str, - callback=None, offset: int = 0, limit: int = 50, - username=False, - ) -> list[User] | None: + username: bool = False, + ) -> Page[User]: """ 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 """ - resource_path = "/users" - wrapped_callback = wrap_callback(callback=callback, data_type=User) - 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"}) response = self.__api_client.call_api( - resource_path=resource_path, - params=params, - method="GET", - callback=wrapped_callback, + resource_path="/users", params=params, method="GET" ) - # Return None if threaded - if callback: - return - 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, callback=None) -> User | None: + def get_user(self, user_id: str) -> User: """ Get the user profile with [user_id] :param user_id: , example: '2859950549165568970' - :param callback: :return user: """ - - # 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 + resource_path=f"/users/{user_id}", method="GET" ) - # Return None if threaded - if callback: - return - return deserialize(response=response, data_type=User) def get_user_by_username(self, username: str) -> User | None: @@ -112,91 +96,91 @@ def get_user_by_username(self, username: str) -> User | None: def get_user_friends_list( self, - user_id: str = None, - user: User = None, - callback=None, + user_id: str, offset: int = 0, limit: int = 3337, - ) -> Page | None: + ) -> Page[User]: """ Get ([user_id]'s or [user]'s) friends list as a list of s :return users_list: A list of objects or empty """ - user_id = get_user_id(user, user_id) params = {"limit": limit, "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, + resource_path=f"/users/{user_id}/friends", method="GET", params=params ) - # 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, ) + # --- TRANSACTIONS --- + def get_user_transactions( self, - user_id: str = None, - user: User = None, - callback=None, + user_id: str, + social_only: bool = False, + public_only: bool = False, limit: int = 50, - before_id=None, - ) -> Page | None: + before_id: str | None = None, + ) -> Page[Transaction]: """ 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: """ - user_id = get_user_id(user, user_id) - - params = {"limit": limit} - if before_id: - params["before_id"] = before_id - - # Prepare the request - resource_path = f"/stories/target-or-actor/{user_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, + 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, + }, ) - # 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_friends_transactions( + self, + social_only: bool = False, + public_only: bool = False, + limit: int = 50, + before_id: str | None = None, + ) -> Page[Transaction] | None: + """ + Get ([user_id]'s or [user]'s) transactions visible to yourself as a list of s + :param user_id: + :param user: + :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 = None, - user_id_two: str = None, - user_one: User = None, - user_two: User = None, - callback=None, + user_id_one: str, + user_id_two: str, + social_only: bool = False, + public_only: bool = False, limit: int = 50, - before_id=None, - ) -> Page | None: + before_id: str | None = None, + ) -> Page[Transaction] | None: """ Get the transactions between two users. Note that user_one must be the owner of the access token. Otherwise it raises an unauthorized error. @@ -204,36 +188,50 @@ def get_transaction_between_two_users( :param user_id_two: :param user_one: :param user_two: - :param callback: :param limit: :param before_id: :return: """ - 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: + """ """ + # TODO more? + 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}" - ) - - wrapped_callback = wrap_callback(callback=callback, data_type=Transaction) # Make the request response = self.__api_client.call_api( - resource_path=resource_path, + resource_path=f"/stories/target-or-actor/{endpoint_suffix}", 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}, ) + return response diff --git a/venmo_api/apis_prepydantic/__init__.py b/venmo_api/apis_prepydantic/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/venmo_api/apis_prepydantic/auth_api.py b/venmo_api/apis_prepydantic/auth_api.py deleted file mode 100644 index 7ffa649..0000000 --- a/venmo_api/apis_prepydantic/auth_api.py +++ /dev/null @@ -1,208 +0,0 @@ -from venmo_api import ( - ApiClient, - AuthenticationFailedError, - confirm, - random_device_id, - warn, -) - - -# NOTE: it seems a device-id is required for payments now, so ApiClient should probably -# own it -class AuthenticationApi(object): - TWO_FACTOR_ERROR_CODE = 81109 - - def __init__( - self, api_client: ApiClient | None = None, device_id: str | None = None - ): - super().__init__() - - self.__device_id = device_id or random_device_id() - self.__api_client = api_client or ApiClient(device_id=self.__device_id) - - 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: - """ - - # 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" - ) - - 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) - self.trust_this_device() - else: - access_token = response["body"]["access_token"] - - confirm("Successfully logged in. Note your token and device-id") - print(f"access_token: {access_token}\ndevice-id: {self.__device_id}") - - return access_token - - @staticmethod - def log_out(access_token: str) -> bool: - """ - Revoke your access_token - :param access_token: - :return: - """ - - resource_path = "/oauth/access_token" - api_client = ApiClient(access_token=access_token) - - api_client.call_api(resource_path=resource_path, method="DELETE") - - confirm("Successfully logged out.") - return True - - 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 - """ - - 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)" - ) - - self.send_text_otp(otp_secret=otp_secret) - 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) - - 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: - """ - - resource_path = "/oauth/access_token" - header_params = {"device-id": self.__device_id, "Host": "api.venmo.com"} - body = { - "phone_email_or_username": username, - "client_id": "1", - "password": password, - } - - 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], - ) - - 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: - """ - - resource_path = "/account/two-factor/token" - header_params = {"device-id": self.__device_id, "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: - reason = None - try: - reason = response["body"]["error"]["message"] - finally: - 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 - """ - - 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} - - 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): - """ - Add device_id or self.device_id (if no device_id passed) to the trusted devices on Venmo - :return: - """ - 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("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." - ) - - 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) - - @staticmethod - 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" - ) - - return otp diff --git a/venmo_api/apis_prepydantic/payment_api.py b/venmo_api/apis_prepydantic/payment_api.py deleted file mode 100644 index 5b8b34c..0000000 --- a/venmo_api/apis_prepydantic/payment_api.py +++ /dev/null @@ -1,341 +0,0 @@ -import uuid - -from venmo_api import ( - AlreadyRemindedPaymentError, - ApiClient, - ArgumentMissingError, - GeneralPaymentError, - NoPaymentMethodFoundError, - NoPendingPaymentToUpdateError, - NotEnoughBalanceError, - Payment, - PaymentMethod, - PaymentPrivacy, - PaymentRole, - User, - deserialize, - get_user_id, - wrap_callback, -) -from venmo_api.models.eligibility_token import EligibilityToken - - -class PaymentApi(object): - def __init__(self, profile, api_client: ApiClient): - super().__init__() - self.__profile = profile - 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, - } - - 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_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) - - def remind_payment(self, payment: Payment = None, payment_id: int = None) -> bool: - """ - 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 - """ - - # if the reminder has already sent - payment_id = payment_id or payment.id - 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 - ) - 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" - - response = self.__update_payment(action=action, payment_id=payment_id) - - if "error" in response.get("body"): - raise NoPendingPaymentToUpdateError(payment_id=payment_id, action=action) - return True - - def get_payment_methods(self, callback=None) -> list[PaymentMethod] | None: - """ - Get a list of available payment_methods - :param callback: - :return: - """ - - 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 - - return deserialize(response=response, data_type=PaymentMethod) - - 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, - ) -> bool | None: - """ - 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, - 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, - ) -> bool | None: - """ - 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. - """ - 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 __get_eligibility_token( - self, - amount: float, - note: str, - target_id: str, - action: str = "pay", - country_code: str = "1", - target_type: str = "user_id", - callback=None, - ) -> EligibilityToken: - """ - Generate eligibility token which is needed in payment requests - :param amount: amount of money to be requested - :param note: message/note of the transaction - :param target_id: the user id of the person you are sending money to - :param funding_source_id: Your payment_method id for this payment - :param action: action that eligibility token is used for - :param country_code: country code, not sure what this is for - :param target_type: set by default to user_id, but there are probably other target types - """ - resource_path = "/protection/eligibility" - body = { - "funding_source_id": "", - "action": action, - "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, body=body, method="POST" - ) - if callback: - return - - return deserialize(response=response, data_type=EligibilityToken) - - def __update_payment(self, action, payment_id): - if not payment_id: - raise ArgumentMissingError(arguments=("payment", "payment_id")) - - resource_path = f"/payments/{payment_id}" - body = { - "action": action, - } - return self.__api_client.call_api( - resource_path=resource_path, - body=body, - method="PUT", - ok_error_codes=list(self.__payment_error_codes.values())[:-1], - ) - - def __get_payments(self, action, limit, callback=None): - """ - Get a list of ongoing payments with the given action - :return: - """ - wrapped_callback = wrap_callback(callback=callback, data_type=Payment) - - resource_path = "/payments" - parameters = {"action": action, "actor": self.__profile.id, "limit": limit} - response = self.__api_client.call_api( - resource_path=resource_path, - params=parameters, - method="GET", - callback=wrapped_callback, - ) - if callback: - return - - 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, - eligibility_token: str = None, - callback=None, - ) -> bool | None: - """ - 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 eligibility_token: - :param callback: - :return: - """ - 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, - } - - 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) - - 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") - if error_code: - if error_code == self.__payment_error_codes["not_enough_balance_error"]: - raise NotEnoughBalanceError(amount, target_user_id) - - error = result["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() diff --git a/venmo_api/apis_prepydantic/user_api.py b/venmo_api/apis_prepydantic/user_api.py deleted file mode 100644 index caca172..0000000 --- a/venmo_api/apis_prepydantic/user_api.py +++ /dev/null @@ -1,241 +0,0 @@ -from venmo_api import Page, Transaction, User, deserialize, get_user_id, wrap_callback -from venmo_api.utils.api_client import ApiClient - - -class UserApi(object): - def __init__(self, api_client: ApiClient): - super().__init__() - self.__api_client = api_client - self.__profile = None - - def get_my_profile(self, callback=None, force_update=False) -> 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, - ) -> list[User] | None: - """ - 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 - """ - - resource_path = "/users" - wrapped_callback = wrap_callback(callback=callback, data_type=User) - - 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"}) - - response = self.__api_client.call_api( - resource_path=resource_path, - params=params, - method="GET", - callback=wrapped_callback, - ) - # Return None if threaded - if callback: - return - - 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, callback=None) -> User | None: - """ - Get the user profile with [user_id] - :param user_id: , example: '2859950549165568970' - :param callback: - :return user: - """ - - # 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 - - return deserialize(response=response, data_type=User) - - def get_user_by_username(self, username: str) -> User | None: - """ - Get the user profile with [username] - :param username: - :return user: - """ - users = self.search_for_users(query=username, username=True) - for user in users: - if user.username == username: - return user - - # 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, - ) -> 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 - """ - user_id = get_user_id(user, user_id) - params = {"limit": limit, "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, - ) -> 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: - """ - user_id = get_user_id(user, user_id) - - params = {"limit": limit} - if before_id: - params["before_id"] = before_id - - # Prepare the request - resource_path = f"/stories/target-or-actor/{user_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_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, - ) -> Page | None: - """ - 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: - """ - user_id_one = get_user_id(user_one, user_id_one) - user_id_two = get_user_id(user_two, user_id_two) - - params = {"limit": limit} - 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}" - ) - - 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}, - ) diff --git a/venmo_api/models/comment.py b/venmo_api/models/comment.py deleted file mode 100644 index dc65acf..0000000 --- a/venmo_api/models/comment.py +++ /dev/null @@ -1,13 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel - -from venmo_api import Mention, User - - -class Comment(BaseModel): - id: str - message: str - date_created: datetime - mentions: list[Mention] - user: User diff --git a/venmo_api/models/json_schema.py b/venmo_api/models/json_schema.py index aaf014c..2204477 100644 --- a/venmo_api/models/json_schema.py +++ b/venmo_api/models/json_schema.py @@ -1,189 +1,4 @@ -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) - - @staticmethod - def eligibility_token(json): - return EligibilityTokenParser(json) - - @staticmethod - def fee(json): - return FeeParser(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", -# } - +# TODO variant of user not encountered yet profile_json_format = { "user_id": "external_id", "username": "username", @@ -196,194 +11,3 @@ def get_amount(self): "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", -# } - -# TODO what's up with mentions/mentions_list? -# 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"} - - -# class EligibilityTokenParser: -# def __init__(self, json): -# self.json = json - -# def get_eligibility_token(self): -# return self.json.get(eligibility_token_json_format["eligibility_token"]) - -# def get_eligible(self): -# return self.json.get(eligibility_token_json_format["eligible"]) - -# def get_fees(self): -# return self.json.get(eligibility_token_json_format["fees"]) - -# def get_fee_disclaimer(self): -# return self.json.get(eligibility_token_json_format["fee_disclaimer"]) - - -# eligibility_token_json_format = { -# "eligibility_token": "eligibility_token", -# "eligible": "eligible", -# "fees": "fees", -# "fee_disclaimer": "fee_disclaimer", -# } - - -# class FeeParser: -# def __init__(self, json): -# self.json = json - -# def get_product_uri(self): -# return self.json.get(fee_json_format["product_uri"]) - -# def get_applied_to(self): -# return self.json.get(fee_json_format["applied_to"]) - -# def get_base_fee_amount(self): -# return self.json.get(fee_json_format["base_fee_amount"]) - -# def get_fee_percentage(self): -# return self.json.get(fee_json_format["fee_percentage"]) - -# def get_calculated_fee_amount_in_cents(self): -# return self.json.get(fee_json_format["calculated_fee_amount_in_cents"]) - -# def get_fee_token(self): -# return self.json.get(fee_json_format["fee_token"]) - - -# fee_json_format = { -# "product_uri": "product_uri", -# "applied_to": "applied_to", -# "base_fee_amount": "base_fee_amount", -# "fee_percentage": "fee_percentage", -# "calculated_fee_amount_in_cents": "calculated_fee_amount_in_cents", -# "fee_token": "fee_token", -# } diff --git a/venmo_api/models/mention.py b/venmo_api/models/mention.py deleted file mode 100644 index 8702723..0000000 --- a/venmo_api/models/mention.py +++ /dev/null @@ -1,35 +0,0 @@ -from venmo_api import BaseModel, JSONSchema, User - - -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 b5a967c..17bf6d7 100644 --- a/venmo_api/models/page.py +++ b/venmo_api/models/page.py @@ -1,4 +1,6 @@ class Page(list): + """fancy list that calls it's own next-in-line""" + def __init__(self): super().__init__() self.method = None diff --git a/venmo_api/models/payment.py b/venmo_api/models/payment.py index eab65e0..2d520b3 100644 --- a/venmo_api/models/payment.py +++ b/venmo_api/models/payment.py @@ -2,13 +2,17 @@ from enum import StrEnum, auto from typing import Any, Literal -from pydantic import AliasPath, BaseModel, Field +from pydantic import ( + AliasPath, + BaseModel, + Field, +) -from venmo_api import BaseModel -from venmo_api.models.user import User +from venmo_api.models.us_dollars import UsDollars +from venmo_api.models.user import PaymentPrivacy, User -# --- REQUEST PARAM ENUMS --- +# --- ENUMS --- class PaymentStatus(StrEnum): SETTLED = auto() CANCELLED = auto() @@ -18,35 +22,21 @@ class PaymentStatus(StrEnum): EXPIRED = auto() -class PaymentAction: +class PaymentAction(StrEnum): PAY = auto() CHARGE = auto() -class PaymentUpdate: - REMIND = auto() - CANCEL = auto() - - -# -- RESPONSE FIELD ENUMS --- - - -class PaymentRole(StrEnum): +class PaymentMethodRole(StrEnum): DEFAULT = auto() BACKUP = auto() NONE = auto() -class PaymentPrivacy(StrEnum): - PRIVATE = auto() - PUBLIC = auto() - FRIENDS = auto() - - -class PaymentType(StrEnum): +class PaymentMethodType(StrEnum): BANK = auto() BALANCE = auto() - CARDs = auto() + CARD = auto() # --- MODELS --- @@ -55,7 +45,7 @@ class PaymentType(StrEnum): class Fee(BaseModel): product_uri: str applied_to: str - base_fee_amount: float + base_fee_amount: UsDollars fee_percentage: float calculated_fee_amount_in_cents: int fee_token: str @@ -74,28 +64,28 @@ class Payment(BaseModel): id: str status: PaymentStatus action: PaymentAction - amount: float + amount: UsDollars | None date_created: datetime - audience: PaymentPrivacy + audience: PaymentPrivacy | None = None note: str - target: User = Field(validation_alias=AliasPath("user")) + 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 - fee: Fee | None + refund: Any | None = None + fee: Fee | None = None class PaymentMethod(BaseModel): id: str - type: PaymentType + type: PaymentMethodType name: str last_four: str | None - peer_payment_role: PaymentRole - merchant_payment_role: PaymentRole - top_up_role: PaymentRole - default_transfer_destination: Literal["default"] | None = 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 @@ -105,7 +95,7 @@ class PaymentMethod(BaseModel): class TransferDestination(BaseModel): id: str - type: PaymentType + type: PaymentMethodType name: str last_four: str | None is_default: bool @@ -115,7 +105,7 @@ class TransferDestination(BaseModel): class TransferPostResponse(BaseModel): id: str - amount: float + amount: UsDollars amount_cents: int amount_fee_cents: int amount_requested_cents: int diff --git a/venmo_api/models/transaction.py b/venmo_api/models/transaction.py index 12fce3d..d8f1528 100644 --- a/venmo_api/models/transaction.py +++ b/venmo_api/models/transaction.py @@ -1,124 +1,31 @@ +from datetime import datetime from enum import Enum +from typing import Annotated, Literal -from venmo_api import ( - BaseModel, - Comment, - JSONSchema, - User, - get_phone_model_from_json, - string_to_timestamp, -) +from pydantic import AliasPath, BaseModel, BeforeValidator, Field +from venmo_api.models.payment import Payment +from venmo_api.models.user import PaymentPrivacy, User -class Transaction(BaseModel): - 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, - ) +DEVICE_MAP = {1: "iPhone", 4: "Android", 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"] + + return DEVICE_MAP.get(int(_id)) + + +DeviceModel = Annotated[ + Literal["iPhone", "Android", "Other"], BeforeValidator(get_device_model_from_json) +] class TransactionType(Enum): @@ -127,7 +34,7 @@ class TransactionType(Enum): REFUND = "refund" # to/from bank account TRANSFER = "transfer" - # add money to debit card + # add money to debit cards TOP_UP = "top_up" # debit card purchase AUTHORIZATION = "authorization" @@ -135,3 +42,29 @@ class TransactionType(Enum): ATM_WITHDRAWAL = "atm_withdrawal" 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): + 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")) + # mentions: list[Mention] = Field(validation_alias=AliasPath("mentions", "data")) diff --git a/venmo_api/models/us_dollars.py b/venmo_api/models/us_dollars.py new file mode 100644 index 0000000..9f25ba9 --- /dev/null +++ b/venmo_api/models/us_dollars.py @@ -0,0 +1,53 @@ +# NOTE: default rounding (inherited from decimal.Decimal is ROUND_HALF_EVEN) +from decimal import Decimal + +from dinero import Dinero +from dinero.currencies import USD +from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import core_schema + + +class UsDollars(Dinero): + def __init__(self, amount: int | float | str | Decimal): + super().__init__(amount, currency=USD) + + def __str__(self) -> str: + return self.format() + + def __repr__(self) -> str: + return self.format(symbol=True, currency=True) + + @classmethod + def __get_pydantic_core_schema__(cls, _source, handler: GetCoreSchemaHandler): + def validate(v): + if isinstance(v, cls): + return v + if isinstance(v, Dinero): + return cls(v.amount) # adjust if needed + return cls(v) + + return core_schema.no_info_after_validator_function( + function=validate, + schema=core_schema.union_schema( + [ + core_schema.is_instance_schema(cls), + core_schema.is_instance_schema(Dinero), + core_schema.int_schema(), + core_schema.float_schema(), + core_schema.str_schema(), + core_schema.decimal_schema(), + ] + ), + serialization=core_schema.plain_serializer_function_ser_schema( + lambda v: str(v), when_used="json" + ), + ) + + @classmethod + def __get_pydantic_json_schema__( + cls, core_schema, handler: GetJsonSchemaHandler + ) -> JsonSchemaValue: + schema = handler(core_schema) + schema.update({"type": "string", "title": "US Dollars", "example": "19.99"}) + return schema diff --git a/venmo_api/models/user.py b/venmo_api/models/user.py index fd47d9d..c45ab2b 100644 --- a/venmo_api/models/user.py +++ b/venmo_api/models/user.py @@ -1,25 +1,52 @@ from datetime import datetime +from enum import StrEnum, auto from pydantic import BaseModel, EmailStr -from venmo_api.pydantic_models.payment_method import PaymentPrivacy + +class PaymentPrivacy(StrEnum): + PRIVATE = auto() + PUBLIC = auto() + FRIENDS = auto() + + +class FriendStatus(StrEnum): + FRIEND = auto() + NOT_FRIEND = auto() + + +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 class User(BaseModel): about: str date_joined: datetime - friends_count: int + friends_count: int | None is_active: bool is_blocked: bool - friend_status: str | None # TODO enum? + friend_status: FriendStatus | None profile_picture_url: str username: str - trust_request: str | None # TODO + trust_request: str | None # TODO, so far just None display_name: str email: EmailStr | None = None first_name: str id: str - identity_type: str # TODO enum? + identity_type: IdentityType is_group: bool last_name: str phone: str | None = None diff --git a/venmo_api/models_prepydantic/__init__.py b/venmo_api/models_prepydantic/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/venmo_api/models_prepydantic/base_model.py b/venmo_api/models_prepydantic/base_model.py deleted file mode 100644 index d7dc3db..0000000 --- a/venmo_api/models_prepydantic/base_model.py +++ /dev/null @@ -1,18 +0,0 @@ -# TODO Pydantic V2! - - -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_prepydantic/comment.py b/venmo_api/models_prepydantic/comment.py deleted file mode 100644 index 0eb8316..0000000 --- a/venmo_api/models_prepydantic/comment.py +++ /dev/null @@ -1,53 +0,0 @@ -from venmo_api import BaseModel, JSONSchema, Mention, User, string_to_timestamp - - -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_prepydantic/eligibility_token.py b/venmo_api/models_prepydantic/eligibility_token.py deleted file mode 100644 index d1a2c02..0000000 --- a/venmo_api/models_prepydantic/eligibility_token.py +++ /dev/null @@ -1,36 +0,0 @@ -from venmo_api import BaseModel, JSONSchema -from venmo_api.models.fee import Fee - - -class EligibilityToken(BaseModel): - def __init__(self, eligibility_token, eligible, fees, fee_disclaimer, json=None): - super().__init__() - - self.eligibility_token = eligibility_token - self.eligible = eligible - self.fees = fees - self.fee_disclaimer = fee_disclaimer - self._json = json - - @classmethod - def from_json(cls, json): - """ - Initialize a new eligibility token object from JSON. - :param json: JSON data to parse. - :return: EligibilityToken object. - """ - if not json: - return None - - parser = JSONSchema.eligibility_token(json) - - fees = parser.get_fees() - fee_objects = [Fee.from_json(fee) for fee in fees] if fees else [] - - return cls( - eligibility_token=parser.get_eligibility_token(), - eligible=parser.get_eligible(), - fees=fee_objects, - fee_disclaimer=parser.get_fee_disclaimer(), - json=json, - ) diff --git a/venmo_api/models_prepydantic/exception.py b/venmo_api/models_prepydantic/exception.py deleted file mode 100644 index dca904e..0000000 --- a/venmo_api/models_prepydantic/exception.py +++ /dev/null @@ -1,135 +0,0 @@ -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 "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" - ) - super(InvalidHttpMethodError, self).__init__(self.msg) - - -class ResourceNotFoundError(Exception): - """Raise it for 400 HTTP status code, when resource is not found""" - - def __init__(self, msg: str = None): - self.msg = msg or "400 Bad Request. Couldn't find the requested resource." - super(ResourceNotFoundError, self).__init__(self.msg) - - -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." - ) - status_code = response.status_code or "NA" - reason = response.reason or "Unknown reason" - try: - json = response.json() - except JSONDecodeError: - json = "Invalid Json" - - 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) - super(NoPaymentMethodFoundError, self).__init__(self.msg) - - -class AlreadyRemindedPaymentError(Exception): - def __init__(self, payment_id: int): - self.msg = f"A reminder has already been sent to the recipient of this transaction: {payment_id}." - super(AlreadyRemindedPaymentError, self).__init__(self.msg) - - -class NoPendingPaymentToUpdateError(Exception): - def __init__(self, payment_id: int, action: str): - self.msg = f"There is no *pending* transaction with the specified id: {payment_id}, to be {action}ed." - super(NoPendingPaymentToUpdateError, self).__init__(self.msg) - - -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" - ) - super(NotEnoughBalanceError, self).__init__(self.msg) - - -class GeneralPaymentError(Exception): - def __init__(self, msg): - self.msg = f"Transaction failed. {msg}" - super(GeneralPaymentError, self).__init__(self.msg) - - -__all__ = [ - "AuthenticationFailedError", - "InvalidArgumentError", - "InvalidHttpMethodError", - "ArgumentMissingError", - "JSONDecodeError", - "ResourceNotFoundError", - "HttpCodeError", - "NoPaymentMethodFoundError", - "AlreadyRemindedPaymentError", - "NoPendingPaymentToUpdateError", - "NotEnoughBalanceError", - "GeneralPaymentError", -] diff --git a/venmo_api/models_prepydantic/fee.py b/venmo_api/models_prepydantic/fee.py deleted file mode 100644 index 64a2444..0000000 --- a/venmo_api/models_prepydantic/fee.py +++ /dev/null @@ -1,45 +0,0 @@ -from venmo_api import BaseModel, JSONSchema - - -class Fee(BaseModel): - def __init__( - self, - product_uri, - applied_to, - base_fee_amount, - fee_percentage, - calculated_fee_amount_in_cents, - fee_token, - json=None, - ): - super().__init__() - - self.product_uri = product_uri - self.applied_to = applied_to - self.base_fee_amount = base_fee_amount - self.fee_percentage = fee_percentage - self.calculated_fee_amount_in_cents = calculated_fee_amount_in_cents - self.fee_token = fee_token - self._json = json - - @classmethod - def from_json(cls, json): - """ - Initialize a new Fee object from JSON using the FeeParser. - :param json: JSON data to parse. - :return: Fee object. - """ - if not json: - return None - - parser = JSONSchema.fee(json) - - return cls( - product_uri=parser.get_product_uri(), - applied_to=parser.get_applied_to(), - base_fee_amount=parser.get_base_fee_amount(), - fee_percentage=parser.get_fee_percentage(), - calculated_fee_amount_in_cents=parser.get_calculated_fee_amount_in_cents(), - fee_token=parser.get_fee_token(), - json=json, - ) diff --git a/venmo_api/models_prepydantic/json_schema.py b/venmo_api/models_prepydantic/json_schema.py deleted file mode 100644 index 2befb53..0000000 --- a/venmo_api/models_prepydantic/json_schema.py +++ /dev/null @@ -1,389 +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) - - @staticmethod - def eligibility_token(json): - return EligibilityTokenParser(json) - - @staticmethod - def fee(json): - return FeeParser(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"} - - -class EligibilityTokenParser: - def __init__(self, json): - self.json = json - - def get_eligibility_token(self): - return self.json.get(eligibility_token_json_format["eligibility_token"]) - - def get_eligible(self): - return self.json.get(eligibility_token_json_format["eligible"]) - - def get_fees(self): - return self.json.get(eligibility_token_json_format["fees"]) - - def get_fee_disclaimer(self): - return self.json.get(eligibility_token_json_format["fee_disclaimer"]) - - -eligibility_token_json_format = { - "eligibility_token": "eligibility_token", - "eligible": "eligible", - "fees": "fees", - "fee_disclaimer": "fee_disclaimer", -} - - -class FeeParser: - def __init__(self, json): - self.json = json - - def get_product_uri(self): - return self.json.get(fee_json_format["product_uri"]) - - def get_applied_to(self): - return self.json.get(fee_json_format["applied_to"]) - - def get_base_fee_amount(self): - return self.json.get(fee_json_format["base_fee_amount"]) - - def get_fee_percentage(self): - return self.json.get(fee_json_format["fee_percentage"]) - - def get_calculated_fee_amount_in_cents(self): - return self.json.get(fee_json_format["calculated_fee_amount_in_cents"]) - - def get_fee_token(self): - return self.json.get(fee_json_format["fee_token"]) - - -fee_json_format = { - "product_uri": "product_uri", - "applied_to": "applied_to", - "base_fee_amount": "base_fee_amount", - "fee_percentage": "fee_percentage", - "calculated_fee_amount_in_cents": "calculated_fee_amount_in_cents", - "fee_token": "fee_token", -} diff --git a/venmo_api/models_prepydantic/mention.py b/venmo_api/models_prepydantic/mention.py deleted file mode 100644 index 8702723..0000000 --- a/venmo_api/models_prepydantic/mention.py +++ /dev/null @@ -1,35 +0,0 @@ -from venmo_api import BaseModel, JSONSchema, User - - -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_prepydantic/page.py b/venmo_api/models_prepydantic/page.py deleted file mode 100644 index b5a967c..0000000 --- a/venmo_api/models_prepydantic/page.py +++ /dev/null @@ -1,35 +0,0 @@ -class Page(list): - def __init__(self): - super().__init__() - self.method = None - 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: - """ - self.method = method - self.kwargs = kwargs - self.current_offset = current_offset - return self - - 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) - else: - self.kwargs["before_id"] = self[-1].id - - return self.method(**self.kwargs) diff --git a/venmo_api/models_prepydantic/payment.py b/venmo_api/models_prepydantic/payment.py deleted file mode 100644 index 8c1c4fe..0000000 --- a/venmo_api/models_prepydantic/payment.py +++ /dev/null @@ -1,84 +0,0 @@ -from enum import Enum - -from venmo_api import BaseModel, JSONSchema, User, string_to_timestamp - - -class Payment(BaseModel): - 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" diff --git a/venmo_api/models_prepydantic/payment_method.py b/venmo_api/models_prepydantic/payment_method.py deleted file mode 100644 index b0e0bfe..0000000 --- a/venmo_api/models_prepydantic/payment_method.py +++ /dev/null @@ -1,74 +0,0 @@ -import logging -from enum import Enum - -from venmo_api import BaseModel, JSONSchema - - -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_prepydantic/transaction.py b/venmo_api/models_prepydantic/transaction.py deleted file mode 100644 index 12fce3d..0000000 --- a/venmo_api/models_prepydantic/transaction.py +++ /dev/null @@ -1,137 +0,0 @@ -from enum import Enum - -from venmo_api import ( - BaseModel, - Comment, - JSONSchema, - User, - get_phone_model_from_json, - string_to_timestamp, -) - - -class Transaction(BaseModel): - 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, - ) - - -class TransactionType(Enum): - PAYMENT = "payment" - # merchant refund - REFUND = "refund" - # to/from bank account - TRANSFER = "transfer" - # add money to debit card - TOP_UP = "top_up" - # debit card purchase - AUTHORIZATION = "authorization" - # debit card atm withdrawal - ATM_WITHDRAWAL = "atm_withdrawal" - - DISBURSEMENT = "disbursement" diff --git a/venmo_api/models_prepydantic/user.py b/venmo_api/models_prepydantic/user.py deleted file mode 100644 index 5ba0611..0000000 --- a/venmo_api/models_prepydantic/user.py +++ /dev/null @@ -1,79 +0,0 @@ -from venmo_api import BaseModel, JSONSchema, string_to_timestamp - - -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__() - - 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 - - parser = JSONSchema.user(json, is_profile=is_profile) - - 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, - ) diff --git a/venmo_api/utils/__init__.py b/venmo_api/utils/__init__.py deleted file mode 100644 index bde2635..0000000 --- a/venmo_api/utils/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from pathlib import Path - -PROJECT_ROOT = Path(__file__).parents[2] diff --git a/venmo_api/utils/api_util.py b/venmo_api/utils/api_util.py deleted file mode 100644 index 7fd45e1..0000000 --- a/venmo_api/utils/api_util.py +++ /dev/null @@ -1,125 +0,0 @@ -from enum import Enum -from typing import Any - -from pydantic import BaseModel - -from venmo_api import ArgumentMissingError, Page, User - - -def deserialize( - response: dict, data_type: type[BaseModel], nested_response: list[str] | None = None -) -> BaseModel | Page[BaseModel]: - """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.model_validate(data) - - -def wrap_callback( - callback, data_type: type[BaseModel], nested_response: list[str] | None = 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: list[Any], data_type: type[BaseModel] -) -> Page[BaseModel]: - """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.model_validate(obj) - 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 024be5d..0000000 --- a/venmo_api/utils/model_util.py +++ /dev/null @@ -1,52 +0,0 @@ -import uuid -from datetime import datetime - - -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: - """ - return str(uuid.uuid4()).upper() - # 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 23d9ded..436b718 100644 --- a/venmo_api/venmo.py +++ b/venmo_api/venmo.py @@ -1,5 +1,7 @@ from typing import Self +from loguru import logger + from venmo_api import ApiClient, AuthenticationApi, PaymentApi, UserApi @@ -56,8 +58,13 @@ def __init__( self.__api_client.update_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) + self._profile = self.user.get_my_profile() + # logger.info(pformat(self.__profile)) + self.__balance = self.user.get_my_balance() + logger.info(f"{self.__balance=}") + self.payment = PaymentApi( + profile=self._profile, api_client=self.__api_client, balance=self.__balance + ) def my_profile(self, force_update=False): """ @@ -65,9 +72,19 @@ def my_profile(self, force_update=False): :return: """ if force_update: - self.__profile = self.user.get_my_profile(force_update=force_update) + self._profile = self.user.get_my_profile(force_update=force_update) + + return self._profile + + def my_balance(self, force_update=False): + """ + Get your profile info. It can be cached from the prev time. + :return: + """ + if force_update: + self.__balance = self.user.get_my_balance(force_update=force_update) - return self.__profile + return self.__balance @property def access_token(self) -> str | None: From 5898ba48882627abdb2aacfb9357aacf58f7bc06 Mon Sep 17 00:00:00 2001 From: joshhubert-dsp <102703352+joshhubert-dsp@users.noreply.github.com> Date: Thu, 13 Nov 2025 03:11:16 -0800 Subject: [PATCH 14/23] cleanup getting ready for packaging --- README.md | 48 ++++++++- pyproject.toml | 28 +++-- uv.lock | 165 ++++++++++++++++++++++-------- venmo_api/apis/api_client.py | 29 ++---- venmo_api/apis/api_util.py | 10 +- venmo_api/apis/auth_api.py | 62 +++++------ venmo_api/apis/exception.py | 25 ----- venmo_api/apis/logging_session.py | 33 +++--- venmo_api/apis/payment_api.py | 25 ++--- venmo_api/apis/user_api.py | 8 +- venmo_api/models/json_schema.py | 13 --- venmo_api/models/payment.py | 19 ++-- venmo_api/models/transaction.py | 3 +- venmo_api/models/us_dollars.py | 53 ---------- venmo_api/venmo.py | 15 +-- zsetup.py | 51 --------- 16 files changed, 281 insertions(+), 306 deletions(-) delete mode 100644 venmo_api/models/json_schema.py delete mode 100644 venmo_api/models/us_dollars.py delete mode 100644 zsetup.py diff --git a/README.md b/README.md index 431f8fd..59b2cb5 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,54 @@ -# Venmo API +# Python Venmo API - Updated Fork + +This is as a fork of [mmohades' python venmo api +package](https://github.com/mmohades/Venmo.git), 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. + +## MAJOR 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 see 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`. + +## 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 on desktop, +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. + +``` +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 +``` 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 diff --git a/pyproject.toml b/pyproject.toml index 844fb0d..3f4dfcd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,28 +5,42 @@ 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" ] -requires-python = ">=3.12,<3.13" +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", - "dinero>=0.4.0", - "loguru>=0.7.3", "orjson>=3.11.3", "pydantic>=2.12.4", - "requests>=2.19.0", + "requests>=2.32.5", + "rich>=14.2.0", ] [build-system] requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" -[tool.pytest.ini_options] -testpaths = ["tests"] - [tool.setuptools] package-dir = { "" = "." } diff --git a/uv.lock b/uv.lock index 50952dc..e8da9f8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = "==3.12.*" +requires-python = ">=3.11, <3.14" [[package]] name = "annotated-types" @@ -38,6 +38,17 @@ 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" }, @@ -49,18 +60,20 @@ wheels = [ { 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 = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - [[package]] name = "devtools" version = "0.12.2" @@ -75,18 +88,6 @@ 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 = "dinero" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/69/56/d5b5a2fd33c844be8c3a42ab42f95fb51a6297486966599fa25abc79776e/dinero-0.4.0.tar.gz", hash = "sha256:87b55cc17dd5a9a2acf7bd044d460c769f869100e3d5a286a7a94c065af84125", size = 20799, upload-time = "2025-05-18T21:43:02.487Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/0e/cca2c39fb429d465a1ea54038d6449a61ddd818d6b9886691b1aa184ba94/dinero-0.4.0-py3-none-any.whl", hash = "sha256:19f66e4fa7b1c7b9419bb1d942cf0a06c12c1f38b444b21cec510f5a9408c387", size = 52938, upload-time = "2025-05-18T21:43:01.004Z" }, -] - [[package]] name = "executing" version = "2.2.1" @@ -106,16 +107,24 @@ wheels = [ ] [[package]] -name = "loguru" -version = "0.7.3" +name = "markdown-it-py" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "win32-setctime", marker = "sys_platform == 'win32'" }, + { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +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/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, + { 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]] @@ -124,6 +133,21 @@ 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" }, @@ -139,6 +163,21 @@ wheels = [ { 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]] @@ -165,6 +204,20 @@ dependencies = [ ] 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" }, @@ -179,10 +232,36 @@ wheels = [ { 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]] @@ -209,6 +288,19 @@ 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" @@ -254,28 +346,17 @@ version = "1.0.0" source = { editable = "." } dependencies = [ { name = "devtools" }, - { name = "dinero" }, - { name = "loguru" }, { name = "orjson" }, { name = "pydantic" }, { name = "requests" }, + { name = "rich" }, ] [package.metadata] requires-dist = [ { name = "devtools", specifier = ">=0.12.2" }, - { name = "dinero", specifier = ">=0.4.0" }, - { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.3" }, { name = "pydantic", specifier = ">=2.12.4" }, - { name = "requests", specifier = ">=2.19.0" }, -] - -[[package]] -name = "win32-setctime" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, + { name = "requests", specifier = ">=2.32.5" }, + { name = "rich", specifier = ">=14.2.0" }, ] diff --git a/venmo_api/apis/api_client.py b/venmo_api/apis/api_client.py index c075e6d..de9a5ca 100644 --- a/venmo_api/apis/api_client.py +++ b/venmo_api/apis/api_client.py @@ -1,24 +1,20 @@ import os -from dataclasses import dataclass from json import JSONDecodeError from random import getrandbits import orjson import requests -from requests.structures import CaseInsensitiveDict from venmo_api import PROJECT_ROOT -from venmo_api.apis.exception import InvalidHttpMethodError, ResourceNotFoundError +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 -@dataclass(frozen=True, slots=True) -class ValidatedResponse: - status_code: int - headers: CaseInsensitiveDict - body: list | dict - - class ApiClient: """ Generic API Client for the Venmo API @@ -90,7 +86,7 @@ def call_api( # Update the header with the required values header_params = header_params or {} - if body: + if body: # POST or PUT header_params.update({"Content-Type": "application/json; charset=utf-8"}) url = self.configuration["host"] + resource_path @@ -99,20 +95,17 @@ def call_api( processed_response = self.request( method, url, - self.session, header_params=header_params, params=params, body=body, ok_error_codes=ok_error_codes, ) - self.last_response = processed_response return processed_response def request( self, method, url, - session: requests.Session, header_params=None, params=None, body=None, @@ -134,10 +127,9 @@ def request( if method not in ["POST", "PUT", "GET", "DELETE"]: raise InvalidHttpMethodError() - response = session.request( + response = self.session.request( method=method, url=url, headers=header_params, params=params, json=body ) - validated_response = self._validate_response( response, ok_error_codes=ok_error_codes ) @@ -167,9 +159,8 @@ def _validate_response( ): return built_response - elif response.status_code == 400 and body.get.get("error").get("code") == 283: + elif response.status_code == 400 and body.get("error").get("code") == 283: raise ResourceNotFoundError() else: - response.raise_for_status() - # raise HttpCodeError(response=response) + raise HttpCodeError(response=response) diff --git a/venmo_api/apis/api_util.py b/venmo_api/apis/api_util.py index 5dd9e28..2634d25 100644 --- a/venmo_api/apis/api_util.py +++ b/venmo_api/apis/api_util.py @@ -1,12 +1,20 @@ +from dataclasses import dataclass from enum import Enum from typing import Any from pydantic import BaseModel +from requests.structures import CaseInsensitiveDict -from venmo_api.apis.api_client import ValidatedResponse from venmo_api.models.page import Page +@dataclass(frozen=True, slots=True) +class ValidatedResponse: + status_code: int + headers: CaseInsensitiveDict + body: list | dict + + def deserialize( response: ValidatedResponse, data_type: type[BaseModel | Any], diff --git a/venmo_api/apis/auth_api.py b/venmo_api/apis/auth_api.py index eb11ddc..b8e686a 100644 --- a/venmo_api/apis/auth_api.py +++ b/venmo_api/apis/auth_api.py @@ -1,20 +1,19 @@ import uuid -from venmo_api.apis.api_client import ApiClient, ValidatedResponse -from venmo_api.apis.api_util import confirm, warn +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 -def random_device_id(): +def random_device_id() -> str: """ Generate a random device id that can be used for logging in. - :return: + 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() -# NOTE: it seems a device-id is required for payments now, so ApiClient should probably -# own it class AuthenticationApi: TWO_FACTOR_ERROR_CODE = 81109 @@ -23,8 +22,12 @@ def __init__( ): super().__init__() - self.__device_id = device_id or random_device_id() - self._api_client = api_client or ApiClient(device_id=self.__device_id) + self._device_id = device_id or random_device_id() + if not api_client: + self._api_client = ApiClient(device_id=self._device_id) + else: + self._api_client = api_client + self._api_client.update_device_id(self._device_id) def login_with_credentials_cli(self, username: str, password: str) -> str: """ @@ -38,7 +41,7 @@ def login_with_credentials_cli(self, username: str, password: str) -> str: 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}") + 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" @@ -54,7 +57,7 @@ def login_with_credentials_cli(self, username: str, password: str) -> str: access_token = response.body["access_token"] confirm("Successfully logged in. Note your token and device-id") - print(f"access_token: {access_token}\ndevice-id: {self.__device_id}") + print(f"access_token: {access_token}\ndevice-id: {self._device_id}") return access_token @@ -108,9 +111,6 @@ def authenticate_using_username_password( :param password: :return: """ - - resource_path = "/oauth/access_token" - header_params = {"device-id": self.__device_id, "Host": "api.venmo.com"} body = { "phone_email_or_username": username, "client_id": "1", @@ -118,8 +118,7 @@ def authenticate_using_username_password( } return self._api_client.call_api( - resource_path=resource_path, - header_params=header_params, + resource_path="/oauth/access_token", body=body, method="POST", ok_error_codes=[self.TWO_FACTOR_ERROR_CODE], @@ -131,15 +130,10 @@ def send_text_otp(self, otp_secret: str) -> ValidatedResponse: :param otp_secret: the otp-secret from response_headers.venmo-otp-secret :return: """ - - resource_path = "/account/two-factor/token" - header_params = {"device-id": self.__device_id, "venmo-otp-secret": otp_secret} - body = {"via": "sms"} - response = self._api_client.call_api( - resource_path=resource_path, - header_params=header_params, - body=body, + resource_path="/account/two-factor/token", + header_params={"venmo-otp-secret": otp_secret}, + body={"via": "sms"}, method="POST", ) @@ -163,18 +157,11 @@ def authenticate_using_otp(self, user_otp: str, otp_secret: str) -> str: :return: access_token """ - 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} - + header_params = {"venmo-otp": user_otp, "venmo-otp-secret": otp_secret} response = self._api_client.call_api( - resource_path=resource_path, + resource_path="/oauth/access_token", header_params=header_params, - params=params, + params={"client_id": 1}, method="POST", ) return response.body["access_token"] @@ -184,21 +171,20 @@ def trust_this_device(self, device_id=None): Add device_id or self.device_id (if no device_id passed) to the trusted devices on Venmo :return: """ - device_id = device_id or self.__device_id + 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" + resource_path="/users/devices", header_params=header_params, method="POST" ) confirm("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." + f"Use the same device-id: {self._device_id} next time to avoid 2-factor-auth process." ) def get_device_id(self): - return self.__device_id + return self._device_id def set_access_token(self, access_token): self._api_client.update_access_token(access_token=access_token) diff --git a/venmo_api/apis/exception.py b/venmo_api/apis/exception.py index dca904e..d7e5a2a 100644 --- a/venmo_api/apis/exception.py +++ b/venmo_api/apis/exception.py @@ -57,29 +57,6 @@ def __init__(self, response=None, msg: str = None): 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 ======= @@ -121,9 +98,7 @@ def __init__(self, msg): __all__ = [ "AuthenticationFailedError", - "InvalidArgumentError", "InvalidHttpMethodError", - "ArgumentMissingError", "JSONDecodeError", "ResourceNotFoundError", "HttpCodeError", diff --git a/venmo_api/apis/logging_session.py b/venmo_api/apis/logging_session.py index fc76925..5ce6cd3 100644 --- a/venmo_api/apis/logging_session.py +++ b/venmo_api/apis/logging_session.py @@ -1,14 +1,14 @@ import orjson from devtools import pformat -from loguru import logger 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): +def safe_text(b: bytes | None, fallback_repr=True) -> str: if b is None: - return "" + return "None" try: text = b.decode("utf-8") if len(text) <= MAX_BODY_LOG: @@ -24,30 +24,35 @@ def safe_text(b: bytes | None, fallback_repr=True): class LoggingSession(Session): + """ + requests.Session subclass that pretty-logs its requests and responses using + rich.print + """ + def send(self, request: PreparedRequest, **kwargs) -> Response: - logger.debug(f"→ {request.method} {request.url}") - logger.trace(f"→ Request headers: {pformat(dict(request.headers))}") + print(f"\n→ REQUEST: {request.method} {request.url}") + print(f"→ Request headers: {pformat(dict(request.headers))}") + body = request.body if isinstance(body, str): - logger.trace(f"→ Request body (str): {pformat(safe_text(body.encode()))}") + print(f"→ Request body (str): {pformat(safe_text(body.encode()))}") elif isinstance(body, bytes): - logger.trace(f"→ Request body (bytes): {pformat(safe_text(body))}") + print(f"→ Request body (bytes): {pformat(safe_text(body))}") elif body is None: - logger.trace("→ Request body: ") + print("→ Request body: None") else: # could be generator/iterable (multipart streaming) - logger.trace(f"→ Request body: (type={type(body).__name__}) {repr(body)}") + print(f"→ Request body: (type={type(body).__name__}) {repr(body)}") resp = super().send(request, **kwargs) - logger.debug(f"← {resp.status_code} {resp.reason}") - logger.trace(f"← Response headers: {pformat(dict(resp.headers))}") + print(f"← RESPONSE: {resp.status_code} {resp.reason}") + print(f"← Response headers: {pformat(dict(resp.headers))}") - # careful: .content will load the whole response into memory try: content = resp.content - logger.trace(f"← Response body: {pformat(safe_text(content))}") + print(f"← Response body: {pformat(safe_text(content))}") except Exception as e: - logger.trace(f"← Response body: ") + print(f"← Response body: ") return resp diff --git a/venmo_api/apis/payment_api.py b/venmo_api/apis/payment_api.py index c7523a1..d2909cd 100644 --- a/venmo_api/apis/payment_api.py +++ b/venmo_api/apis/payment_api.py @@ -1,11 +1,10 @@ import uuid from typing import Literal -from venmo_api.apis.api_client import ApiClient, ValidatedResponse -from venmo_api.apis.api_util import deserialize +from venmo_api.apis.api_client import ApiClient +from venmo_api.apis.api_util import ValidatedResponse, deserialize from venmo_api.apis.exception import ( AlreadyRemindedPaymentError, - ArgumentMissingError, GeneralPaymentError, NoPaymentMethodFoundError, NoPendingPaymentToUpdateError, @@ -55,16 +54,13 @@ def get_pay_payments(self, limit=100000) -> Page[Payment]: """ return self._get_payments(action="pay", limit=limit) - def remind_payment(self, payment: Payment = None, payment_id: int = None) -> bool: + def remind_payment(self, payment_id: str) -> bool: """ 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 """ - - # if the reminder has already sent - payment_id = payment_id or payment.id action = "remind" response = self._update_payment(action=action, payment_id=payment_id) @@ -78,18 +74,15 @@ def remind_payment(self, payment: Payment = None, payment_id: int = None) -> boo raise AlreadyRemindedPaymentError(payment_id=payment_id) return True - def cancel_payment(self, payment: Payment = None, payment_id: int = None) -> bool: + def cancel_payment(self, payment_id: str) -> bool: """ - Cancel the payment/payment_id provided. Only applicable to payments you have access to (requested payments) + Cancel the 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 action = "cancel" - payment_id = payment_id or payment.id response = self._update_payment(action=action, payment_id=payment_id) - if "error" in response.body: raise NoPendingPaymentToUpdateError(payment_id, action) return True @@ -165,8 +158,9 @@ def get_transfer_destinations( Get a list of available transfer destination options for the given type :return: """ - resource_path = "/transfers/options" - response = self._api_client.call_api(resource_path=resource_path, method="GET") + response = self._api_client.call_api( + resource_path="/transfers/options", method="GET" + ) return deserialize( response, TransferDestination, [trans_type, "eligible_destinations"] ) @@ -249,9 +243,6 @@ def _get_eligibility_token( def _update_payment( self, action: Literal["remind", "cancel"], payment_id: str ) -> ValidatedResponse: - if not payment_id: - raise ArgumentMissingError(arguments=("payment", "payment_id")) - return self._api_client.call_api( resource_path=f"/payments/{payment_id}", body={"action": action}, diff --git a/venmo_api/apis/user_api.py b/venmo_api/apis/user_api.py index ffb5093..b8cef26 100644 --- a/venmo_api/apis/user_api.py +++ b/venmo_api/apis/user_api.py @@ -1,8 +1,7 @@ -from venmo_api.apis.api_client import ApiClient, ValidatedResponse -from venmo_api.apis.api_util import deserialize +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.us_dollars import UsDollars from venmo_api.models.user import User @@ -34,7 +33,7 @@ def get_my_balance(self, force_update=False) -> float: return self._balance response = self.__api_client.call_api(resource_path="/account", method="GET") - self._balance = deserialize(response, UsDollars, nested_response=["balance"]) + self._balance = deserialize(response, float, nested_response=["balance"]) return self._balance # --- USERS --- @@ -219,7 +218,6 @@ def _get_transactions( before_id: str | None, ) -> ValidatedResponse | None: """ """ - # TODO more? params = { "limit": limit, "social_only": str(social_only).lower(), diff --git a/venmo_api/models/json_schema.py b/venmo_api/models/json_schema.py deleted file mode 100644 index 2204477..0000000 --- a/venmo_api/models/json_schema.py +++ /dev/null @@ -1,13 +0,0 @@ -# TODO variant of user not encountered yet -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", -} diff --git a/venmo_api/models/payment.py b/venmo_api/models/payment.py index 2d520b3..ec4a905 100644 --- a/venmo_api/models/payment.py +++ b/venmo_api/models/payment.py @@ -1,16 +1,13 @@ from datetime import datetime from enum import StrEnum, auto -from typing import Any, Literal +from typing import Annotated, Any, Literal -from pydantic import ( - AliasPath, - BaseModel, - Field, -) +from pydantic import AfterValidator, AliasPath, BaseModel, Field -from venmo_api.models.us_dollars import UsDollars from venmo_api.models.user import PaymentPrivacy, User +UsDollarsFloat = Annotated[float, AfterValidator(lambda v: round(v, 2))] + # --- ENUMS --- class PaymentStatus(StrEnum): @@ -45,7 +42,7 @@ class PaymentMethodType(StrEnum): class Fee(BaseModel): product_uri: str applied_to: str - base_fee_amount: UsDollars + base_fee_amount: UsDollarsFloat fee_percentage: float calculated_fee_amount_in_cents: int fee_token: str @@ -64,7 +61,7 @@ class Payment(BaseModel): id: str status: PaymentStatus action: PaymentAction - amount: UsDollars | None + amount: UsDollarsFloat | None date_created: datetime audience: PaymentPrivacy | None = None note: str @@ -94,7 +91,7 @@ class PaymentMethod(BaseModel): class TransferDestination(BaseModel): - id: str + id: int type: PaymentMethodType name: str last_four: str | None @@ -105,7 +102,7 @@ class TransferDestination(BaseModel): class TransferPostResponse(BaseModel): id: str - amount: UsDollars + amount: UsDollarsFloat amount_cents: int amount_fee_cents: int amount_requested_cents: int diff --git a/venmo_api/models/transaction.py b/venmo_api/models/transaction.py index d8f1528..11bdec3 100644 --- a/venmo_api/models/transaction.py +++ b/venmo_api/models/transaction.py @@ -7,7 +7,7 @@ from venmo_api.models.payment import Payment from venmo_api.models.user import PaymentPrivacy, User -DEVICE_MAP = {1: "iPhone", 4: "Android", 0: "Other"} +DEVICE_MAP = {1: "iPhone", 4: "Android", 10: "Desktop Browser", 0: "Other"} def get_device_model_from_json(app_json: dict): @@ -67,4 +67,3 @@ class Transaction(BaseModel): audience: PaymentPrivacy device_used: DeviceModel = Field(validation_alias="app") comments: list[Comment] = Field(validation_alias=AliasPath("comments", "data")) - # mentions: list[Mention] = Field(validation_alias=AliasPath("mentions", "data")) diff --git a/venmo_api/models/us_dollars.py b/venmo_api/models/us_dollars.py deleted file mode 100644 index 9f25ba9..0000000 --- a/venmo_api/models/us_dollars.py +++ /dev/null @@ -1,53 +0,0 @@ -# NOTE: default rounding (inherited from decimal.Decimal is ROUND_HALF_EVEN) -from decimal import Decimal - -from dinero import Dinero -from dinero.currencies import USD -from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler -from pydantic.json_schema import JsonSchemaValue -from pydantic_core import core_schema - - -class UsDollars(Dinero): - def __init__(self, amount: int | float | str | Decimal): - super().__init__(amount, currency=USD) - - def __str__(self) -> str: - return self.format() - - def __repr__(self) -> str: - return self.format(symbol=True, currency=True) - - @classmethod - def __get_pydantic_core_schema__(cls, _source, handler: GetCoreSchemaHandler): - def validate(v): - if isinstance(v, cls): - return v - if isinstance(v, Dinero): - return cls(v.amount) # adjust if needed - return cls(v) - - return core_schema.no_info_after_validator_function( - function=validate, - schema=core_schema.union_schema( - [ - core_schema.is_instance_schema(cls), - core_schema.is_instance_schema(Dinero), - core_schema.int_schema(), - core_schema.float_schema(), - core_schema.str_schema(), - core_schema.decimal_schema(), - ] - ), - serialization=core_schema.plain_serializer_function_ser_schema( - lambda v: str(v), when_used="json" - ), - ) - - @classmethod - def __get_pydantic_json_schema__( - cls, core_schema, handler: GetJsonSchemaHandler - ) -> JsonSchemaValue: - schema = handler(core_schema) - schema.update({"type": "string", "title": "US Dollars", "example": "19.99"}) - return schema diff --git a/venmo_api/venmo.py b/venmo_api/venmo.py index 436b718..44a4f42 100644 --- a/venmo_api/venmo.py +++ b/venmo_api/venmo.py @@ -1,11 +1,9 @@ from typing import Self -from loguru import logger - from venmo_api import ApiClient, AuthenticationApi, PaymentApi, UserApi -class Client(object): +class Client: @staticmethod def login(username: str, password: str, device_id: str | None = None) -> Self: """ @@ -59,9 +57,7 @@ def __init__( self.user = UserApi(self.__api_client) self._profile = self.user.get_my_profile() - # logger.info(pformat(self.__profile)) self.__balance = self.user.get_my_balance() - logger.info(f"{self.__balance=}") self.payment = PaymentApi( profile=self._profile, api_client=self.__api_client, balance=self.__balance ) @@ -78,7 +74,7 @@ def my_profile(self, force_update=False): def my_balance(self, force_update=False): """ - Get your profile info. It can be cached from the prev time. + Get your balance info. It can be cached from the prev time. :return: """ if force_update: @@ -90,6 +86,13 @@ def my_balance(self, force_update=False): 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, token: str | None = None) -> bool: """ Revoke your access_token. Log out, in other words. diff --git a/zsetup.py b/zsetup.py deleted file mode 100644 index d0044ea..0000000 --- a/zsetup.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', - ] -) From 8c1b4aba2b89852f3727cf1e92cf19f630c84aa3 Mon Sep 17 00:00:00 2001 From: joshhubert-dsp <102703352+joshhubert-dsp@users.noreply.github.com> Date: Thu, 13 Nov 2025 03:39:52 -0800 Subject: [PATCH 15/23] Client.login_from_env --- README.md | 17 ++++++++--------- venmo_api/venmo.py | 9 +++++++++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 59b2cb5..85efe51 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # Python Venmo API - Updated Fork -This is as a fork of [mmohades' python venmo api -package](https://github.com/mmohades/Venmo.git), 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. +This is as 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. ## MAJOR UPDATES @@ -16,7 +15,7 @@ version is quite different. - 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 see the raw requests sent and responses +- 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. @@ -32,13 +31,13 @@ version is quite different. 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 on desktop, +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. -``` +```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 diff --git a/venmo_api/venmo.py b/venmo_api/venmo.py index 44a4f42..f284d8e 100644 --- a/venmo_api/venmo.py +++ b/venmo_api/venmo.py @@ -1,9 +1,18 @@ +import os from typing import Self from venmo_api import ApiClient, AuthenticationApi, PaymentApi, UserApi class Client: + @staticmethod + def login_from_env( + username_env: str, password_env: str, device_id_env: str + ) -> Self: + 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: """ From 8971137a70e39863dcb445f07befa8f9f97faa07 Mon Sep 17 00:00:00 2001 From: joshhubert-dsp <102703352+joshhubert-dsp@users.noreply.github.com> Date: Thu, 13 Nov 2025 03:40:27 -0800 Subject: [PATCH 16/23] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 85efe51..0cedb2a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Python Venmo API - Updated Fork -This is as a fork of mmohades' [python venmo api] (https://github.com/mmohades/Venmo.git) +This is as 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 From 56bcc5f960b474b7bb1ec4749d24a72f2da80c58 Mon Sep 17 00:00:00 2001 From: joshhubert-dsp <102703352+joshhubert-dsp@users.noreply.github.com> Date: Thu, 13 Nov 2025 03:41:14 -0800 Subject: [PATCH 17/23] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0cedb2a..0574c58 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Python Venmo API - Updated Fork -This is as a fork of mmohades' [python venmo api](https://github.com/mmohades/Venmo.git) +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 From 3c24ef898020c0d6798ed2a9757913fac87b78ea Mon Sep 17 00:00:00 2001 From: joshhubert-dsp <102703352+joshhubert-dsp@users.noreply.github.com> Date: Thu, 13 Nov 2025 12:50:53 -0800 Subject: [PATCH 18/23] EligibilityToken.ineligible_reason --- README.md | 4 ++-- venmo_api/models/payment.py | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0574c58..7e107db 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ Good instructions [here](https://blog.sayan.page/mitm-proxy-on-ios/). Luckily yo have to do this once, the ID is fixed. ```bash -brew install mitmproxy +$ 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 +$ mitmweb --listen-host 0.0.0.0 --listen-port 8080 --web-port 8081 ``` Disclaimer: This is an individual effort and is not PayPal/Venmo sponsored or maintained. diff --git a/venmo_api/models/payment.py b/venmo_api/models/payment.py index ec4a905..151c432 100644 --- a/venmo_api/models/payment.py +++ b/venmo_api/models/payment.py @@ -55,6 +55,10 @@ class EligibilityToken(BaseModel): 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): From 1338788603537d44af6a49ff941c1f1d1c8ee33a Mon Sep 17 00:00:00 2001 From: joshhubert-dsp <102703352+joshhubert-dsp@users.noreply.github.com> Date: Fri, 14 Nov 2025 23:14:21 -0800 Subject: [PATCH 19/23] docstrings updated --- README.md | 10 +- venmo_api/apis/api_client.py | 82 +++++---------- venmo_api/apis/api_util.py | 32 +++--- venmo_api/apis/auth_api.py | 129 +++++++++++++---------- venmo_api/apis/payment_api.py | 178 ++++++++++++++++++++------------ venmo_api/apis/user_api.py | 158 ++++++++++++++++++---------- venmo_api/models/page.py | 22 ++-- venmo_api/models/payment.py | 9 ++ venmo_api/models/transaction.py | 2 + venmo_api/models/user.py | 1 + venmo_api/venmo.py | 110 ++++++++++++++------ 11 files changed, 446 insertions(+), 287 deletions(-) diff --git a/README.md b/README.md index 7e107db..2b2a23f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ payments) no longer worked. I took the liberty of fixing payment functionality, additional endpoints, and refactoring it almost beyond recognition. To be specific, this uses the mobile Venmo app API, the browser version is quite different. -## MAJOR UPDATES +## FORK UPDATES - Payments work again! Credit to [Joseph Charles](https://github.com/j027/Venmo) for adding eligibility token support and laying the groundwork. @@ -26,6 +26,12 @@ uses the mobile Venmo app API, the browser version is quite different. 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 @@ -43,6 +49,8 @@ $ brew install mitmproxy $ 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 diff --git a/venmo_api/apis/api_client.py b/venmo_api/apis/api_client.py index de9a5ca..f884137 100644 --- a/venmo_api/apis/api_client.py +++ b/venmo_api/apis/api_client.py @@ -22,8 +22,11 @@ class ApiClient: def __init__(self, access_token: str | None = None, device_id: str | None = None): """ - :param access_token: access token you received for your account, not - including the 'Bearer ' prefix, that's added to the request header. + 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. + device_id (str | None, optional): Must be a real device ID. Defaults to None. """ super().__init__() @@ -66,74 +69,42 @@ def call_api( self, resource_path: str, method: str, - header_params: dict = None, + headers: dict = None, params: dict = None, body: dict = None, ok_error_codes: list[int] = None, ) -> ValidatedResponse: - """ - 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 ok_error_codes: A list of integer error codes that you don't want an exception for. - - :return: response: {'status_code': , 'headers': , 'body': } + """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 - header_params = header_params or {} + headers = headers or {} if body: # POST or PUT - header_params.update({"Content-Type": "application/json; charset=utf-8"}) - + headers.update({"Content-Type": "application/json; charset=utf-8"}) url = self.configuration["host"] + resource_path - # perform request and return response - processed_response = self.request( - method, - url, - header_params=header_params, - params=params, - body=body, - ok_error_codes=ok_error_codes, - ) - return processed_response - - def request( - self, - method, - url, - header_params=None, - params=None, - body=None, - ok_error_codes: list[int] = None, - ) -> ValidatedResponse: - """ - 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 = self.session.request( - method=method, url=url, headers=header_params, params=params, json=body - ) - validated_response = self._validate_response( - response, ok_error_codes=ok_error_codes + method=method, url=url, headers=headers, params=params, json=body ) - + validated_response = self._validate_response(response, ok_error_codes) return validated_response @staticmethod @@ -142,9 +113,6 @@ def _validate_response( ) -> ValidatedResponse: """ 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: """ headers = response.headers try: diff --git a/venmo_api/apis/api_util.py b/venmo_api/apis/api_util.py index 2634d25..f20249a 100644 --- a/venmo_api/apis/api_util.py +++ b/venmo_api/apis/api_util.py @@ -21,13 +21,17 @@ def deserialize( nested_response: list[str] | None = None, ) -> Any | Page[Any]: """Extract one or a list of Objects from the api_client structured response. - :param response: - :param data_type: if data of interest is a json object, should be a pydantic - BaseModel subclass. Otherwise can be a primitive class - :param nested_response: Optional. Loop through the body - :return: a single or a of objects (Objects can be User/Transaction/Payment/PaymentMethod) - """ + 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.") @@ -53,10 +57,14 @@ def deserialize( def __get_objs_from_json_list( json_list: list[Any], data_type: type[BaseModel | Any] ) -> Page[Any]: - """Process JSON for User/Transaction - :param json_list: a list of objs - :param data_type: User/Transaction/Payment/PaymentMethod - :return: + """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: @@ -82,8 +90,6 @@ class Colors(Enum): def warn(message): """ print message in Red Color - :param message: - :return: """ print(Colors.WARNING.value + message + Colors.ENDC.value) @@ -91,7 +97,5 @@ def warn(message): def confirm(message): """ print message in Blue Color - :param message: - :return: """ 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 b8e686a..bd0023e 100644 --- a/venmo_api/apis/auth_api.py +++ b/venmo_api/apis/auth_api.py @@ -30,13 +30,15 @@ def __init__( self._api_client.update_device_id(self._device_id) 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. + 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." @@ -46,7 +48,6 @@ def login_with_credentials_cli(self, username: str, password: str) -> str: "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 @@ -61,29 +62,61 @@ def login_with_credentials_cli(self, username: str, password: str) -> str: return access_token - @staticmethod - def log_out(access_token: str) -> bool: - """ - Revoke your access_token - :param access_token: - :return: + 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, + } - resource_path = "/oauth/access_token" - api_client = ApiClient(access_token=access_token) + return self._api_client.call_api( + resource_path="/oauth/access_token", + body=body, + method="POST", + ok_error_codes=[self.TWO_FACTOR_ERROR_CODE], + ) - api_client.call_api(resource_path=resource_path, method="DELETE") + @staticmethod + def log_out(access_token: str) -> bool: + """Revoke your 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 def _two_factor_process_cli(self, response: ValidatedResponse) -> str: - """ - Get response from authenticate_with_username_password for a CLI two-factor process - :param response: - :return: access_token - """ + """Get response from authenticate_with_username_password for a CLI two-factor process + + Args: + response (ValidatedResponse): validated response + + Raises: + AuthenticationFailedError + Returns: + str: access token generated for this session + """ otp_secret = response.headers.get("venmo-otp-secret") if not otp_secret: raise AuthenticationFailedError( @@ -99,36 +132,17 @@ def _two_factor_process_cli(self, response: ValidatedResponse) -> str: return access_token - def authenticate_using_username_password( - self, username: str, password: str - ) -> ValidatedResponse: - """ - 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: - """ - body = { - "phone_email_or_username": username, - "client_id": "1", - "password": password, - } + def send_text_otp(self, otp_secret: str) -> ValidatedResponse: + """Send one-time-password to user phone-number - return self._api_client.call_api( - resource_path="/oauth/access_token", - body=body, - method="POST", - ok_error_codes=[self.TWO_FACTOR_ERROR_CODE], - ) + Args: + otp_secret (str): the otp-secret from response_headers.venmo-otp-secret - def send_text_otp(self, otp_secret: str) -> ValidatedResponse: - """ - Send one-time-password to user phone-number - :param otp_secret: the otp-secret from response_headers.venmo-otp-secret - :return: + Raises: + AuthenticationFailedError + + Returns: + ValidatedResponse: validated response """ response = self._api_client.call_api( resource_path="/account/two-factor/token", @@ -150,13 +164,15 @@ def send_text_otp(self, otp_secret: str) -> ValidatedResponse: 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 + + Args: + user_otp (str): otp user received on their phone + otp_secret (str): otp_secret obtained from 2-factor process + Returns: + str: _description_ + """ header_params = {"venmo-otp": user_otp, "venmo-otp-secret": otp_secret} response = self._api_client.call_api( resource_path="/oauth/access_token", @@ -166,10 +182,9 @@ def authenticate_using_otp(self, user_otp: str, otp_secret: str) -> str: ) return response.body["access_token"] - def trust_this_device(self, device_id=None): + def trust_this_device(self, device_id: str | None = None): """ Add device_id or self.device_id (if no device_id passed) to the trusted devices on Venmo - :return: """ device_id = device_id or self._device_id header_params = {"device-id": device_id} diff --git a/venmo_api/apis/payment_api.py b/venmo_api/apis/payment_api.py index d2909cd..31a2ee4 100644 --- a/venmo_api/apis/payment_api.py +++ b/venmo_api/apis/payment_api.py @@ -27,6 +27,13 @@ class PaymentApi: def __init__( self, profile: User, api_client: ApiClient, balance: float | None = None ): + """ + Args: + profile (User): User object for the current user, fetched at login. + api_client (ApiClient): client to use for requests. + balance (float | None, optional): User initial Venmo balance, if desired. Defaults + to None. + """ super().__init__() self._profile = profile self._balance = balance @@ -39,27 +46,39 @@ def __init__( } def get_charge_payments(self, limit=100000) -> Page[Payment]: - """ - Get a list of charge ongoing payments (pending request money) - :param limit: - :return: + """Get a list of charge ongoing payments (pending request money) + + Args: + limit (int, optional): Maximum number of payments to fetch. Defaults to 100000. + + Returns: + Page[Payment] """ 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) - :param limit: - :return: + """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) def remind_payment(self, payment_id: str) -> bool: - """ - 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 + """Send a reminder for a payment + + Args: + payment_id (str): the uuid for the payment, as returned by Payment.id. + + Raises: + NoPendingPaymentToUpdateError + AlreadyRemindedPaymentError + + Returns: + bool: True or raises AlreadyRemindedPaymentError """ action = "remind" response = self._update_payment(action=action, payment_id=payment_id) @@ -75,11 +94,17 @@ def remind_payment(self, payment_id: str) -> bool: return True def cancel_payment(self, payment_id: str) -> bool: - """ - Cancel the payment_id provided. Only applicable to payments you have access to (requested payments) - :param payment: - :param payment_id: - :return: True or raises NoPendingPaymentToCancelError + """Cancel the payment_id provided. Only applicable to payments you have access + to (requested payments). + + Args: + payment_id (str): the uuid for the payment + + Raises: + NoPendingPaymentToUpdateError + + Returns: + bool: True or raises NoPendingPaymentToCancelError """ action = "cancel" response = self._update_payment(action=action, payment_id=payment_id) @@ -90,7 +115,6 @@ def cancel_payment(self, payment_id: str) -> bool: def get_payment_methods(self) -> Page[PaymentMethod]: """ Get a list of available payment_methods - :return: """ response = self._api_client.call_api( resource_path="/payment-methods", method="GET" @@ -105,18 +129,20 @@ def send_money( funding_source_id: str = None, privacy_setting: PaymentPrivacy = PaymentPrivacy.PRIVATE, ) -> Payment: - """ - send [amount] money with [note] to the ([target_user_id] or [target_user]) from the [funding_source_id] + """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. - :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: - :return: Either the transaction was successful or an exception will rise. - """ + 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. + + Returns: + Payment: Either the transaction was successful or an exception will rise. + """ return self._send_or_request_money( amount=amount, note=note, @@ -133,14 +159,17 @@ def request_money( target_user_id: str, privacy_setting: PaymentPrivacy = PaymentPrivacy.PRIVATE, ) -> Payment: - """ - 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 - :return: Either the transaction was successful or an exception will rise. + """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, @@ -154,9 +183,16 @@ def request_money( def get_transfer_destinations( self, trans_type: Literal["standard", "instant"] ) -> Page[TransferDestination]: - """ - Get a list of available transfer destination options for the given type - :return: + """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. """ response = self._api_client.call_api( resource_path="/transfers/options", method="GET" @@ -171,6 +207,24 @@ def initiate_transfer( 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. + """ if amount is None and self._balance is not None: amount = self._balance else: @@ -192,7 +246,6 @@ def initiate_transfer( 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() @@ -216,18 +269,24 @@ def _get_eligibility_token( country_code: str = "1", target_type: str = "user_id", ) -> EligibilityToken: - """ - Generate eligibility token which is needed in payment requests - :param amount: amount of money to be requested - :param note: message/note of the transaction - :param target_id: the user id of the person you are sending money to - :param funding_source_id: Your payment_method id for this payment - :param action: action that eligibility token is used for - :param country_code: country code, not sure what this is for - :param target_type: set by default to user_id, but there are probably other target types + """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": "", + "funding_source_id": "", # api leaves this blank currently "action": action, "country_code": country_code, "target_type": target_type, @@ -252,11 +311,10 @@ def _update_payment( def _get_payments(self, action: PaymentAction, limit: int) -> Page[Payment]: """ - Get a list of ongoing payments with the given action - :return: + Helper method for getting a list of ongoing payments with the given action """ parameters = {"action": action, "actor": self._profile.id, "limit": limit} - # other params `status: pending,held` + # TODO other params `status: pending,held` response = self._api_client.call_api( resource_path="/payments", params=parameters, @@ -273,17 +331,9 @@ def _send_or_request_money( target_user_id: str, privacy_setting: PaymentPrivacy = PaymentPrivacy.PRIVATE, eligibility_token: str | None = None, - ) -> Payment | 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 eligibility_token: - :return: + Helper method for sending and requesting money. """ amount = abs(amount) diff --git a/venmo_api/apis/user_api.py b/venmo_api/apis/user_api.py index b8cef26..d20de26 100644 --- a/venmo_api/apis/user_api.py +++ b/venmo_api/apis/user_api.py @@ -13,9 +13,14 @@ def __init__(self, api_client: ApiClient): self._balance = None def get_my_profile(self, force_update=False) -> User: - """ - Get my profile info and return as a - :return my_profile: + """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. """ if self._profile and not force_update: return self._profile @@ -25,9 +30,14 @@ def get_my_profile(self, force_update=False) -> User: return self._profile def get_my_balance(self, force_update=False) -> float: - """ - Get my current balance info and return as a float - :return my_profile: + """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 @@ -45,13 +55,16 @@ def search_for_users( limit: int = 50, username: bool = False, ) -> Page[User]: - """ - search for [query] in users - :param query: - :param offset: - :param limit: - :param username: default: False; Pass True if search is by username - :return users_list: A list of objects or empty + """search for [query] in users + + 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} @@ -68,22 +81,31 @@ def search_for_users( current_offset=offset, ) - def get_user(self, user_id: str) -> User: - """ - Get the user profile with [user_id] - :param user_id: , example: '2859950549165568970' - :return user: + 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. + + Returns: + User | None: the corresponding User, if any. """ response = self.__api_client.call_api( resource_path=f"/users/{user_id}", method="GET" ) - return deserialize(response=response, data_type=User) + try: + return deserialize(response=response, data_type=User) + except Exception: + return None def get_user_by_username(self, username: str) -> User | None: - """ - Get the user profile with [username] - :param username: - :return user: + """Search for the user profile with [username] + + Args: + username (str): username of User. + + Returns: + User | None: The corresponding User, if any. """ users = self.search_for_users(query=username, username=True) for user in users: @@ -99,9 +121,15 @@ def get_user_friends_list( offset: int = 0, limit: int = 3337, ) -> Page[User]: - """ - Get ([user_id]'s or [user]'s) friends list as a list of s - :return users_list: A list of objects or empty + """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 :( """ params = {"limit": limit, "offset": offset} response = self.__api_client.call_api( @@ -119,17 +147,25 @@ def get_user_transactions( self, user_id: str, social_only: bool = False, - public_only: bool = False, + public_only: bool = True, limit: int = 50, before_id: str | None = None, ) -> Page[Transaction]: - """ - Get ([user_id]'s or [user]'s) transactions visible to yourself as a list of s - :param user_id: - :param user: - :param limit: - :param before_id: - :return: + """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. """ response = self._get_transactions( user_id, social_only, public_only, limit, before_id @@ -147,17 +183,24 @@ def get_user_transactions( def get_friends_transactions( self, social_only: bool = False, - public_only: bool = False, + public_only: bool = True, limit: int = 50, before_id: str | None = None, - ) -> Page[Transaction] | None: - """ - Get ([user_id]'s or [user]'s) transactions visible to yourself as a list of s - :param user_id: - :param user: - :param limit: - :param before_id: - :return: + ) -> Page[Transaction]: + """Get your friends' transactions visible to you as a list of Transactions + + 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. + + Returns: + Page[Transaction]: A list of Transaction objects. """ response = self._get_transactions( "friends", social_only, public_only, limit, before_id @@ -176,20 +219,27 @@ def get_transaction_between_two_users( user_id_one: str, user_id_two: str, social_only: bool = False, - public_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 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 limit: - :param before_id: - :return: + """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. """ response = self._get_transactions( f"{user_id_one}/target-or-actor/{user_id_two}", diff --git a/venmo_api/models/page.py b/venmo_api/models/page.py index 17bf6d7..fab213c 100644 --- a/venmo_api/models/page.py +++ b/venmo_api/models/page.py @@ -1,3 +1,7 @@ +from collections.abc import Callable +from typing import Self + + class Page(list): """fancy list that calls it's own next-in-line""" @@ -7,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 @@ -23,7 +30,6 @@ 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__() diff --git a/venmo_api/models/payment.py b/venmo_api/models/payment.py index 151c432..b3019d9 100644 --- a/venmo_api/models/payment.py +++ b/venmo_api/models/payment.py @@ -40,6 +40,9 @@ class PaymentMethodType(StrEnum): 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 @@ -62,6 +65,8 @@ class EligibilityToken(BaseModel): class Payment(BaseModel): + """object returned by a successful payment/request""" + id: str status: PaymentStatus action: PaymentAction @@ -95,6 +100,8 @@ class PaymentMethod(BaseModel): class TransferDestination(BaseModel): + """variant of PaymentMethod specifically for transfers to/from your Venmo balance""" + id: int type: PaymentMethodType name: str @@ -105,6 +112,8 @@ class TransferDestination(BaseModel): class TransferPostResponse(BaseModel): + """object returned by a successful transfer""" + id: str amount: UsDollarsFloat amount_cents: int diff --git a/venmo_api/models/transaction.py b/venmo_api/models/transaction.py index 11bdec3..6139722 100644 --- a/venmo_api/models/transaction.py +++ b/venmo_api/models/transaction.py @@ -58,6 +58,8 @@ class Comment(BaseModel): class Transaction(BaseModel): + """wrapper around Payment returned when you fetch your "stories" feeds""" + type: TransactionType id: str note: str diff --git a/venmo_api/models/user.py b/venmo_api/models/user.py index c45ab2b..0efaca4 100644 --- a/venmo_api/models/user.py +++ b/venmo_api/models/user.py @@ -15,6 +15,7 @@ class FriendStatus(StrEnum): NOT_FRIEND = auto() +# TODO verify stuff that isn't personal class IdentityType(StrEnum): PERSONAL = auto() BUSINESS = auto() diff --git a/venmo_api/venmo.py b/venmo_api/venmo.py index f284d8e..b2ca8f0 100644 --- a/venmo_api/venmo.py +++ b/venmo_api/venmo.py @@ -2,26 +2,53 @@ from typing import Self from venmo_api import ApiClient, AuthenticationApi, PaymentApi, UserApi +from venmo_api.models.user import User 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 - :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. + """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. - :return: access_token + Returns: + Self: Logged in Client instance. """ api_client = ApiClient(device_id=device_id) access_token = AuthenticationApi( @@ -32,10 +59,13 @@ def login(username: str, password: str, device_id: str | None = None) -> Self: @staticmethod def logout(access_token) -> bool: - """ - Revoke your access_token. Log out, in other words. - :param access_token: - :return: + """Revoke your access_token. Log out, in other words. + + Args: + access_token (_type_): Token for current session. + + Returns: + bool: True or raises exception. """ return AuthenticationApi.log_out(access_token=access_token) @@ -46,8 +76,15 @@ def __init__( api_client: ApiClient | None = None, ): """ - VenmoAPI Client - :param access_token: Need access_token to work with the API. + 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. """ super().__init__() if api_client is None: @@ -66,30 +103,40 @@ def __init__( self.user = UserApi(self.__api_client) self._profile = self.user.get_my_profile() - self.__balance = self.user.get_my_balance() + self._balance = self.user.get_my_balance() self.payment = PaymentApi( - profile=self._profile, api_client=self.__api_client, balance=self.__balance + profile=self._profile, api_client=self.__api_client, balance=self._balance ) - def my_profile(self, force_update=False): - """ - Get your profile info. It can be cached from the prev time. - :return: + def my_profile(self, force_update=False) -> User: + """Get your profile info. It can be cached from the previous time. + + Args: + force_update (bool, optional): Whether to force fetching an updated user. + Defaults to False. + + Returns: + User: your profile. """ if force_update: self._profile = self.user.get_my_profile(force_update=force_update) return self._profile - def my_balance(self, force_update=False): - """ - Get your balance info. It can be cached from the prev time. - :return: + 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. """ if force_update: - self.__balance = self.user.get_my_balance(force_update=force_update) + self._balance = self.user.get_my_balance(force_update=force_update) - return self.__balance + return self._balance @property def access_token(self) -> str | None: @@ -102,12 +149,11 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): self.log_out_instance() - def log_out_instance(self, token: str | None = None) -> bool: - """ - Revoke your access_token. Log out, in other words. - :param access_token: - :return: + 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 """ - if token is None: - token = self.access_token - return AuthenticationApi.log_out(token) + return AuthenticationApi.log_out(self.access_token) From 73f54f77fba920113adc2afa77a0011230c73593 Mon Sep 17 00:00:00 2001 From: joshhubert-dsp <102703352+joshhubert-dsp@users.noreply.github.com> Date: Sat, 15 Nov 2025 01:23:12 -0800 Subject: [PATCH 20/23] ApiClient is sole owner of device_id, OTP step-up error --- venmo_api/apis/api_client.py | 36 ++++++++++++++-------- venmo_api/apis/api_util.py | 2 +- venmo_api/apis/auth_api.py | 58 ++++++++++++----------------------- venmo_api/apis/payment_api.py | 38 +++++++++++++++++------ venmo_api/apis/user_api.py | 6 ++++ venmo_api/models/payment.py | 2 +- venmo_api/venmo.py | 8 ++--- 7 files changed, 82 insertions(+), 68 deletions(-) diff --git a/venmo_api/apis/api_client.py b/venmo_api/apis/api_client.py index f884137..f7dc568 100644 --- a/venmo_api/apis/api_client.py +++ b/venmo_api/apis/api_client.py @@ -1,4 +1,5 @@ import os +import uuid from json import JSONDecodeError from random import getrandbits @@ -15,22 +16,29 @@ 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): - """ - 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. - device_id (str | None, optional): Must be a real device ID. Defaults to None. - """ - - super().__init__() - self.default_headers = orjson.loads( (PROJECT_ROOT / "default_headers.json").read_bytes() ) @@ -43,9 +51,11 @@ def __init__(self, access_token: str | None = None, device_id: str | None = None self.access_token = access_token if access_token: self.update_access_token(access_token) - self.device_id = device_id - if device_id: - self.update_device_id(device_id) + + 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"} diff --git a/venmo_api/apis/api_util.py b/venmo_api/apis/api_util.py index f20249a..47463d1 100644 --- a/venmo_api/apis/api_util.py +++ b/venmo_api/apis/api_util.py @@ -12,7 +12,7 @@ class ValidatedResponse: status_code: int headers: CaseInsensitiveDict - body: list | dict + body: dict def deserialize( diff --git a/venmo_api/apis/auth_api.py b/venmo_api/apis/auth_api.py index bd0023e..1528c85 100644 --- a/venmo_api/apis/auth_api.py +++ b/venmo_api/apis/auth_api.py @@ -1,33 +1,22 @@ -import uuid - 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 - -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() +# NOTE: ApiClient owns device-id now class AuthenticationApi: - TWO_FACTOR_ERROR_CODE = 81109 + """Auth API for logging in/out of your account. - def __init__( - self, api_client: ApiClient | None = None, device_id: str | None = None - ): - super().__init__() + Args: + api_client (ApiClient): Pre-initialized ApiClient that holds device-id. This + instance will be logged in with the access token returned. + """ - self._device_id = device_id or random_device_id() - if not api_client: - self._api_client = ApiClient(device_id=self._device_id) - else: - self._api_client = api_client - self._api_client.update_device_id(self._device_id) + TWO_FACTOR_ERROR_CODE = 81109 + + 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. @@ -43,7 +32,7 @@ def login_with_credentials_cli(self, username: str, password: str) -> str: 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}") + 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" @@ -58,7 +47,8 @@ def login_with_credentials_cli(self, username: str, password: str) -> str: access_token = response.body["access_token"] confirm("Successfully logged in. Note your token and device-id") - print(f"access_token: {access_token}\ndevice-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 @@ -126,9 +116,8 @@ def _two_factor_process_cli(self, response: ValidatedResponse) -> str: self.send_text_otp(otp_secret=otp_secret) 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 @@ -182,27 +171,18 @@ def authenticate_using_otp(self, user_otp: str, otp_secret: str) -> str: ) return response.body["access_token"] - def trust_this_device(self, device_id: str | None = None): + def trust_this_device(self): """ - Add device_id or self.device_id (if no device_id passed) to the trusted devices on Venmo + Add current device_id to the trusted devices on Venmo """ - device_id = device_id or self._device_id - header_params = {"device-id": device_id} - - self._api_client.call_api( - resource_path="/users/devices", header_params=header_params, method="POST" - ) - + 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._device_id} next time to avoid 2-factor-auth process." + 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(): diff --git a/venmo_api/apis/payment_api.py b/venmo_api/apis/payment_api.py index 31a2ee4..7306c66 100644 --- a/venmo_api/apis/payment_api.py +++ b/venmo_api/apis/payment_api.py @@ -24,16 +24,19 @@ 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 ): - """ - Args: - profile (User): User object for the current user, fetched at login. - api_client (ApiClient): client to use for requests. - balance (float | None, optional): User initial Venmo balance, if desired. Defaults - to None. - """ super().__init__() self._profile = profile self._balance = balance @@ -43,6 +46,7 @@ def __init__( "no_pending_payment_error": 2901, "no_pending_payment_error2": 2905, "not_enough_balance_error": 13006, + "otp_step_up_required_error": 1396, } def get_charge_payments(self, limit=100000) -> Page[Payment]: @@ -359,12 +363,26 @@ def _send_or_request_money( body.update({"funding_source_id": funding_source_id}) response = self._api_client.call_api( - resource_path="/payments", method="POST", body=body + 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"], + ], ) + # handle 200 status code errors - error_code = response.body["data"].get("error_code") + error_code = response.body.get("error").get("code") + 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 = response.body["data"] diff --git a/venmo_api/apis/user_api.py b/venmo_api/apis/user_api.py index d20de26..2bf08d7 100644 --- a/venmo_api/apis/user_api.py +++ b/venmo_api/apis/user_api.py @@ -6,6 +6,12 @@ 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): super().__init__() self.__api_client = api_client diff --git a/venmo_api/models/payment.py b/venmo_api/models/payment.py index b3019d9..e84693f 100644 --- a/venmo_api/models/payment.py +++ b/venmo_api/models/payment.py @@ -114,7 +114,7 @@ class TransferDestination(BaseModel): class TransferPostResponse(BaseModel): """object returned by a successful transfer""" - id: str + id: int amount: UsDollarsFloat amount_cents: int amount_fee_cents: int diff --git a/venmo_api/venmo.py b/venmo_api/venmo.py index b2ca8f0..fac6999 100644 --- a/venmo_api/venmo.py +++ b/venmo_api/venmo.py @@ -51,10 +51,10 @@ def login(username: str, password: str, device_id: str | None = None) -> Self: Self: Logged in Client instance. """ api_client = ApiClient(device_id=device_id) - access_token = AuthenticationApi( - api_client, device_id - ).login_with_credentials_cli(username=username, password=password) - api_client.update_access_token(access_token) + # 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 From f752e686660f9287d6445a4d39a872e658335f78 Mon Sep 17 00:00:00 2001 From: joshhubert-dsp <102703352+joshhubert-dsp@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:20:38 -0800 Subject: [PATCH 21/23] error_code oopsie --- venmo_api/apis/payment_api.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/venmo_api/apis/payment_api.py b/venmo_api/apis/payment_api.py index 7306c66..a9aedfe 100644 --- a/venmo_api/apis/payment_api.py +++ b/venmo_api/apis/payment_api.py @@ -373,7 +373,11 @@ def _send_or_request_money( ) # handle 200 status code errors - error_code = response.body.get("error").get("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["otp_step_up_required_error"]: From 356835e7a5f503eb5aeb1f662c6010341c0fa054 Mon Sep 17 00:00:00 2001 From: joshhubert-dsp <102703352+joshhubert-dsp@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:34:12 -0800 Subject: [PATCH 22/23] headers --- venmo_api/apis/auth_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/venmo_api/apis/auth_api.py b/venmo_api/apis/auth_api.py index 1528c85..961f31b 100644 --- a/venmo_api/apis/auth_api.py +++ b/venmo_api/apis/auth_api.py @@ -135,7 +135,7 @@ def send_text_otp(self, otp_secret: str) -> ValidatedResponse: """ response = self._api_client.call_api( resource_path="/account/two-factor/token", - header_params={"venmo-otp-secret": otp_secret}, + headers={"venmo-otp-secret": otp_secret}, body={"via": "sms"}, method="POST", ) @@ -162,10 +162,10 @@ def authenticate_using_otp(self, user_otp: str, otp_secret: str) -> str: Returns: str: _description_ """ - header_params = {"venmo-otp": user_otp, "venmo-otp-secret": otp_secret} + headers = {"venmo-otp": user_otp, "venmo-otp-secret": otp_secret} response = self._api_client.call_api( resource_path="/oauth/access_token", - header_params=header_params, + headers=headers, params={"client_id": 1}, method="POST", ) From 899cd288d11d3599176ff3b8d40236a7d4886027 Mon Sep 17 00:00:00 2001 From: joshhubert-dsp <102703352+joshhubert-dsp@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:38:17 -0800 Subject: [PATCH 23/23] update headers, transfer amount fix --- default_headers.json | 2 +- venmo_api/apis/payment_api.py | 12 ++++++------ venmo_api/apis/user_api.py | 1 - venmo_api/venmo.py | 1 - 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/default_headers.json b/default_headers.json index 0b65d51..49ebe60 100644 --- a/default_headers.json +++ b/default_headers.json @@ -1,6 +1,6 @@ { "Host": "api.venmo.com", - "User-Agent": "Venmo/10.77.0 (iPhone; iOS 18.6.2; Scale/3.0)", + "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", diff --git a/venmo_api/apis/payment_api.py b/venmo_api/apis/payment_api.py index a9aedfe..156c731 100644 --- a/venmo_api/apis/payment_api.py +++ b/venmo_api/apis/payment_api.py @@ -37,7 +37,6 @@ class PaymentApi: def __init__( self, profile: User, api_client: ApiClient, balance: float | None = None ): - super().__init__() self._profile = profile self._balance = balance self._api_client = api_client @@ -229,10 +228,11 @@ def initiate_transfer( Returns: TransferPostResponse: object signifying successful transfer with details. """ - if amount is None and self._balance is not None: - amount = self._balance - else: - raise ValueError("must pass a transfer amount if no balance available") + 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") amount_cents = round(amount * 100) body = { @@ -257,7 +257,7 @@ def get_default_payment_method(self) -> PaymentMethod: if not p_method: continue - if p_method.role == PaymentMethodRole.DEFAULT: + if p_method.peer_payment_role == PaymentMethodRole.DEFAULT: return p_method raise NoPaymentMethodFoundError() diff --git a/venmo_api/apis/user_api.py b/venmo_api/apis/user_api.py index 2bf08d7..350c761 100644 --- a/venmo_api/apis/user_api.py +++ b/venmo_api/apis/user_api.py @@ -13,7 +13,6 @@ class UserApi: """ def __init__(self, api_client: ApiClient): - super().__init__() self.__api_client = api_client self._profile = None self._balance = None diff --git a/venmo_api/venmo.py b/venmo_api/venmo.py index fac6999..552476f 100644 --- a/venmo_api/venmo.py +++ b/venmo_api/venmo.py @@ -86,7 +86,6 @@ def __init__( api_client (ApiClient | None, optional): Alternative to the above 2. Defaults to None. """ - super().__init__() if api_client is None: self.__api_client = ApiClient( access_token=access_token, device_id=device_id