From 6272ce4335748760896b2641f1a4a280ce0f2fbb Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Fri, 26 Apr 2024 07:36:27 +0200 Subject: [PATCH 01/28] added ReconcileAdjuster --- poetry.lock | 193 +++++++++++++++++++++++++---- pyproject.toml | 3 +- tests/test_adjusters.py | 23 ++++ ynabsplitbudget/adjusters.py | 36 ++++++ ynabsplitbudget/ynabsplitbudget.py | 17 ++- 5 files changed, 249 insertions(+), 23 deletions(-) create mode 100644 tests/test_adjusters.py create mode 100644 ynabsplitbudget/adjusters.py diff --git a/poetry.lock b/poetry.lock index 61e38fb..004d268 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,18 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.6.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, + {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} [[package]] name = "certifi" @@ -123,13 +137,13 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.2.0" +version = "1.2.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, - {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, + {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, + {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, ] [package.extras] @@ -137,13 +151,13 @@ test = ["pytest (>=6)"] [[package]] name = "idna" -version = "3.6" +version = "3.7" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" files = [ - {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, - {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, ] [[package]] @@ -159,39 +173,149 @@ files = [ [[package]] name = "packaging" -version = "23.2" +version = "24.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, - {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, ] [[package]] name = "pluggy" -version = "1.4.0" +version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" files = [ - {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, - {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pydantic" +version = "2.7.0" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.7.0-py3-none-any.whl", hash = "sha256:9dee74a271705f14f9a1567671d144a851c675b072736f0a7b2608fd9e495352"}, + {file = "pydantic-2.7.0.tar.gz", hash = "sha256:b5ecdd42262ca2462e2624793551e80911a1e989f462910bb81aef974b4bb383"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.18.1" +typing-extensions = ">=4.6.1" + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.18.1" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.18.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:ee9cf33e7fe14243f5ca6977658eb7d1042caaa66847daacbd2117adb258b226"}, + {file = "pydantic_core-2.18.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6b7bbb97d82659ac8b37450c60ff2e9f97e4eb0f8a8a3645a5568b9334b08b50"}, + {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df4249b579e75094f7e9bb4bd28231acf55e308bf686b952f43100a5a0be394c"}, + {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d0491006a6ad20507aec2be72e7831a42efc93193d2402018007ff827dc62926"}, + {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ae80f72bb7a3e397ab37b53a2b49c62cc5496412e71bc4f1277620a7ce3f52b"}, + {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58aca931bef83217fca7a390e0486ae327c4af9c3e941adb75f8772f8eeb03a1"}, + {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1be91ad664fc9245404a789d60cba1e91c26b1454ba136d2a1bf0c2ac0c0505a"}, + {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:667880321e916a8920ef49f5d50e7983792cf59f3b6079f3c9dac2b88a311d17"}, + {file = "pydantic_core-2.18.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f7054fdc556f5421f01e39cbb767d5ec5c1139ea98c3e5b350e02e62201740c7"}, + {file = "pydantic_core-2.18.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:030e4f9516f9947f38179249778709a460a3adb516bf39b5eb9066fcfe43d0e6"}, + {file = "pydantic_core-2.18.1-cp310-none-win32.whl", hash = "sha256:2e91711e36e229978d92642bfc3546333a9127ecebb3f2761372e096395fc649"}, + {file = "pydantic_core-2.18.1-cp310-none-win_amd64.whl", hash = "sha256:9a29726f91c6cb390b3c2338f0df5cd3e216ad7a938762d11c994bb37552edb0"}, + {file = "pydantic_core-2.18.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9ece8a49696669d483d206b4474c367852c44815fca23ac4e48b72b339807f80"}, + {file = "pydantic_core-2.18.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a5d83efc109ceddb99abd2c1316298ced2adb4570410defe766851a804fcd5b"}, + {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7973c381283783cd1043a8c8f61ea5ce7a3a58b0369f0ee0ee975eaf2f2a1b"}, + {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:54c7375c62190a7845091f521add19b0f026bcf6ae674bdb89f296972272e86d"}, + {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd63cec4e26e790b70544ae5cc48d11b515b09e05fdd5eff12e3195f54b8a586"}, + {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:561cf62c8a3498406495cfc49eee086ed2bb186d08bcc65812b75fda42c38294"}, + {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68717c38a68e37af87c4da20e08f3e27d7e4212e99e96c3d875fbf3f4812abfc"}, + {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d5728e93d28a3c63ee513d9ffbac9c5989de8c76e049dbcb5bfe4b923a9739d"}, + {file = "pydantic_core-2.18.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f0f17814c505f07806e22b28856c59ac80cee7dd0fbb152aed273e116378f519"}, + {file = "pydantic_core-2.18.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d816f44a51ba5175394bc6c7879ca0bd2be560b2c9e9f3411ef3a4cbe644c2e9"}, + {file = "pydantic_core-2.18.1-cp311-none-win32.whl", hash = "sha256:09f03dfc0ef8c22622eaa8608caa4a1e189cfb83ce847045eca34f690895eccb"}, + {file = "pydantic_core-2.18.1-cp311-none-win_amd64.whl", hash = "sha256:27f1009dc292f3b7ca77feb3571c537276b9aad5dd4efb471ac88a8bd09024e9"}, + {file = "pydantic_core-2.18.1-cp311-none-win_arm64.whl", hash = "sha256:48dd883db92e92519201f2b01cafa881e5f7125666141a49ffba8b9facc072b0"}, + {file = "pydantic_core-2.18.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b6b0e4912030c6f28bcb72b9ebe4989d6dc2eebcd2a9cdc35fefc38052dd4fe8"}, + {file = "pydantic_core-2.18.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3202a429fe825b699c57892d4371c74cc3456d8d71b7f35d6028c96dfecad31"}, + {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3982b0a32d0a88b3907e4b0dc36809fda477f0757c59a505d4e9b455f384b8b"}, + {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25595ac311f20e5324d1941909b0d12933f1fd2171075fcff763e90f43e92a0d"}, + {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14fe73881cf8e4cbdaded8ca0aa671635b597e42447fec7060d0868b52d074e6"}, + {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca976884ce34070799e4dfc6fbd68cb1d181db1eefe4a3a94798ddfb34b8867f"}, + {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:684d840d2c9ec5de9cb397fcb3f36d5ebb6fa0d94734f9886032dd796c1ead06"}, + {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:54764c083bbe0264f0f746cefcded6cb08fbbaaf1ad1d78fb8a4c30cff999a90"}, + {file = "pydantic_core-2.18.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:201713f2f462e5c015b343e86e68bd8a530a4f76609b33d8f0ec65d2b921712a"}, + {file = "pydantic_core-2.18.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fd1a9edb9dd9d79fbeac1ea1f9a8dd527a6113b18d2e9bcc0d541d308dae639b"}, + {file = "pydantic_core-2.18.1-cp312-none-win32.whl", hash = "sha256:d5e6b7155b8197b329dc787356cfd2684c9d6a6b1a197f6bbf45f5555a98d411"}, + {file = "pydantic_core-2.18.1-cp312-none-win_amd64.whl", hash = "sha256:9376d83d686ec62e8b19c0ac3bf8d28d8a5981d0df290196fb6ef24d8a26f0d6"}, + {file = "pydantic_core-2.18.1-cp312-none-win_arm64.whl", hash = "sha256:c562b49c96906b4029b5685075fe1ebd3b5cc2601dfa0b9e16c2c09d6cbce048"}, + {file = "pydantic_core-2.18.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:3e352f0191d99fe617371096845070dee295444979efb8f27ad941227de6ad09"}, + {file = "pydantic_core-2.18.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c0295d52b012cbe0d3059b1dba99159c3be55e632aae1999ab74ae2bd86a33d7"}, + {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56823a92075780582d1ffd4489a2e61d56fd3ebb4b40b713d63f96dd92d28144"}, + {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd3f79e17b56741b5177bcc36307750d50ea0698df6aa82f69c7db32d968c1c2"}, + {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38a5024de321d672a132b1834a66eeb7931959c59964b777e8f32dbe9523f6b1"}, + {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2ce426ee691319d4767748c8e0895cfc56593d725594e415f274059bcf3cb76"}, + {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2adaeea59849ec0939af5c5d476935f2bab4b7f0335b0110f0f069a41024278e"}, + {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9b6431559676a1079eac0f52d6d0721fb8e3c5ba43c37bc537c8c83724031feb"}, + {file = "pydantic_core-2.18.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:85233abb44bc18d16e72dc05bf13848a36f363f83757541f1a97db2f8d58cfd9"}, + {file = "pydantic_core-2.18.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:641a018af4fe48be57a2b3d7a1f0f5dbca07c1d00951d3d7463f0ac9dac66622"}, + {file = "pydantic_core-2.18.1-cp38-none-win32.whl", hash = "sha256:63d7523cd95d2fde0d28dc42968ac731b5bb1e516cc56b93a50ab293f4daeaad"}, + {file = "pydantic_core-2.18.1-cp38-none-win_amd64.whl", hash = "sha256:907a4d7720abfcb1c81619863efd47c8a85d26a257a2dbebdb87c3b847df0278"}, + {file = "pydantic_core-2.18.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:aad17e462f42ddbef5984d70c40bfc4146c322a2da79715932cd8976317054de"}, + {file = "pydantic_core-2.18.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:94b9769ba435b598b547c762184bcfc4783d0d4c7771b04a3b45775c3589ca44"}, + {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80e0e57cc704a52fb1b48f16d5b2c8818da087dbee6f98d9bf19546930dc64b5"}, + {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76b86e24039c35280ceee6dce7e62945eb93a5175d43689ba98360ab31eebc4a"}, + {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12a05db5013ec0ca4a32cc6433f53faa2a014ec364031408540ba858c2172bb0"}, + {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:250ae39445cb5475e483a36b1061af1bc233de3e9ad0f4f76a71b66231b07f88"}, + {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a32204489259786a923e02990249c65b0f17235073149d0033efcebe80095570"}, + {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6395a4435fa26519fd96fdccb77e9d00ddae9dd6c742309bd0b5610609ad7fb2"}, + {file = "pydantic_core-2.18.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2533ad2883f001efa72f3d0e733fb846710c3af6dcdd544fe5bf14fa5fe2d7db"}, + {file = "pydantic_core-2.18.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b560b72ed4816aee52783c66854d96157fd8175631f01ef58e894cc57c84f0f6"}, + {file = "pydantic_core-2.18.1-cp39-none-win32.whl", hash = "sha256:582cf2cead97c9e382a7f4d3b744cf0ef1a6e815e44d3aa81af3ad98762f5a9b"}, + {file = "pydantic_core-2.18.1-cp39-none-win_amd64.whl", hash = "sha256:ca71d501629d1fa50ea7fa3b08ba884fe10cefc559f5c6c8dfe9036c16e8ae89"}, + {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e178e5b66a06ec5bf51668ec0d4ac8cfb2bdcb553b2c207d58148340efd00143"}, + {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:72722ce529a76a4637a60be18bd789d8fb871e84472490ed7ddff62d5fed620d"}, + {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fe0c1ce5b129455e43f941f7a46f61f3d3861e571f2905d55cdbb8b5c6f5e2c"}, + {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4284c621f06a72ce2cb55f74ea3150113d926a6eb78ab38340c08f770eb9b4d"}, + {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a0c3e718f4e064efde68092d9d974e39572c14e56726ecfaeebbe6544521f47"}, + {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2027493cc44c23b598cfaf200936110433d9caa84e2c6cf487a83999638a96ac"}, + {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:76909849d1a6bffa5a07742294f3fa1d357dc917cb1fe7b470afbc3a7579d539"}, + {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ee7ccc7fb7e921d767f853b47814c3048c7de536663e82fbc37f5eb0d532224b"}, + {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ee2794111c188548a4547eccc73a6a8527fe2af6cf25e1a4ebda2fd01cdd2e60"}, + {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a139fe9f298dc097349fb4f28c8b81cc7a202dbfba66af0e14be5cfca4ef7ce5"}, + {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d074b07a10c391fc5bbdcb37b2f16f20fcd9e51e10d01652ab298c0d07908ee2"}, + {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c69567ddbac186e8c0aadc1f324a60a564cfe25e43ef2ce81bcc4b8c3abffbae"}, + {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf1c7b78cddb5af00971ad5294a4583188bda1495b13760d9f03c9483bb6203"}, + {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2684a94fdfd1b146ff10689c6e4e815f6a01141781c493b97342cdc5b06f4d5d"}, + {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:73c1bc8a86a5c9e8721a088df234265317692d0b5cd9e86e975ce3bc3db62a59"}, + {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e60defc3c15defb70bb38dd605ff7e0fae5f6c9c7cbfe0ad7868582cb7e844a6"}, + {file = "pydantic_core-2.18.1.tar.gz", hash = "sha256:de9d3e8717560eb05e28739d1b35e4eac2e458553a52a301e51352a7ffc86a35"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + [[package]] name = "pytest" -version = "8.0.2" +version = "8.1.1" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.0.2-py3-none-any.whl", hash = "sha256:edfaaef32ce5172d5466b5127b42e0d6d35ebbe4453f0e3505d96afd93f6b096"}, - {file = "pytest-8.0.2.tar.gz", hash = "sha256:d4051d623a2e0b7e51960ba963193b09ce6daeb9759a451844a21e4ddedfc1bd"}, + {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, + {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, ] [package.dependencies] @@ -199,11 +323,11 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=1.3.0,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} +pluggy = ">=1.4,<2.0" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pyyaml" @@ -296,6 +420,17 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +[[package]] +name = "typing-extensions" +version = "4.11.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, + {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, +] + [[package]] name = "urllib3" version = "2.2.1" @@ -313,7 +448,23 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "ynab-transaction-adjuster" +version = "1.0.0" +description = "Library to adjust transactions in YNAB based on custom patterns" +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "ynab_transaction_adjuster-1.0.0-py3-none-any.whl", hash = "sha256:875db2c36d735cb11c4fbf81e3d0a641f9254f0a16503e5b15ff22b226122f3c"}, + {file = "ynab_transaction_adjuster-1.0.0.tar.gz", hash = "sha256:3146c023d3f45eedc69fcf73f4760ba67e3227c92a5d785dd955d96ada4c598d"}, +] + +[package.dependencies] +pydantic = ">=2.7.0,<3.0.0" +pytest = ">=8.1.1,<9.0.0" +requests = ">=2.28.0,<3.0.0" + [metadata] lock-version = "2.0" -python-versions = ">=3.8" -content-hash = "132c9564a074324ee27b6174e51eb4fa476b90c7e5d18ae39f15963b5ad35ef8" +python-versions = "<4.0,>=3.8" +content-hash = "b4e5ffc193cb1468e0b08f17e21aaa5b2a63b322c4e697a8570ad9aca76054b7" diff --git a/pyproject.toml b/pyproject.toml index 84cd4da..bf72619 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,9 +17,10 @@ classifiers = [ ] [tool.poetry.dependencies] -python = '>=3.8' +python = '<4.0,>=3.8' pyyaml = '>=6.0' requests = '>=2.28' +ynab-transaction-adjuster= '>=1.0.0' [tool.poetry.group.dev.dependencies] pytest = ">=8.0.0" diff --git a/tests/test_adjusters.py b/tests/test_adjusters.py new file mode 100644 index 0000000..7ee5961 --- /dev/null +++ b/tests/test_adjusters.py @@ -0,0 +1,23 @@ +from unittest.mock import MagicMock, PropertyMock + +from ynabtransactionadjuster.models import Category + +from ynabsplitbudget.adjusters import ReconcileAdjuster + + +def test_filter(): + + ra = ReconcileAdjuster(payees=MagicMock(), categories=MagicMock(), credentials=MagicMock(), transactions=MagicMock()) + # Act + t = ra.filter([PropertyMock(cleared='cleared'), PropertyMock(cleared='uncleared'), PropertyMock(cleared='reconciled')]) + assert len(t) == 1 + + +def test_adjust(): + # Arrange + ma = ReconcileAdjuster(payees=MagicMock(), categories=MagicMock(), credentials=MagicMock(), transactions=MagicMock()) + ma.categories.fetch_by_name.return_value = MagicMock(spec=Category) + t = ma.adjust(PropertyMock(cleared='cleared'), PropertyMock(cleared='cleared')) + assert t.cleared == 'reconciled' + assert isinstance(t.category, Category) + diff --git a/ynabsplitbudget/adjusters.py b/ynabsplitbudget/adjusters.py new file mode 100644 index 0000000..2164b35 --- /dev/null +++ b/ynabsplitbudget/adjusters.py @@ -0,0 +1,36 @@ +from typing import List + +from ynabtransactionadjuster import Adjuster, Credentials, ModifierSubTransaction +from ynabtransactionadjuster.models import Transaction, Modifier + +from ynabsplitbudget.splitparser import SplitParser + + +class ReconcileAdjuster(Adjuster): + + def filter(self, transactions: List[Transaction]) -> List[Transaction]: + return [t for t in transactions if t.cleared == 'cleared'] + + def adjust(self, original: Transaction, modifier: Modifier) -> Modifier: + modifier.cleared = 'reconciled' + modifier.category = self.categories.fetch_by_name('Inflow: Ready to Assign') + return modifier + + +class SplitAdjuster(Adjuster): + + def __init__(self, credentials: Credentials, flag_color: str, transfer_payee_id: str): + super().from_credentials(credentials=credentials) + self.flag_color = flag_color + self.transfer_payee_id = transfer_payee_id + + def filter(self, transactions: List[Transaction]) -> List[Transaction]: + return [t for t in transactions if t.cleared == 'cleared' and t.flag_color == self.flag_color] + + def adjust(self, original: Transaction, modifier: Modifier) -> Modifier: + split_amount = SplitParser().parse_split(transaction=original) + s1 = ModifierSubTransaction(amount=split_amount, payee=self.payees.fetch_by_id(self.transfer_payee_id), + memo=f"{original.payee.name} | {original.memo}") + s2 = ModifierSubTransaction(amount=original.amount - split_amount, category=original.category, memo=original.memo) + modifier.subtransactions = [s1, s2] + return modifier diff --git a/ynabsplitbudget/ynabsplitbudget.py b/ynabsplitbudget/ynabsplitbudget.py index 7621fef..5955b67 100644 --- a/ynabsplitbudget/ynabsplitbudget.py +++ b/ynabsplitbudget/ynabsplitbudget.py @@ -2,6 +2,9 @@ from datetime import date, timedelta, datetime from typing import List, Optional +from ynabtransactionadjuster import Credentials + +from ynabsplitbudget.adjusters import ReconcileAdjuster from ynabsplitbudget.client import SplitClient, SyncClient from ynabsplitbudget.fileloader import FileLoader from ynabsplitbudget.models.exception import BalancesDontMatch @@ -33,7 +36,8 @@ def insert_complements(self, since: date = None) -> int: logging.getLogger(__name__).info(f'inserted {len(transactions)} complements into account of {self._partner.name}') return len(transactions) - def split_transactions(self) -> int: + def split_transactions(self, set_cleared: bool = False) -> int: + """Splits transactions """ c = SplitClient(self._user) st_list = c.fetch_new_to_split() [c.insert_split(st) for st in st_list] @@ -58,6 +62,17 @@ def delete_orphaned_complements(self, since: date = None) -> List[ComplementTran logging.getLogger(__name__).info(orphaned_complements) return orphaned_complements + def reconcile(self) -> int: + """Reconciles cleared transactions in the current account + :return: count of reconciled transactions + """ + creds = Credentials(token=self._user.token, budget=self._user.account.budget_id, + account=self._user.account.account_id) + adjuster = ReconcileAdjuster.from_credentials(creds) + c = adjuster.run() + logging.getLogger(__name__).info(f'reconciled {c} transactions for {self._user.name}') + return c + @staticmethod def _substitute_default_since(since: Optional[date]): if since is None: From c0e70d3db57d6f890780a4aceeabc2310e63c12d Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Mon, 29 Apr 2024 06:42:37 +0200 Subject: [PATCH 02/28] refactored split functionality into adjuster concept --- poetry.lock | 285 +++++++++++++++++++---------- pyproject.toml | 2 +- tests/test_adjusters.py | 11 +- tests/test_splitparser.py | 12 +- ynabsplitbudget/adjusters.py | 20 +- ynabsplitbudget/splitparser.py | 35 ++++ ynabsplitbudget/ynabsplitbudget.py | 16 +- 7 files changed, 268 insertions(+), 113 deletions(-) create mode 100644 ynabsplitbudget/splitparser.py diff --git a/poetry.lock b/poetry.lock index 004d268..20e5f57 100644 --- a/poetry.lock +++ b/poetry.lock @@ -135,6 +135,23 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "deprecated" +version = "1.2.14" +description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"}, + {file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"}, +] + +[package.dependencies] +wrapt = ">=1.10,<2" + +[package.extras] +dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"] + [[package]] name = "exceptiongroup" version = "1.2.1" @@ -199,18 +216,18 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pydantic" -version = "2.7.0" +version = "2.7.1" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.7.0-py3-none-any.whl", hash = "sha256:9dee74a271705f14f9a1567671d144a851c675b072736f0a7b2608fd9e495352"}, - {file = "pydantic-2.7.0.tar.gz", hash = "sha256:b5ecdd42262ca2462e2624793551e80911a1e989f462910bb81aef974b4bb383"}, + {file = "pydantic-2.7.1-py3-none-any.whl", hash = "sha256:e029badca45266732a9a79898a15ae2e8b14840b1eabbb25844be28f0b33f3d5"}, + {file = "pydantic-2.7.1.tar.gz", hash = "sha256:e9dbb5eada8abe4d9ae5f46b9939aead650cd2b68f249bb3a8139dbe125803cc"}, ] [package.dependencies] annotated-types = ">=0.4.0" -pydantic-core = "2.18.1" +pydantic-core = "2.18.2" typing-extensions = ">=4.6.1" [package.extras] @@ -218,90 +235,90 @@ email = ["email-validator (>=2.0.0)"] [[package]] name = "pydantic-core" -version = "2.18.1" +version = "2.18.2" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.18.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:ee9cf33e7fe14243f5ca6977658eb7d1042caaa66847daacbd2117adb258b226"}, - {file = "pydantic_core-2.18.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6b7bbb97d82659ac8b37450c60ff2e9f97e4eb0f8a8a3645a5568b9334b08b50"}, - {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df4249b579e75094f7e9bb4bd28231acf55e308bf686b952f43100a5a0be394c"}, - {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d0491006a6ad20507aec2be72e7831a42efc93193d2402018007ff827dc62926"}, - {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ae80f72bb7a3e397ab37b53a2b49c62cc5496412e71bc4f1277620a7ce3f52b"}, - {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58aca931bef83217fca7a390e0486ae327c4af9c3e941adb75f8772f8eeb03a1"}, - {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1be91ad664fc9245404a789d60cba1e91c26b1454ba136d2a1bf0c2ac0c0505a"}, - {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:667880321e916a8920ef49f5d50e7983792cf59f3b6079f3c9dac2b88a311d17"}, - {file = "pydantic_core-2.18.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f7054fdc556f5421f01e39cbb767d5ec5c1139ea98c3e5b350e02e62201740c7"}, - {file = "pydantic_core-2.18.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:030e4f9516f9947f38179249778709a460a3adb516bf39b5eb9066fcfe43d0e6"}, - {file = "pydantic_core-2.18.1-cp310-none-win32.whl", hash = "sha256:2e91711e36e229978d92642bfc3546333a9127ecebb3f2761372e096395fc649"}, - {file = "pydantic_core-2.18.1-cp310-none-win_amd64.whl", hash = "sha256:9a29726f91c6cb390b3c2338f0df5cd3e216ad7a938762d11c994bb37552edb0"}, - {file = "pydantic_core-2.18.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9ece8a49696669d483d206b4474c367852c44815fca23ac4e48b72b339807f80"}, - {file = "pydantic_core-2.18.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a5d83efc109ceddb99abd2c1316298ced2adb4570410defe766851a804fcd5b"}, - {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7973c381283783cd1043a8c8f61ea5ce7a3a58b0369f0ee0ee975eaf2f2a1b"}, - {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:54c7375c62190a7845091f521add19b0f026bcf6ae674bdb89f296972272e86d"}, - {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd63cec4e26e790b70544ae5cc48d11b515b09e05fdd5eff12e3195f54b8a586"}, - {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:561cf62c8a3498406495cfc49eee086ed2bb186d08bcc65812b75fda42c38294"}, - {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68717c38a68e37af87c4da20e08f3e27d7e4212e99e96c3d875fbf3f4812abfc"}, - {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d5728e93d28a3c63ee513d9ffbac9c5989de8c76e049dbcb5bfe4b923a9739d"}, - {file = "pydantic_core-2.18.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f0f17814c505f07806e22b28856c59ac80cee7dd0fbb152aed273e116378f519"}, - {file = "pydantic_core-2.18.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d816f44a51ba5175394bc6c7879ca0bd2be560b2c9e9f3411ef3a4cbe644c2e9"}, - {file = "pydantic_core-2.18.1-cp311-none-win32.whl", hash = "sha256:09f03dfc0ef8c22622eaa8608caa4a1e189cfb83ce847045eca34f690895eccb"}, - {file = "pydantic_core-2.18.1-cp311-none-win_amd64.whl", hash = "sha256:27f1009dc292f3b7ca77feb3571c537276b9aad5dd4efb471ac88a8bd09024e9"}, - {file = "pydantic_core-2.18.1-cp311-none-win_arm64.whl", hash = "sha256:48dd883db92e92519201f2b01cafa881e5f7125666141a49ffba8b9facc072b0"}, - {file = "pydantic_core-2.18.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b6b0e4912030c6f28bcb72b9ebe4989d6dc2eebcd2a9cdc35fefc38052dd4fe8"}, - {file = "pydantic_core-2.18.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3202a429fe825b699c57892d4371c74cc3456d8d71b7f35d6028c96dfecad31"}, - {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3982b0a32d0a88b3907e4b0dc36809fda477f0757c59a505d4e9b455f384b8b"}, - {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25595ac311f20e5324d1941909b0d12933f1fd2171075fcff763e90f43e92a0d"}, - {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14fe73881cf8e4cbdaded8ca0aa671635b597e42447fec7060d0868b52d074e6"}, - {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca976884ce34070799e4dfc6fbd68cb1d181db1eefe4a3a94798ddfb34b8867f"}, - {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:684d840d2c9ec5de9cb397fcb3f36d5ebb6fa0d94734f9886032dd796c1ead06"}, - {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:54764c083bbe0264f0f746cefcded6cb08fbbaaf1ad1d78fb8a4c30cff999a90"}, - {file = "pydantic_core-2.18.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:201713f2f462e5c015b343e86e68bd8a530a4f76609b33d8f0ec65d2b921712a"}, - {file = "pydantic_core-2.18.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fd1a9edb9dd9d79fbeac1ea1f9a8dd527a6113b18d2e9bcc0d541d308dae639b"}, - {file = "pydantic_core-2.18.1-cp312-none-win32.whl", hash = "sha256:d5e6b7155b8197b329dc787356cfd2684c9d6a6b1a197f6bbf45f5555a98d411"}, - {file = "pydantic_core-2.18.1-cp312-none-win_amd64.whl", hash = "sha256:9376d83d686ec62e8b19c0ac3bf8d28d8a5981d0df290196fb6ef24d8a26f0d6"}, - {file = "pydantic_core-2.18.1-cp312-none-win_arm64.whl", hash = "sha256:c562b49c96906b4029b5685075fe1ebd3b5cc2601dfa0b9e16c2c09d6cbce048"}, - {file = "pydantic_core-2.18.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:3e352f0191d99fe617371096845070dee295444979efb8f27ad941227de6ad09"}, - {file = "pydantic_core-2.18.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c0295d52b012cbe0d3059b1dba99159c3be55e632aae1999ab74ae2bd86a33d7"}, - {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56823a92075780582d1ffd4489a2e61d56fd3ebb4b40b713d63f96dd92d28144"}, - {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd3f79e17b56741b5177bcc36307750d50ea0698df6aa82f69c7db32d968c1c2"}, - {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38a5024de321d672a132b1834a66eeb7931959c59964b777e8f32dbe9523f6b1"}, - {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2ce426ee691319d4767748c8e0895cfc56593d725594e415f274059bcf3cb76"}, - {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2adaeea59849ec0939af5c5d476935f2bab4b7f0335b0110f0f069a41024278e"}, - {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9b6431559676a1079eac0f52d6d0721fb8e3c5ba43c37bc537c8c83724031feb"}, - {file = "pydantic_core-2.18.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:85233abb44bc18d16e72dc05bf13848a36f363f83757541f1a97db2f8d58cfd9"}, - {file = "pydantic_core-2.18.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:641a018af4fe48be57a2b3d7a1f0f5dbca07c1d00951d3d7463f0ac9dac66622"}, - {file = "pydantic_core-2.18.1-cp38-none-win32.whl", hash = "sha256:63d7523cd95d2fde0d28dc42968ac731b5bb1e516cc56b93a50ab293f4daeaad"}, - {file = "pydantic_core-2.18.1-cp38-none-win_amd64.whl", hash = "sha256:907a4d7720abfcb1c81619863efd47c8a85d26a257a2dbebdb87c3b847df0278"}, - {file = "pydantic_core-2.18.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:aad17e462f42ddbef5984d70c40bfc4146c322a2da79715932cd8976317054de"}, - {file = "pydantic_core-2.18.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:94b9769ba435b598b547c762184bcfc4783d0d4c7771b04a3b45775c3589ca44"}, - {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80e0e57cc704a52fb1b48f16d5b2c8818da087dbee6f98d9bf19546930dc64b5"}, - {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76b86e24039c35280ceee6dce7e62945eb93a5175d43689ba98360ab31eebc4a"}, - {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12a05db5013ec0ca4a32cc6433f53faa2a014ec364031408540ba858c2172bb0"}, - {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:250ae39445cb5475e483a36b1061af1bc233de3e9ad0f4f76a71b66231b07f88"}, - {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a32204489259786a923e02990249c65b0f17235073149d0033efcebe80095570"}, - {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6395a4435fa26519fd96fdccb77e9d00ddae9dd6c742309bd0b5610609ad7fb2"}, - {file = "pydantic_core-2.18.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2533ad2883f001efa72f3d0e733fb846710c3af6dcdd544fe5bf14fa5fe2d7db"}, - {file = "pydantic_core-2.18.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b560b72ed4816aee52783c66854d96157fd8175631f01ef58e894cc57c84f0f6"}, - {file = "pydantic_core-2.18.1-cp39-none-win32.whl", hash = "sha256:582cf2cead97c9e382a7f4d3b744cf0ef1a6e815e44d3aa81af3ad98762f5a9b"}, - {file = "pydantic_core-2.18.1-cp39-none-win_amd64.whl", hash = "sha256:ca71d501629d1fa50ea7fa3b08ba884fe10cefc559f5c6c8dfe9036c16e8ae89"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e178e5b66a06ec5bf51668ec0d4ac8cfb2bdcb553b2c207d58148340efd00143"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:72722ce529a76a4637a60be18bd789d8fb871e84472490ed7ddff62d5fed620d"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fe0c1ce5b129455e43f941f7a46f61f3d3861e571f2905d55cdbb8b5c6f5e2c"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4284c621f06a72ce2cb55f74ea3150113d926a6eb78ab38340c08f770eb9b4d"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a0c3e718f4e064efde68092d9d974e39572c14e56726ecfaeebbe6544521f47"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2027493cc44c23b598cfaf200936110433d9caa84e2c6cf487a83999638a96ac"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:76909849d1a6bffa5a07742294f3fa1d357dc917cb1fe7b470afbc3a7579d539"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ee7ccc7fb7e921d767f853b47814c3048c7de536663e82fbc37f5eb0d532224b"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ee2794111c188548a4547eccc73a6a8527fe2af6cf25e1a4ebda2fd01cdd2e60"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a139fe9f298dc097349fb4f28c8b81cc7a202dbfba66af0e14be5cfca4ef7ce5"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d074b07a10c391fc5bbdcb37b2f16f20fcd9e51e10d01652ab298c0d07908ee2"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c69567ddbac186e8c0aadc1f324a60a564cfe25e43ef2ce81bcc4b8c3abffbae"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf1c7b78cddb5af00971ad5294a4583188bda1495b13760d9f03c9483bb6203"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2684a94fdfd1b146ff10689c6e4e815f6a01141781c493b97342cdc5b06f4d5d"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:73c1bc8a86a5c9e8721a088df234265317692d0b5cd9e86e975ce3bc3db62a59"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e60defc3c15defb70bb38dd605ff7e0fae5f6c9c7cbfe0ad7868582cb7e844a6"}, - {file = "pydantic_core-2.18.1.tar.gz", hash = "sha256:de9d3e8717560eb05e28739d1b35e4eac2e458553a52a301e51352a7ffc86a35"}, + {file = "pydantic_core-2.18.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9e08e867b306f525802df7cd16c44ff5ebbe747ff0ca6cf3fde7f36c05a59a81"}, + {file = "pydantic_core-2.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f0a21cbaa69900cbe1a2e7cad2aa74ac3cf21b10c3efb0fa0b80305274c0e8a2"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0680b1f1f11fda801397de52c36ce38ef1c1dc841a0927a94f226dea29c3ae3d"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:95b9d5e72481d3780ba3442eac863eae92ae43a5f3adb5b4d0a1de89d42bb250"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fcf5cd9c4b655ad666ca332b9a081112cd7a58a8b5a6ca7a3104bc950f2038"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b5155ff768083cb1d62f3e143b49a8a3432e6789a3abee8acd005c3c7af1c74"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553ef617b6836fc7e4df130bb851e32fe357ce36336d897fd6646d6058d980af"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89ed9eb7d616ef5714e5590e6cf7f23b02d0d539767d33561e3675d6f9e3857"}, + {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:75f7e9488238e920ab6204399ded280dc4c307d034f3924cd7f90a38b1829563"}, + {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ef26c9e94a8c04a1b2924149a9cb081836913818e55681722d7f29af88fe7b38"}, + {file = "pydantic_core-2.18.2-cp310-none-win32.whl", hash = "sha256:182245ff6b0039e82b6bb585ed55a64d7c81c560715d1bad0cbad6dfa07b4027"}, + {file = "pydantic_core-2.18.2-cp310-none-win_amd64.whl", hash = "sha256:e23ec367a948b6d812301afc1b13f8094ab7b2c280af66ef450efc357d2ae543"}, + {file = "pydantic_core-2.18.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:219da3f096d50a157f33645a1cf31c0ad1fe829a92181dd1311022f986e5fbe3"}, + {file = "pydantic_core-2.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc1cfd88a64e012b74e94cd00bbe0f9c6df57049c97f02bb07d39e9c852e19a4"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05b7133a6e6aeb8df37d6f413f7705a37ab4031597f64ab56384c94d98fa0e90"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:224c421235f6102e8737032483f43c1a8cfb1d2f45740c44166219599358c2cd"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b14d82cdb934e99dda6d9d60dc84a24379820176cc4a0d123f88df319ae9c150"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2728b01246a3bba6de144f9e3115b532ee44bd6cf39795194fb75491824a1413"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:470b94480bb5ee929f5acba6995251ada5e059a5ef3e0dfc63cca287283ebfa6"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:997abc4df705d1295a42f95b4eec4950a37ad8ae46d913caeee117b6b198811c"}, + {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75250dbc5290e3f1a0f4618db35e51a165186f9034eff158f3d490b3fed9f8a0"}, + {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4456f2dca97c425231d7315737d45239b2b51a50dc2b6f0c2bb181fce6207664"}, + {file = "pydantic_core-2.18.2-cp311-none-win32.whl", hash = "sha256:269322dcc3d8bdb69f054681edff86276b2ff972447863cf34c8b860f5188e2e"}, + {file = "pydantic_core-2.18.2-cp311-none-win_amd64.whl", hash = "sha256:800d60565aec896f25bc3cfa56d2277d52d5182af08162f7954f938c06dc4ee3"}, + {file = "pydantic_core-2.18.2-cp311-none-win_arm64.whl", hash = "sha256:1404c69d6a676245199767ba4f633cce5f4ad4181f9d0ccb0577e1f66cf4c46d"}, + {file = "pydantic_core-2.18.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:fb2bd7be70c0fe4dfd32c951bc813d9fe6ebcbfdd15a07527796c8204bd36242"}, + {file = "pydantic_core-2.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6132dd3bd52838acddca05a72aafb6eab6536aa145e923bb50f45e78b7251043"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d904828195733c183d20a54230c0df0eb46ec746ea1a666730787353e87182"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9bd70772c720142be1020eac55f8143a34ec9f82d75a8e7a07852023e46617f"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8ed04b3582771764538f7ee7001b02e1170223cf9b75dff0bc698fadb00cf3"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6dac87ddb34aaec85f873d737e9d06a3555a1cc1a8e0c44b7f8d5daeb89d86f"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ca4ae5a27ad7a4ee5170aebce1574b375de390bc01284f87b18d43a3984df72"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:886eec03591b7cf058467a70a87733b35f44707bd86cf64a615584fd72488b7c"}, + {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ca7b0c1f1c983e064caa85f3792dd2fe3526b3505378874afa84baf662e12241"}, + {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b4356d3538c3649337df4074e81b85f0616b79731fe22dd11b99499b2ebbdf3"}, + {file = "pydantic_core-2.18.2-cp312-none-win32.whl", hash = "sha256:8b172601454f2d7701121bbec3425dd71efcb787a027edf49724c9cefc14c038"}, + {file = "pydantic_core-2.18.2-cp312-none-win_amd64.whl", hash = "sha256:b1bd7e47b1558ea872bd16c8502c414f9e90dcf12f1395129d7bb42a09a95438"}, + {file = "pydantic_core-2.18.2-cp312-none-win_arm64.whl", hash = "sha256:98758d627ff397e752bc339272c14c98199c613f922d4a384ddc07526c86a2ec"}, + {file = "pydantic_core-2.18.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:9fdad8e35f278b2c3eb77cbdc5c0a49dada440657bf738d6905ce106dc1de439"}, + {file = "pydantic_core-2.18.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1d90c3265ae107f91a4f279f4d6f6f1d4907ac76c6868b27dc7fb33688cfb347"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390193c770399861d8df9670fb0d1874f330c79caaca4642332df7c682bf6b91"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:82d5d4d78e4448683cb467897fe24e2b74bb7b973a541ea1dcfec1d3cbce39fb"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4774f3184d2ef3e14e8693194f661dea5a4d6ca4e3dc8e39786d33a94865cefd"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4d938ec0adf5167cb335acb25a4ee69a8107e4984f8fbd2e897021d9e4ca21b"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0e8b1be28239fc64a88a8189d1df7fad8be8c1ae47fcc33e43d4be15f99cc70"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:868649da93e5a3d5eacc2b5b3b9235c98ccdbfd443832f31e075f54419e1b96b"}, + {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:78363590ef93d5d226ba21a90a03ea89a20738ee5b7da83d771d283fd8a56761"}, + {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:852e966fbd035a6468fc0a3496589b45e2208ec7ca95c26470a54daed82a0788"}, + {file = "pydantic_core-2.18.2-cp38-none-win32.whl", hash = "sha256:6a46e22a707e7ad4484ac9ee9f290f9d501df45954184e23fc29408dfad61350"}, + {file = "pydantic_core-2.18.2-cp38-none-win_amd64.whl", hash = "sha256:d91cb5ea8b11607cc757675051f61b3d93f15eca3cefb3e6c704a5d6e8440f4e"}, + {file = "pydantic_core-2.18.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ae0a8a797a5e56c053610fa7be147993fe50960fa43609ff2a9552b0e07013e8"}, + {file = "pydantic_core-2.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:042473b6280246b1dbf530559246f6842b56119c2926d1e52b631bdc46075f2a"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a388a77e629b9ec814c1b1e6b3b595fe521d2cdc625fcca26fbc2d44c816804"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25add29b8f3b233ae90ccef2d902d0ae0432eb0d45370fe315d1a5cf231004b"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f459a5ce8434614dfd39bbebf1041952ae01da6bed9855008cb33b875cb024c0"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eff2de745698eb46eeb51193a9f41d67d834d50e424aef27df2fcdee1b153845"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8309f67285bdfe65c372ea3722b7a5642680f3dba538566340a9d36e920b5f0"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f93a8a2e3938ff656a7c1bc57193b1319960ac015b6e87d76c76bf14fe0244b4"}, + {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:22057013c8c1e272eb8d0eebc796701167d8377441ec894a8fed1af64a0bf399"}, + {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cfeecd1ac6cc1fb2692c3d5110781c965aabd4ec5d32799773ca7b1456ac636b"}, + {file = "pydantic_core-2.18.2-cp39-none-win32.whl", hash = "sha256:0d69b4c2f6bb3e130dba60d34c0845ba31b69babdd3f78f7c0c8fae5021a253e"}, + {file = "pydantic_core-2.18.2-cp39-none-win_amd64.whl", hash = "sha256:d9319e499827271b09b4e411905b24a426b8fb69464dfa1696258f53a3334641"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a1874c6dd4113308bd0eb568418e6114b252afe44319ead2b4081e9b9521fe75"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:ccdd111c03bfd3666bd2472b674c6899550e09e9f298954cfc896ab92b5b0e6d"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e18609ceaa6eed63753037fc06ebb16041d17d28199ae5aba0052c51449650a9"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e5c584d357c4e2baf0ff7baf44f4994be121e16a2c88918a5817331fc7599d7"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43f0f463cf89ace478de71a318b1b4f05ebc456a9b9300d027b4b57c1a2064fb"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e1b395e58b10b73b07b7cf740d728dd4ff9365ac46c18751bf8b3d8cca8f625a"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0098300eebb1c837271d3d1a2cd2911e7c11b396eac9661655ee524a7f10587b"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:36789b70d613fbac0a25bb07ab3d9dba4d2e38af609c020cf4d888d165ee0bf3"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3f9a801e7c8f1ef8718da265bba008fa121243dfe37c1cea17840b0944dfd72c"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3a6515ebc6e69d85502b4951d89131ca4e036078ea35533bb76327f8424531ce"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20aca1e2298c56ececfd8ed159ae4dde2df0781988c97ef77d5c16ff4bd5b400"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:223ee893d77a310a0391dca6df00f70bbc2f36a71a895cecd9a0e762dc37b349"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2334ce8c673ee93a1d6a65bd90327588387ba073c17e61bf19b4fd97d688d63c"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cbca948f2d14b09d20268cda7b0367723d79063f26c4ffc523af9042cad95592"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b3ef08e20ec49e02d5c6717a91bb5af9b20f1805583cb0adfe9ba2c6b505b5ae"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c6fdc8627910eed0c01aed6a390a252fe3ea6d472ee70fdde56273f198938374"}, + {file = "pydantic_core-2.18.2.tar.gz", hash = "sha256:2e29d20810dfc3043ee13ac7d9e25105799817683348823f305ab3f349b9386e"}, ] [package.dependencies] @@ -309,13 +326,13 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pytest" -version = "8.1.1" +version = "8.2.0" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, - {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, + {file = "pytest-8.2.0-py3-none-any.whl", hash = "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233"}, + {file = "pytest-8.2.0.tar.gz", hash = "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f"}, ] [package.dependencies] @@ -323,11 +340,11 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=1.4,<2.0" +pluggy = ">=1.5,<2.0" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pyyaml" @@ -448,23 +465,103 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "wrapt" +version = "1.16.0" +description = "Module for decorators, wrappers and monkey patching." +optional = false +python-versions = ">=3.6" +files = [ + {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, + {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"}, + {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"}, + {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"}, + {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"}, + {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"}, + {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"}, + {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"}, + {file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"}, + {file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"}, + {file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"}, + {file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"}, + {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"}, + {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"}, + {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"}, + {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"}, + {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"}, + {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, +] + [[package]] name = "ynab-transaction-adjuster" -version = "1.0.0" +version = "1.2.0" description = "Library to adjust transactions in YNAB based on custom patterns" optional = false python-versions = "<4.0,>=3.8" files = [ - {file = "ynab_transaction_adjuster-1.0.0-py3-none-any.whl", hash = "sha256:875db2c36d735cb11c4fbf81e3d0a641f9254f0a16503e5b15ff22b226122f3c"}, - {file = "ynab_transaction_adjuster-1.0.0.tar.gz", hash = "sha256:3146c023d3f45eedc69fcf73f4760ba67e3227c92a5d785dd955d96ada4c598d"}, + {file = "ynab_transaction_adjuster-1.2.0-py3-none-any.whl", hash = "sha256:544e4ddb72be5caca1364538b541555a23e323b3baa8f7234cddad70ddd671be"}, + {file = "ynab_transaction_adjuster-1.2.0.tar.gz", hash = "sha256:d51786b811412cd5420a07c297011819bbb56835dda0d362a4220f5d62fb8786"}, ] [package.dependencies] +deprecated = ">=1.2.14,<2.0.0" pydantic = ">=2.7.0,<3.0.0" -pytest = ">=8.1.1,<9.0.0" +pytest = ">=8.2.0,<9.0.0" requests = ">=2.28.0,<3.0.0" [metadata] lock-version = "2.0" python-versions = "<4.0,>=3.8" -content-hash = "b4e5ffc193cb1468e0b08f17e21aaa5b2a63b322c4e697a8570ad9aca76054b7" +content-hash = "c8a77aa99105c873341788bb8a0e7baf6df6368a17244ec172b903d6c4cc0510" diff --git a/pyproject.toml b/pyproject.toml index bf72619..4ed2b9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ python = '<4.0,>=3.8' pyyaml = '>=6.0' requests = '>=2.28' -ynab-transaction-adjuster= '>=1.0.0' +ynab-transaction-adjuster= '>=1.2.0' [tool.poetry.group.dev.dependencies] pytest = ">=8.0.0" diff --git a/tests/test_adjusters.py b/tests/test_adjusters.py index 7ee5961..f2020f0 100644 --- a/tests/test_adjusters.py +++ b/tests/test_adjusters.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, PropertyMock +from unittest.mock import MagicMock, PropertyMock, patch from ynabtransactionadjuster.models import Category @@ -7,16 +7,17 @@ def test_filter(): - ra = ReconcileAdjuster(payees=MagicMock(), categories=MagicMock(), credentials=MagicMock(), transactions=MagicMock()) + ra = ReconcileAdjuster(credentials=MagicMock()) # Act t = ra.filter([PropertyMock(cleared='cleared'), PropertyMock(cleared='uncleared'), PropertyMock(cleared='reconciled')]) assert len(t) == 1 -def test_adjust(): +@patch('ynabsplitbudget.adjusters.ReconcileAdjuster.categories', new_callable=PropertyMock()) +def test_adjust(mock_categories): # Arrange - ma = ReconcileAdjuster(payees=MagicMock(), categories=MagicMock(), credentials=MagicMock(), transactions=MagicMock()) - ma.categories.fetch_by_name.return_value = MagicMock(spec=Category) + ma = ReconcileAdjuster(credentials=MagicMock()) + mock_categories.fetch_by_name.return_value = MagicMock(spec=Category) t = ma.adjust(PropertyMock(cleared='cleared'), PropertyMock(cleared='cleared')) assert t.cleared == 'reconciled' assert isinstance(t.category, Category) diff --git a/tests/test_splitparser.py b/tests/test_splitparser.py index 94ad31e..ac95540 100644 --- a/tests/test_splitparser.py +++ b/tests/test_splitparser.py @@ -1,7 +1,9 @@ +from unittest.mock import MagicMock + import pytest from ynabsplitbudget.models.exception import SplitNotValid -from ynabsplitbudget.transactionbuilder import SplitParser +from ynabsplitbudget.splitparser import SplitParser @pytest.mark.parametrize('test_input, expected', [('xxx', -1000), ('xxx @25%:xxx', -500), ('@33%', -660), ('@0.7', -700), @@ -9,10 +11,10 @@ def test_parse_split_pass(test_input, expected): # Arrange c = SplitParser() - t_dict = {'date': '2023-12-01', 'payee_name': 'payee', 'amount': -2000, 'memo': test_input} + transaction = MagicMock(date='2023-12-01', amount=-2000, memo=test_input) # Act - split_amount = c.parse_split(t_dict) + split_amount = c.parse_split(transaction) # Assert assert split_amount == expected @@ -22,9 +24,9 @@ def test_parse_split_pass(test_input, expected): def test_parse_split_fail(test_input): # Arrange c = SplitParser() - t_dict = {'date': '2023-12-01', 'payee_name': 'payee', 'amount': -1000, 'memo': test_input} + transaction = MagicMock(date='2023-12-01', amount=-1000, memo=test_input) # Assert with pytest.raises(SplitNotValid): # Act - c.parse_split(t_dict) + c.parse_split(transaction) diff --git a/ynabsplitbudget/adjusters.py b/ynabsplitbudget/adjusters.py index 2164b35..6a13f07 100644 --- a/ynabsplitbudget/adjusters.py +++ b/ynabsplitbudget/adjusters.py @@ -17,10 +17,28 @@ def adjust(self, original: Transaction, modifier: Modifier) -> Modifier: return modifier +class ClearAdjuster(Adjuster): + + def __init__(self, credentials: Credentials, flag_color: str): + super().__init__(credentials=credentials) + self.flag_color = flag_color + + def filter(self, transactions: List[Transaction]) -> List[Transaction]: + return [t for t in transactions + if t.cleared == 'uncleared' + and t.transfer_transaction_id + and self.fetch_transaction(t.transfer_transaction_id).flag_color == self.flag_color] + + def adjust(self, original: Transaction, modifier: Modifier) -> Modifier: + modifier.cleared = 'cleared' + modifier.category = self.categories.fetch_by_name('Inflow: Ready to Assign') + return modifier + + class SplitAdjuster(Adjuster): def __init__(self, credentials: Credentials, flag_color: str, transfer_payee_id: str): - super().from_credentials(credentials=credentials) + super().__init__(credentials=credentials) self.flag_color = flag_color self.transfer_payee_id = transfer_payee_id diff --git a/ynabsplitbudget/splitparser.py b/ynabsplitbudget/splitparser.py new file mode 100644 index 0000000..bbfca68 --- /dev/null +++ b/ynabsplitbudget/splitparser.py @@ -0,0 +1,35 @@ +import re + +from ynabtransactionadjuster import Transaction + +from ynabsplitbudget.models.exception import SplitNotValid + + +class SplitParser: + + @staticmethod + def parse_split(transaction: Transaction) -> int: + amount = transaction.amount + + try: + r = re.search(r'@(\d+\.?\d*)(%?)', transaction.memo) + except TypeError: + r = None + + # return half the amount if no split attribution is found + if r is None: + return int(amount * 0.5) + + split_number = float(r.groups()[0]) + + # return amount percentage if % in memo + if r.groups()[1] == '%': + if split_number <= 100: + return int(amount * split_number / 100) + raise SplitNotValid(f"Split is above 100% for transaction {transaction}") + + # return absolute amount + if split_number * 1000 > abs(amount): + raise SplitNotValid(f"Split is above total amount of {amount / 1000:.2f} for transaction {transaction}") + sign = -1 if amount < 0 else 1 + return int(sign * split_number * 1000) diff --git a/ynabsplitbudget/ynabsplitbudget.py b/ynabsplitbudget/ynabsplitbudget.py index 5955b67..e023cca 100644 --- a/ynabsplitbudget/ynabsplitbudget.py +++ b/ynabsplitbudget/ynabsplitbudget.py @@ -4,7 +4,7 @@ from ynabtransactionadjuster import Credentials -from ynabsplitbudget.adjusters import ReconcileAdjuster +from ynabsplitbudget.adjusters import ReconcileAdjuster, SplitAdjuster from ynabsplitbudget.client import SplitClient, SyncClient from ynabsplitbudget.fileloader import FileLoader from ynabsplitbudget.models.exception import BalancesDontMatch @@ -38,11 +38,13 @@ def insert_complements(self, since: date = None) -> int: def split_transactions(self, set_cleared: bool = False) -> int: """Splits transactions """ - c = SplitClient(self._user) - st_list = c.fetch_new_to_split() - [c.insert_split(st) for st in st_list] - logging.getLogger(__name__).info(f'split {len(st_list)} transactions for {self._user.name}') - return len(st_list) + creds = Credentials(token=self._user.token, budget=self._user.account.budget_id, + account=self._user.account.account_id) + s = SplitAdjuster(creds, flag_color=self._user.flag, transfer_payee_id=self._user.account.transfer_payee_id) + mod_trans = s.apply() + count = s.update(mod_trans) + logging.getLogger(__name__).info(f'split {count} transactions for {self._user.name}') + return count def raise_on_balances_off(self): repo = SyncRepository(user=self._user, partner=self._partner) @@ -69,7 +71,7 @@ def reconcile(self) -> int: creds = Credentials(token=self._user.token, budget=self._user.account.budget_id, account=self._user.account.account_id) adjuster = ReconcileAdjuster.from_credentials(creds) - c = adjuster.run() + c = adjuster.dryrun() logging.getLogger(__name__).info(f'reconciled {c} transactions for {self._user.name}') return c From 2009ef634b70788445f97df58e98565120fc38d9 Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Mon, 29 Apr 2024 07:17:25 +0200 Subject: [PATCH 03/28] deleted old split code --- tests/test_adjusters.py | 33 +++++++++++++++++-- tests/test_client.py | 27 ++------------- tests/test_splitransactionbuilder.py | 35 -------------------- ynabsplitbudget/client.py | 38 +--------------------- ynabsplitbudget/models/splittransaction.py | 29 ----------------- ynabsplitbudget/transactionbuilder.py | 38 ---------------------- ynabsplitbudget/ynabsplitbudget.py | 2 +- 7 files changed, 34 insertions(+), 168 deletions(-) delete mode 100644 tests/test_splitransactionbuilder.py delete mode 100644 ynabsplitbudget/models/splittransaction.py diff --git a/tests/test_adjusters.py b/tests/test_adjusters.py index f2020f0..62971d8 100644 --- a/tests/test_adjusters.py +++ b/tests/test_adjusters.py @@ -1,11 +1,12 @@ from unittest.mock import MagicMock, PropertyMock, patch +from ynabtransactionadjuster import Payee from ynabtransactionadjuster.models import Category -from ynabsplitbudget.adjusters import ReconcileAdjuster +from ynabsplitbudget.adjusters import ReconcileAdjuster, SplitAdjuster -def test_filter(): +def test_reconcile_filter(): ra = ReconcileAdjuster(credentials=MagicMock()) # Act @@ -14,7 +15,7 @@ def test_filter(): @patch('ynabsplitbudget.adjusters.ReconcileAdjuster.categories', new_callable=PropertyMock()) -def test_adjust(mock_categories): +def test_reconcile_adjust(mock_categories): # Arrange ma = ReconcileAdjuster(credentials=MagicMock()) mock_categories.fetch_by_name.return_value = MagicMock(spec=Category) @@ -22,3 +23,29 @@ def test_adjust(mock_categories): assert t.cleared == 'reconciled' assert isinstance(t.category, Category) + +def test_split_filter(): + sa = SplitAdjuster(credentials=MagicMock(), flag_color='red', transfer_payee_id='transfer_payee_id') + f = sa.filter([PropertyMock(cleared='cleared', flag_color='red', id='1'), + PropertyMock(cleared='cleared', id='2'), + PropertyMock(flag_color='yellow', id='3')]) + assert len(f) == 1 + assert f[0].id == '1' + + +@patch('ynabsplitbudget.adjusters.SplitAdjuster.payees', new_callable=PropertyMock()) +def test_split_adjust(mock_payees): + sa = SplitAdjuster(credentials=MagicMock(), flag_color='red', transfer_payee_id='transfer_payee_id') + mock_payees.fetch_by_id.return_value = Payee(name='transfer_payee') + + mt = sa.adjust(PropertyMock(category=Category(id='category_id', name='category_name'), + amount=-1000, + payee=Payee(name='payee_name'), + memo='@25% memo'), PropertyMock()) + + assert len(mt.subtransactions) == 2 + assert mt.subtransactions[0].amount == -250 + assert mt.subtransactions[0].payee.name == 'transfer_payee' + assert mt.subtransactions[1].amount == -750 + assert mt.subtransactions[1].memo == '@25% memo' + assert mt.subtransactions[1].category.name == 'category_name' diff --git a/tests/test_client.py b/tests/test_client.py index 28996f2..dfa772d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,13 +4,10 @@ import pytest from requests import Response -from ynabsplitbudget.client import BaseClient, SyncClient, SplitClient +from ynabsplitbudget.client import BaseClient, SyncClient from ynabsplitbudget.models.account import Account -from ynabsplitbudget.models.exception import BudgetNotFound, AccountNotFound, SplitNotValid -from ynabsplitbudget.models.splittransaction import SplitTransaction +from ynabsplitbudget.models.exception import BudgetNotFound, AccountNotFound from ynabsplitbudget.models.transaction import RootTransaction, LookupTransaction -from ynabsplitbudget.models.user import User -from ynabsplitbudget.transactionbuilder import SplitParser, SplitTransactionBuilder @pytest.mark.parametrize('budget, account, expected', [('bullshit', 'bullshit', BudgetNotFound), @@ -87,26 +84,6 @@ def test_fetch_new_empty(mock_response, mock_transaction_dict): assert len(r) == 0 -@patch('ynabsplitbudget.client.requests.get') -def test_fetch_new_to_split_flag(mock_response, mock_transaction_dict): - # Arrange - mock_resp_obj = MagicMock(spec=Response) - mock_resp_obj.json.return_value = {'data': {'transactions': [mock_transaction_dict]}} - mock_response.return_value = mock_resp_obj - - u = User(name='user_name', flag='purple', - token='sample_token', - account=MagicMock()) - c = SplitClient(u) - - # Act - st = c.fetch_new_to_split() - - # Assert - assert isinstance(st[0], SplitTransaction) - assert st[0].split_amount == 500 - - @patch('ynabsplitbudget.client.requests.get') def test_fetch_balance(mock_response): # Arrange diff --git a/tests/test_splitransactionbuilder.py b/tests/test_splitransactionbuilder.py deleted file mode 100644 index 722b338..0000000 --- a/tests/test_splitransactionbuilder.py +++ /dev/null @@ -1,35 +0,0 @@ -from unittest.mock import patch, MagicMock - -from ynabsplitbudget.models.splittransaction import SplitTransaction -from ynabsplitbudget.transactionbuilder import SplitTransactionBuilder - - -@patch('ynabsplitbudget.client.SplitTransaction.from_dict', return_value=MagicMock(spec=SplitTransaction)) -def test_build_transaction_success(mock): - # Arrange - c = SplitTransactionBuilder() - t_dict = {'date': '2023-12-01', 'payee_name': 'payee', 'amount': 1000, 'memo': 'xxx'} - - # Act - st = c.build(t_dict=t_dict) - - # Assert - mock.assert_called_once_with(t_dict=t_dict, split_amount=500) - - -@patch('ynabsplitbudget.client.SplitTransaction.from_dict', return_value=MagicMock(spec=SplitTransaction)) -def test_build_transaction_fail(mock, caplog): - # Arrange - c = SplitTransactionBuilder() - t_dict = {'date': '2023-12-01', 'payee_name': 'payee', 'amount': 1000, 'memo': '@110%'} - - # Act - with caplog.at_level('ERROR'): - st = c.build(t_dict=t_dict) - assert len(caplog.records) == 1 - assert caplog.records[0].levelname == 'ERROR' - assert len(caplog.records[0].message) > 0 - - # Assert - assert not mock.called - assert st is None diff --git a/ynabsplitbudget/client.py b/ynabsplitbudget/client.py index ed91364..8ad79e2 100644 --- a/ynabsplitbudget/client.py +++ b/ynabsplitbudget/client.py @@ -5,8 +5,7 @@ import requests from requests import HTTPError -from ynabsplitbudget.models.splittransaction import SplitTransaction -from ynabsplitbudget.transactionbuilder import TransactionBuilder, SplitTransactionBuilder +from ynabsplitbudget.transactionbuilder import TransactionBuilder from ynabsplitbudget.models.exception import BudgetNotFound, AccountNotFound from ynabsplitbudget.models.account import Account from ynabsplitbudget.models.transaction import RootTransaction, LookupTransaction, ComplementTransaction @@ -117,38 +116,3 @@ def delete_complement(self, transaction_id: str) -> None: url = f'{YNAB_BASE_URL}budgets/{self.user.account.budget_id}/transactions/{transaction_id}' r = requests.delete(url, headers=self._header()) r.raise_for_status() - - -class SplitClient(ClientMixin): - - def __init__(self, user: User): - self._token = user.token - self.user = user - - def fetch_new_to_split(self) -> List[SplitTransaction]: - url = f'{YNAB_BASE_URL}budgets/{self.user.account.budget_id}/transactions' - - data_dict = self._get(url) - transactions_dicts = [t for t in data_dict['transactions'] if not t['cleared'] == 'uncleared' - and t['deleted'] is False and len(t['subtransactions']) == 0] - stb = SplitTransactionBuilder() - flag_splits = [stb.build(t) for t in transactions_dicts if t['flag_color'] == self.user.flag] - - return [s for s in flag_splits if s is not None] - - def insert_split(self, t: SplitTransaction): - data = {'transaction': { - "subtransactions": [{"amount": int(round(t.split_amount)), - "payee_id": self.user.account.transfer_payee_id, - "memo": t.memo, - "cleared": "cleared" - }, - {"amount": int(t.amount - round(t.split_amount)), - "category_id": t.category.id, - "cleared": "cleared" - }] - }} - url = f'{YNAB_BASE_URL}budgets/{self.user.account.budget_id}/transactions/{t.id}' - - r = requests.put(url, json=data, headers=self._header()) - r.raise_for_status() diff --git a/ynabsplitbudget/models/splittransaction.py b/ynabsplitbudget/models/splittransaction.py deleted file mode 100644 index 4acf114..0000000 --- a/ynabsplitbudget/models/splittransaction.py +++ /dev/null @@ -1,29 +0,0 @@ -from dataclasses import dataclass -from datetime import date, datetime - -from ynabsplitbudget.models.category import Category - - -@dataclass(eq=True, frozen=True) -class SplitTransaction: - id: str - transaction_date: date - memo: str - payee_name: str - payee_id: str - category: Category - amount: float - split_amount: float - account_id: str - - @classmethod - def from_dict(cls, t_dict: dict, split_amount: float): - return cls(id=t_dict['id'], - transaction_date=datetime.strptime(t_dict['date'], '%Y-%m-%d').date(), - payee_name=t_dict['payee_name'], - memo=t_dict['memo'], - amount=t_dict['amount'], - payee_id=t_dict['payee_id'], - category=Category(name=t_dict['category_name'], id=t_dict['category_id']), - split_amount=split_amount, - account_id=t_dict['account_id']) diff --git a/ynabsplitbudget/transactionbuilder.py b/ynabsplitbudget/transactionbuilder.py index 783543d..6d67586 100644 --- a/ynabsplitbudget/transactionbuilder.py +++ b/ynabsplitbudget/transactionbuilder.py @@ -6,7 +6,6 @@ from typing import Union, Optional from ynabsplitbudget.models.exception import SplitNotValid -from ynabsplitbudget.models.splittransaction import SplitTransaction from ynabsplitbudget.models.transaction import RootTransaction, ComplementTransaction, LookupTransaction from ynabsplitbudget.models.user import User @@ -66,40 +65,3 @@ def build_lookup(t_dict: dict) -> LookupTransaction: account_id=t_dict['account_id']) -class SplitTransactionBuilder: - - @staticmethod - def build(t_dict: dict) -> Optional[SplitTransaction]: - try: - split_amount = SplitParser().parse_split(t_dict) - return SplitTransaction.from_dict(t_dict=t_dict, split_amount=split_amount) - except SplitNotValid as e: - logging.getLogger(__name__).error(e) - return None - - -class SplitParser: - - @staticmethod - def parse_split(t_dict: dict) -> int: - amount = t_dict['amount'] - rep = f"[{t_dict['date']} | {t_dict['payee_name']} | {amount / 1000:.2f} | {t_dict['memo']}]" - - try: - r = re.search(r'@(\d+\.?\d*)(%?)', t_dict['memo']) - except TypeError: - r = None - - # return half the amount if no split attribution is found - if r is None: - return amount * 0.5 - - split_number = float(r.groups()[0]) - if r.groups()[1] == '%': - if split_number <= 100: - return amount * split_number / 100 - raise SplitNotValid(f"Split is above 100% for transaction {rep}") - if split_number * 1000 > abs(amount): - raise SplitNotValid(f"Split is above total amount of {amount / 1000:.2f} for transaction {rep}") - sign = -1 if amount < 0 else 1 - return int(sign * split_number * 1000) diff --git a/ynabsplitbudget/ynabsplitbudget.py b/ynabsplitbudget/ynabsplitbudget.py index e023cca..43ae483 100644 --- a/ynabsplitbudget/ynabsplitbudget.py +++ b/ynabsplitbudget/ynabsplitbudget.py @@ -5,7 +5,7 @@ from ynabtransactionadjuster import Credentials from ynabsplitbudget.adjusters import ReconcileAdjuster, SplitAdjuster -from ynabsplitbudget.client import SplitClient, SyncClient +from ynabsplitbudget.client import SyncClient from ynabsplitbudget.fileloader import FileLoader from ynabsplitbudget.models.exception import BalancesDontMatch from ynabsplitbudget.models.transaction import ComplementTransaction From 03fbd1c49b1f944758255b1f72c97e91872f231c Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Tue, 30 Apr 2024 07:37:03 +0200 Subject: [PATCH 04/28] cleaned up unused imports --- ynabsplitbudget/transactionbuilder.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ynabsplitbudget/transactionbuilder.py b/ynabsplitbudget/transactionbuilder.py index 6d67586..1b7be75 100644 --- a/ynabsplitbudget/transactionbuilder.py +++ b/ynabsplitbudget/transactionbuilder.py @@ -1,11 +1,9 @@ import hashlib -import logging import re from dataclasses import dataclass from datetime import datetime -from typing import Union, Optional +from typing import Union -from ynabsplitbudget.models.exception import SplitNotValid from ynabsplitbudget.models.transaction import RootTransaction, ComplementTransaction, LookupTransaction from ynabsplitbudget.models.user import User From 9fa32cfcde87e67a30103fceb82007ac8df96e64 Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Tue, 30 Apr 2024 08:56:41 +0200 Subject: [PATCH 05/28] implemented clear functionality after split --- poetry.lock | 8 +++--- pyproject.toml | 2 +- tests/test_adjusters.py | 45 ++++++++++++++++++++++++------ ynabsplitbudget/adjusters.py | 13 +++++---- ynabsplitbudget/ynabsplitbudget.py | 27 ++++++++++++------ 5 files changed, 68 insertions(+), 27 deletions(-) diff --git a/poetry.lock b/poetry.lock index 20e5f57..7f00a04 100644 --- a/poetry.lock +++ b/poetry.lock @@ -546,13 +546,13 @@ files = [ [[package]] name = "ynab-transaction-adjuster" -version = "1.2.0" +version = "2.0.0" description = "Library to adjust transactions in YNAB based on custom patterns" optional = false python-versions = "<4.0,>=3.8" files = [ - {file = "ynab_transaction_adjuster-1.2.0-py3-none-any.whl", hash = "sha256:544e4ddb72be5caca1364538b541555a23e323b3baa8f7234cddad70ddd671be"}, - {file = "ynab_transaction_adjuster-1.2.0.tar.gz", hash = "sha256:d51786b811412cd5420a07c297011819bbb56835dda0d362a4220f5d62fb8786"}, + {file = "ynab_transaction_adjuster-2.0.0-py3-none-any.whl", hash = "sha256:ae8a13f870a57aeb58140ca502261ccf1a91307d63f15cccc064070ed556c330"}, + {file = "ynab_transaction_adjuster-2.0.0.tar.gz", hash = "sha256:9047fdd08658abe27cfe6060a9660b8dbdca96c232c101ffe73a9baa65d6f824"}, ] [package.dependencies] @@ -564,4 +564,4 @@ requests = ">=2.28.0,<3.0.0" [metadata] lock-version = "2.0" python-versions = "<4.0,>=3.8" -content-hash = "c8a77aa99105c873341788bb8a0e7baf6df6368a17244ec172b903d6c4cc0510" +content-hash = "717e041e3d2d3abef49fe992966d8d5bb1709e7fad725f05e78240c61ee448e3" diff --git a/pyproject.toml b/pyproject.toml index 4ed2b9a..20b5925 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ python = '<4.0,>=3.8' pyyaml = '>=6.0' requests = '>=2.28' -ynab-transaction-adjuster= '>=1.2.0' +ynab-transaction-adjuster= '>=2.0.0' [tool.poetry.group.dev.dependencies] pytest = ">=8.0.0" diff --git a/tests/test_adjusters.py b/tests/test_adjusters.py index 62971d8..7dce113 100644 --- a/tests/test_adjusters.py +++ b/tests/test_adjusters.py @@ -1,9 +1,9 @@ from unittest.mock import MagicMock, PropertyMock, patch -from ynabtransactionadjuster import Payee +from ynabtransactionadjuster import Payee, Transaction from ynabtransactionadjuster.models import Category -from ynabsplitbudget.adjusters import ReconcileAdjuster, SplitAdjuster +from ynabsplitbudget.adjusters import ReconcileAdjuster, SplitAdjuster, ClearAdjuster def test_reconcile_filter(): @@ -25,17 +25,23 @@ def test_reconcile_adjust(mock_categories): def test_split_filter(): - sa = SplitAdjuster(credentials=MagicMock(), flag_color='red', transfer_payee_id='transfer_payee_id') - f = sa.filter([PropertyMock(cleared='cleared', flag_color='red', id='1'), - PropertyMock(cleared='cleared', id='2'), - PropertyMock(flag_color='yellow', id='3')]) + sa = SplitAdjuster(credentials=MagicMock(), flag_color='red', transfer_payee_id='transfer_payee_id', + account_id='account_id') + f = sa.filter([PropertyMock(id='a', cleared='cleared', flag_color='red', account=MagicMock(id='account_idx'), subtransactions=[]), + PropertyMock(id='b', cleared='cleared', flag_color=None, account=MagicMock(id='account_idx'), subtransactions=[]), + PropertyMock(id='c', cleared='cleared', flag_color='yellow', account=MagicMock(id='account_idx'), subtransactions=[]), + PropertyMock(id='d', cleared='uncleared', flag_color='red', account=MagicMock(id='account_idx'), subtransactions=[]), + PropertyMock(id='c', cleared='reconciled', flag_color='red', account=MagicMock(id='account_idx'), subtransactions=[]), + PropertyMock(id='c', cleared='cleared', flag_color='red', account=MagicMock(id='account_id'), subtransactions=[]), + PropertyMock(id='c', cleared='cleared', flag_color='red', account=MagicMock(id='account_idx'), subtransactions=[MagicMock()])]) assert len(f) == 1 - assert f[0].id == '1' + assert f[0].id == 'a' @patch('ynabsplitbudget.adjusters.SplitAdjuster.payees', new_callable=PropertyMock()) def test_split_adjust(mock_payees): - sa = SplitAdjuster(credentials=MagicMock(), flag_color='red', transfer_payee_id='transfer_payee_id') + sa = SplitAdjuster(credentials=MagicMock(), flag_color='red', transfer_payee_id='transfer_payee_id', + account_id='account_id') mock_payees.fetch_by_id.return_value = Payee(name='transfer_payee') mt = sa.adjust(PropertyMock(category=Category(id='category_id', name='category_name'), @@ -49,3 +55,26 @@ def test_split_adjust(mock_payees): assert mt.subtransactions[1].amount == -750 assert mt.subtransactions[1].memo == '@25% memo' assert mt.subtransactions[1].category.name == 'category_name' + + +def test_clear_filter(): + ca = ClearAdjuster(credentials=MagicMock(), split_transaction_ids=['transaction_id']) + r = ca.filter([PropertyMock(spec=Transaction, cleared='uncleared', id='transaction_id'), + PropertyMock(spec=Transaction, cleared='uncleared', id='transaction_id2'), + PropertyMock(spec=Transaction, cleared='cleared', id='transaction_id')]) + assert len(r) == 1 + + +@patch('ynabsplitbudget.adjusters.ClearAdjuster.categories', new_callable=PropertyMock()) +def test_clear_adjust(mock_categories): + # Arrange + ca = ClearAdjuster(credentials=MagicMock(), split_transaction_ids=[]) + mock_category = Category(name='category_name', id='category_id') + mock_categories.fetch_by_name.return_value = mock_category + + # Act + mt = ca.adjust(PropertyMock(cleared='uncleared'), PropertyMock(cleared='uncleared')) + + # Assert + assert mt.cleared == 'cleared' + assert mt.category == mock_category diff --git a/ynabsplitbudget/adjusters.py b/ynabsplitbudget/adjusters.py index 6a13f07..ab88654 100644 --- a/ynabsplitbudget/adjusters.py +++ b/ynabsplitbudget/adjusters.py @@ -19,15 +19,14 @@ def adjust(self, original: Transaction, modifier: Modifier) -> Modifier: class ClearAdjuster(Adjuster): - def __init__(self, credentials: Credentials, flag_color: str): + def __init__(self, credentials: Credentials, split_transaction_ids: List[str]): super().__init__(credentials=credentials) - self.flag_color = flag_color + self.split_transaction_ids = split_transaction_ids def filter(self, transactions: List[Transaction]) -> List[Transaction]: return [t for t in transactions if t.cleared == 'uncleared' - and t.transfer_transaction_id - and self.fetch_transaction(t.transfer_transaction_id).flag_color == self.flag_color] + and t.id in self.split_transaction_ids] def adjust(self, original: Transaction, modifier: Modifier) -> Modifier: modifier.cleared = 'cleared' @@ -37,13 +36,15 @@ def adjust(self, original: Transaction, modifier: Modifier) -> Modifier: class SplitAdjuster(Adjuster): - def __init__(self, credentials: Credentials, flag_color: str, transfer_payee_id: str): + def __init__(self, credentials: Credentials, flag_color: str, transfer_payee_id: str, account_id: str): super().__init__(credentials=credentials) self.flag_color = flag_color self.transfer_payee_id = transfer_payee_id + self.account_id = account_id def filter(self, transactions: List[Transaction]) -> List[Transaction]: - return [t for t in transactions if t.cleared == 'cleared' and t.flag_color == self.flag_color] + return [t for t in transactions if t.cleared == 'cleared' and t.flag_color == self.flag_color + and not t.subtransactions and not t.account.id == self.account_id] def adjust(self, original: Transaction, modifier: Modifier) -> Modifier: split_amount = SplitParser().parse_split(transaction=original) diff --git a/ynabsplitbudget/ynabsplitbudget.py b/ynabsplitbudget/ynabsplitbudget.py index 43ae483..75c5f80 100644 --- a/ynabsplitbudget/ynabsplitbudget.py +++ b/ynabsplitbudget/ynabsplitbudget.py @@ -4,7 +4,7 @@ from ynabtransactionadjuster import Credentials -from ynabsplitbudget.adjusters import ReconcileAdjuster, SplitAdjuster +from ynabsplitbudget.adjusters import ReconcileAdjuster, SplitAdjuster, ClearAdjuster from ynabsplitbudget.client import SyncClient from ynabsplitbudget.fileloader import FileLoader from ynabsplitbudget.models.exception import BalancesDontMatch @@ -36,15 +36,26 @@ def insert_complements(self, since: date = None) -> int: logging.getLogger(__name__).info(f'inserted {len(transactions)} complements into account of {self._partner.name}') return len(transactions) - def split_transactions(self, set_cleared: bool = False) -> int: + def split_transactions(self, clear_splits: bool = False) -> int: """Splits transactions """ - creds = Credentials(token=self._user.token, budget=self._user.account.budget_id, - account=self._user.account.account_id) - s = SplitAdjuster(creds, flag_color=self._user.flag, transfer_payee_id=self._user.account.transfer_payee_id) + creds = Credentials(token=self._user.token, budget=self._user.account.budget_id) + s = SplitAdjuster(creds, flag_color=self._user.flag, transfer_payee_id=self._user.account.transfer_payee_id, + account_id=self._user.account.account_id) mod_trans = s.apply() - count = s.update(mod_trans) - logging.getLogger(__name__).info(f'split {count} transactions for {self._user.name}') - return count + updated_transactions = s.update(mod_trans) + logging.getLogger(__name__).info(f'split {len(updated_transactions)} transactions for {self._user.name}') + + if clear_splits: + transfer_transaction_ids = [st.transfer_transaction_id for t in updated_transactions for st in + t.subtransactions if st.transfer_transaction_id] + creds = Credentials(token=self._user.token, budget=self._user.account.budget_id, + account=self._user.account.account_id) + ca = ClearAdjuster(creds, split_transaction_ids=transfer_transaction_ids) + mod_trans_clear = ca.apply() + count_clear = len(s.update(mod_trans_clear)) + logging.getLogger(__name__).info(f'cleared {count_clear} transactions for {self._user.name}') + + return len(updated_transactions) def raise_on_balances_off(self): repo = SyncRepository(user=self._user, partner=self._partner) From 41fc28aa959938756b5106205cd7f4ba9d284f60 Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Wed, 1 May 2024 06:59:35 +0200 Subject: [PATCH 06/28] refactored interface, added docstrings and updated documentation --- README.md | 48 +++++++++++++++++------------ ynabsplitbudget/__main__.py | 4 +-- ynabsplitbudget/ynabsplitbudget.py | 49 +++++++++++++++++++++++------- 3 files changed, 68 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index e685224..4e8aade 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![GitHub Release](https://img.shields.io/github/release/dnbasta/ynab-split-budget?style=flat)]() [![Github Release](https://img.shields.io/maintenance/yes/2100)]() +[![Monthly downloads](https://img.shields.io/pypi/dm/ynab-split-budget)]() This library enables cost sharing across two YNAB budgets. It requires two dedicated accounts in each budget to which each budget owner can transfer amounts from their own budget. Each transfer is considered as its opposite in the other @@ -62,8 +63,7 @@ By default, the transaction will be split in half, but you can specify a differe percentage or `@x` for specific amount in the memo of the transaction. The amount you specify in this split will be transferred to your sharing account. You can also create a plain transfer to the shared account which will be completely allocated to the partner account. -### 2. Initialize and run the split functionality - +### 2. Initialize library ```py from ynabsplitbudget import YnabSplitBudget @@ -72,36 +72,44 @@ ynab_split_budget = YnabSplitBudget(config=CONFIG, user='') # or alternatively from yaml ynab_split_budget = YnabSplitBudget.from_yaml(path='path/to/config.yaml', user=' 'YnabSplitBudget': + """Create instance by loading config from YAML file + + :param path: Path to the YAML file + :param user: User for which to create the instance + + :returns: instance of YnabSplitBudget class + """ config_dict = FileLoader(path).load() return cls(config=config_dict, user=user) - def insert_complements(self, since: date = None) -> int: + def push(self, since: date = None) -> int: + """Pushes transactions from user split account to partner split account. By default, considers transactions of + last 30 days. + + :param since: If set to date, will push transactions from that date onwards instead of default 30 days + """ since = self._substitute_default_since(since) repo = SyncRepository(user=self._user, partner=self._partner) transactions = repo.fetch_roots_wo_complement(since=since) @@ -36,8 +48,13 @@ def insert_complements(self, since: date = None) -> int: logging.getLogger(__name__).info(f'inserted {len(transactions)} complements into account of {self._partner.name}') return len(transactions) - def split_transactions(self, clear_splits: bool = False) -> int: - """Splits transactions """ + def split(self, clear_splits: bool = False) -> int: + """Splits transactions (by default 50%) into subtransaction with original category and transfer subtransaction + to split account + + :param clear_splits: If set to true transactions in split account will automatically be set to cleared + :return: count of split transactions + """ creds = Credentials(token=self._user.token, budget=self._user.account.budget_id) s = SplitAdjuster(creds, flag_color=self._user.flag, transfer_payee_id=self._user.account.transfer_payee_id, account_id=self._user.account.account_id) @@ -58,6 +75,10 @@ def split_transactions(self, clear_splits: bool = False) -> int: return len(updated_transactions) def raise_on_balances_off(self): + """Evaluates cleared balances in both accounts + + :raises BalancesDontMatch: if cleared amounts in both accounts don't match + """ repo = SyncRepository(user=self._user, partner=self._partner) user_balance, partner_balance = repo.fetch_balances() if user_balance + partner_balance != 0: @@ -66,7 +87,11 @@ def raise_on_balances_off(self): 'partner': {'name': self._partner.name, 'balance': partner_balance}}) - def delete_orphaned_complements(self, since: date = None) -> List[ComplementTransaction]: + def delete_orphans(self, since: date = None) -> List[ComplementTransaction]: + """Delete orphaned transactions in partner account. By default, considers transactions of last 30 days. + + :param since: if set to date will delete orphaned transactions from that date onwards instead of default 30 days + """ since = self._substitute_default_since(since) orphaned_complements = SyncRepository(user=self._user, partner=self._partner).find_orphaned_partner_complements(since) c = SyncClient(self._partner) @@ -77,17 +102,19 @@ def delete_orphaned_complements(self, since: date = None) -> List[ComplementTran def reconcile(self) -> int: """Reconciles cleared transactions in the current account - :return: count of reconciled transactions + + :returns: count of reconciled transactions """ creds = Credentials(token=self._user.token, budget=self._user.account.budget_id, account=self._user.account.account_id) - adjuster = ReconcileAdjuster.from_credentials(creds) - c = adjuster.dryrun() - logging.getLogger(__name__).info(f'reconciled {c} transactions for {self._user.name}') - return c + ra = ReconcileAdjuster(creds) + mod_trans = ra.apply() + updated_trans = ra.update(mod_trans) + logging.getLogger(__name__).info(f'reconciled {len(updated_trans)} transactions for {self._user.name}') + return len(updated_trans) @staticmethod - def _substitute_default_since(since: Optional[date]): + def _substitute_default_since(since: Optional[date]) -> date: if since is None: return datetime.now() - timedelta(days=30) return since From 1dba841bc22b57013670a11544fa38e010dffabd Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Wed, 1 May 2024 07:45:14 +0200 Subject: [PATCH 07/28] created config object, refactored user and base client --- ynabsplitbudget/baseclient.py | 37 ++++++++++++++++++++++++++++++++ ynabsplitbudget/client.py | 29 ------------------------- ynabsplitbudget/models/config.py | 19 ++++++++++++++++ ynabsplitbudget/models/user.py | 29 +++++++++++++++++++++++-- 4 files changed, 83 insertions(+), 31 deletions(-) create mode 100644 ynabsplitbudget/baseclient.py create mode 100644 ynabsplitbudget/models/config.py diff --git a/ynabsplitbudget/baseclient.py b/ynabsplitbudget/baseclient.py new file mode 100644 index 0000000..d70c33c --- /dev/null +++ b/ynabsplitbudget/baseclient.py @@ -0,0 +1,37 @@ +import requests + +from ynabsplitbudget.models.account import Account +from ynabsplitbudget.models.exception import BudgetNotFound, AccountNotFound + +YNAB_BASE_URL = 'https://api.ynab.com/v1/' + + +class BaseClient: + + def __init__(self, token: str, user_name: str): + self.session = requests.Session() + self.session.headers = {'Authorization': f'Bearer {token}'} + self.user_name = user_name + + def fetch_account(self, budget_id: str, account_id: str) -> Account: + r = self.session.get(f'{YNAB_BASE_URL}budgets', params=dict(include_accounts=True)) + r.raise_for_status() + data_dict = r.json()['data'] + + try: + budget = next(b for b in data_dict['budgets'] if b['id'] == budget_id) + except StopIteration: + raise BudgetNotFound(f"No budget with id '{budget_id} found for {self.user_name}'") + + try: + account = next(a for a in budget['accounts'] if a['id'] == account_id and a['deleted'] is False) + except StopIteration: + raise AccountNotFound(f"No Account with id '{account_id}' fund in budget '{budget['name']} " + f"for user {self.user_name}'") + + return Account(budget_id=budget_id, + budget_name=budget['name'], + account_id=account_id, + account_name=account['name'], + transfer_payee_id=account['transfer_payee_id'], + currency=budget['currency_format']['iso_code']) diff --git a/ynabsplitbudget/client.py b/ynabsplitbudget/client.py index 8ad79e2..713b816 100644 --- a/ynabsplitbudget/client.py +++ b/ynabsplitbudget/client.py @@ -26,35 +26,6 @@ def _get(self, url: str, params: dict = None) -> dict: return r.json()['data'] -class BaseClient(ClientMixin): - - def __init__(self, token: str, user_name: str): - self._token = token - self._user_name = user_name - - def fetch_account(self, budget_id: str, account_id: str) -> Account: - url = f'{YNAB_BASE_URL}budgets?include_accounts=true' - data_dict = self._get(url) - - try: - budget = next(b for b in data_dict['budgets'] if b['id'] == budget_id) - except StopIteration: - raise BudgetNotFound(f"No budget with id '{budget_id} found for {self._user_name}'") - - try: - account = next(a for a in budget['accounts'] if a['id'] == account_id and a['deleted'] is False) - except StopIteration: - raise AccountNotFound(f"No Account with id '{account_id}' fund in budget '{budget['name']} " - f"for user {self._user_name}'") - - return Account(budget_id=budget_id, - budget_name=budget['name'], - account_id=account_id, - account_name=account['name'], - transfer_payee_id=account['transfer_payee_id'], - currency=budget['currency_format']['iso_code']) - - @dataclass class SyncClient(ClientMixin): diff --git a/ynabsplitbudget/models/config.py b/ynabsplitbudget/models/config.py new file mode 100644 index 0000000..92d7fd7 --- /dev/null +++ b/ynabsplitbudget/models/config.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass + +from ynabsplitbudget.models.user import User + + +@dataclass +class Config: + """Configuration object to use for YnabSplitBudget instance + + :ivar user: User which to use for the instance + :ivar partner: Partner which to use for the instance + """ + user: User + partner: User + + @classmethod + def from_dict(cls, data: dict) -> 'Config': + """Creates a Config object from a dict.""" + return cls(user=User.from_dict(data['user']), partner=User.from_dict(data['partner'])) diff --git a/ynabsplitbudget/models/user.py b/ynabsplitbudget/models/user.py index 958c69f..9ffb8b2 100644 --- a/ynabsplitbudget/models/user.py +++ b/ynabsplitbudget/models/user.py @@ -1,11 +1,36 @@ from dataclasses import dataclass +from typing import Literal +from ynabsplitbudget.baseclient import BaseClient from ynabsplitbudget.models.account import Account @dataclass(eq=True, frozen=True) class User: + """Object representing a user. + + :ivar name: The name of the user. + :ivar token: The token for access to the YNAB API. + :ivar budget_id: The ID of the YNAB budget to use. + :ivar account_id: The ID of the split account to use in the budget. + :ivar flag_color: The color of the flag with which split transactions are marked + """ name: str token: str - account: Account - flag: str + budget_id: str + account_id: str + flag_color: Literal['red', 'green', 'blue', 'orange', 'purple', 'yellow'] + + def fetch_account(self) -> Account: + """Fetches account data from the API or user + + :return: Account object with budget and account name, transfer_payee_id and currency + """ + client = BaseClient(token=self.token, user_name=self.name) + return client.fetch_account(budget_id=self.budget_id, account_id=self.account_id) + + @classmethod + def from_dict(cls, data: dict) -> 'User': + """Creates user object from dict""" + return cls(name=data['name'], token=data['token'], budget_id=data['budget_id'], account_id=data['account_id'], + flag_color=data['flag_color']) From fb68369dfae8a7f5ec9f4989c591c52e58caa4f5 Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Wed, 1 May 2024 07:46:10 +0200 Subject: [PATCH 08/28] changed init, from_yaml and implemented new user objects --- ynabsplitbudget/ynabsplitbudget.py | 60 ++++++++++++++++-------------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/ynabsplitbudget/ynabsplitbudget.py b/ynabsplitbudget/ynabsplitbudget.py index 00f30a2..b384463 100644 --- a/ynabsplitbudget/ynabsplitbudget.py +++ b/ynabsplitbudget/ynabsplitbudget.py @@ -7,32 +7,36 @@ from ynabsplitbudget.adjusters import ReconcileAdjuster, SplitAdjuster, ClearAdjuster from ynabsplitbudget.client import SyncClient from ynabsplitbudget.fileloader import FileLoader +from ynabsplitbudget.models.config import Config from ynabsplitbudget.models.exception import BalancesDontMatch from ynabsplitbudget.models.transaction import ComplementTransaction +from ynabsplitbudget.models.user import User from ynabsplitbudget.syncrepository import SyncRepository -from ynabsplitbudget.userloader import UserLoader class YnabSplitBudget: - - def __init__(self, config: dict, user: str): + """Interface to YNAB Split Budget. + + :ivar user: User to use for instance + :ivar partner: Partner to use for instance + :ivar logger: Logger of the instance + """ + def __init__(self, user: User, partner: User): + self.user = user + self.partner = partner self.logger = self._set_up_logger() - userloader = UserLoader(config_dict=config) - self._user = userloader.load(user) - self._partner = userloader.load_partner(user) - @classmethod - def from_yaml(cls, path: str, user: str) -> 'YnabSplitBudget': + def from_yaml(cls, path: str) -> 'YnabSplitBudget': """Create instance by loading config from YAML file :param path: Path to the YAML file - :param user: User for which to create the instance :returns: instance of YnabSplitBudget class """ config_dict = FileLoader(path).load() - return cls(config=config_dict, user=user) + config = Config.from_dict(config_dict) + return cls(user=config.user, partner=config.partner) def push(self, since: date = None) -> int: """Pushes transactions from user split account to partner split account. By default, considers transactions of @@ -41,11 +45,11 @@ def push(self, since: date = None) -> int: :param since: If set to date, will push transactions from that date onwards instead of default 30 days """ since = self._substitute_default_since(since) - repo = SyncRepository(user=self._user, partner=self._partner) + repo = SyncRepository(user=self.user, partner=self.partner) transactions = repo.fetch_roots_wo_complement(since=since) repo.insert_complements(transactions) - logging.getLogger(__name__).info(f'inserted {len(transactions)} complements into account of {self._partner.name}') + logging.getLogger(__name__).info(f'inserted {len(transactions)} complements into account of {self.partner.name}') return len(transactions) def split(self, clear_splits: bool = False) -> int: @@ -55,22 +59,22 @@ def split(self, clear_splits: bool = False) -> int: :param clear_splits: If set to true transactions in split account will automatically be set to cleared :return: count of split transactions """ - creds = Credentials(token=self._user.token, budget=self._user.account.budget_id) - s = SplitAdjuster(creds, flag_color=self._user.flag, transfer_payee_id=self._user.account.transfer_payee_id, - account_id=self._user.account.account_id) + creds = Credentials(token=self.user.token, budget=self.user.budget_id) + s = SplitAdjuster(creds, flag_color=self.user.flag_color, transfer_payee_id=self.user.fetch_account().transfer_payee_id, + account_id=self.user.account_id) mod_trans = s.apply() updated_transactions = s.update(mod_trans) - logging.getLogger(__name__).info(f'split {len(updated_transactions)} transactions for {self._user.name}') + logging.getLogger(__name__).info(f'split {len(updated_transactions)} transactions for {self.user.name}') if clear_splits: transfer_transaction_ids = [st.transfer_transaction_id for t in updated_transactions for st in t.subtransactions if st.transfer_transaction_id] - creds = Credentials(token=self._user.token, budget=self._user.account.budget_id, - account=self._user.account.account_id) + creds = Credentials(token=self.user.token, budget=self.user.budget_id, + account=self.user.account_id) ca = ClearAdjuster(creds, split_transaction_ids=transfer_transaction_ids) mod_trans_clear = ca.apply() count_clear = len(s.update(mod_trans_clear)) - logging.getLogger(__name__).info(f'cleared {count_clear} transactions for {self._user.name}') + logging.getLogger(__name__).info(f'cleared {count_clear} transactions for {self.user.name}') return len(updated_transactions) @@ -79,12 +83,12 @@ def raise_on_balances_off(self): :raises BalancesDontMatch: if cleared amounts in both accounts don't match """ - repo = SyncRepository(user=self._user, partner=self._partner) + repo = SyncRepository(user=self.user, partner=self.partner) user_balance, partner_balance = repo.fetch_balances() if user_balance + partner_balance != 0: - raise BalancesDontMatch({'user': {'name': self._user.name, + raise BalancesDontMatch({'user': {'name': self.user.name, 'balance': user_balance}, - 'partner': {'name': self._partner.name, + 'partner': {'name': self.partner.name, 'balance': partner_balance}}) def delete_orphans(self, since: date = None) -> List[ComplementTransaction]: @@ -93,10 +97,10 @@ def delete_orphans(self, since: date = None) -> List[ComplementTransaction]: :param since: if set to date will delete orphaned transactions from that date onwards instead of default 30 days """ since = self._substitute_default_since(since) - orphaned_complements = SyncRepository(user=self._user, partner=self._partner).find_orphaned_partner_complements(since) - c = SyncClient(self._partner) + orphaned_complements = SyncRepository(user=self.user, partner=self.partner).find_orphaned_partner_complements(since) + c = SyncClient(self.partner) [c.delete_complement(oc.id) for oc in orphaned_complements] - logging.getLogger(__name__).info(f'deleted {len(orphaned_complements)} orphaned complements in account of {self._partner.name}') + logging.getLogger(__name__).info(f'deleted {len(orphaned_complements)} orphaned complements in account of {self.partner.name}') logging.getLogger(__name__).info(orphaned_complements) return orphaned_complements @@ -105,12 +109,12 @@ def reconcile(self) -> int: :returns: count of reconciled transactions """ - creds = Credentials(token=self._user.token, budget=self._user.account.budget_id, - account=self._user.account.account_id) + creds = Credentials(token=self.user.token, budget=self.user.budget_id, + account=self.user.account_id) ra = ReconcileAdjuster(creds) mod_trans = ra.apply() updated_trans = ra.update(mod_trans) - logging.getLogger(__name__).info(f'reconciled {len(updated_trans)} transactions for {self._user.name}') + logging.getLogger(__name__).info(f'reconciled {len(updated_trans)} transactions for {self.user.name}') return len(updated_trans) @staticmethod From bb53a994b6d41e3c3b7d33173041ac82707b9465 Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Wed, 1 May 2024 07:55:09 +0200 Subject: [PATCH 09/28] refactored syncclient to work with new user object --- ynabsplitbudget/{client.py => syncclient.py} | 53 ++++++++------------ ynabsplitbudget/ynabsplitbudget.py | 2 +- 2 files changed, 23 insertions(+), 32 deletions(-) rename ynabsplitbudget/{client.py => syncclient.py} (57%) diff --git a/ynabsplitbudget/client.py b/ynabsplitbudget/syncclient.py similarity index 57% rename from ynabsplitbudget/client.py rename to ynabsplitbudget/syncclient.py index 713b816..9fde66e 100644 --- a/ynabsplitbudget/client.py +++ b/ynabsplitbudget/syncclient.py @@ -6,39 +6,26 @@ from requests import HTTPError from ynabsplitbudget.transactionbuilder import TransactionBuilder -from ynabsplitbudget.models.exception import BudgetNotFound, AccountNotFound -from ynabsplitbudget.models.account import Account from ynabsplitbudget.models.transaction import RootTransaction, LookupTransaction, ComplementTransaction from ynabsplitbudget.models.user import User YNAB_BASE_URL = 'https://api.ynab.com/v1/' -class ClientMixin: - _token: str - - def _header(self): - return {'Authorization': f'Bearer {self._token}'} - - def _get(self, url: str, params: dict = None) -> dict: - r = requests.get(url, params=params, headers=self._header()) - r.raise_for_status() - return r.json()['data'] - - @dataclass -class SyncClient(ClientMixin): +class SyncClient: def __init__(self, user: User): - self._token = user.token + self.session = requests.Session() + self.session.headers.update({'Authorization': f'Bearer {user.token}'}) self.user = user self.transaction_builder = TransactionBuilder(user=user) def fetch_roots(self, since: date) -> List[RootTransaction]: - url = (f'{YNAB_BASE_URL}budgets/{self.user.account.budget_id}/accounts/' - f'{self.user.account.account_id}/transactions') - - transactions_dicts = self._get(url, params={'since_date': datetime.strftime(since, '%Y-%m-%d')})['transactions'] + url = f'{YNAB_BASE_URL}budgets/{self.user.budget_id}/accounts/{self.user.account_id}/transactions' + r = self.session.get(url, params={'since_date': datetime.strftime(since, '%Y-%m-%d')}) + r.raise_for_status() + transactions_dicts = r.json()['data']['transactions'] transactions_filtered = [t for t in transactions_dicts if not t['cleared'] == 'uncleared' and t['deleted'] is False and (t['import_id'] is None or 's||' not in t['import_id']) @@ -47,9 +34,11 @@ def fetch_roots(self, since: date) -> List[RootTransaction]: return transactions def fetch_lookup(self, since: date) -> List[Union[RootTransaction, LookupTransaction, ComplementTransaction]]: - url = f'{YNAB_BASE_URL}budgets/{self.user.account.budget_id}/transactions' - data_dict = self._get(url, params={'since_date': datetime.strftime(since, '%Y-%m-%d')}) - transactions = [self.transaction_builder.build(t_dict=t) for t in data_dict['transactions']] + url = f'{YNAB_BASE_URL}budgets/{self.user.budget_id}/transactions' + r = self.session.get(url, params={'since_date': datetime.strftime(since, '%Y-%m-%d')}) + r.raise_for_status() + data_dict = r.json()['data']['transactions'] + transactions = [self.transaction_builder.build(t_dict=t) for t in data_dict] return transactions def insert_complement(self, t: RootTransaction): @@ -64,9 +53,9 @@ def insert_complement(self, t: RootTransaction): iteration += 1 def _insert(self, t: RootTransaction, iteration: int): - url = f'{YNAB_BASE_URL}budgets/{self.user.account.budget_id}/transactions' + url = f'{YNAB_BASE_URL}budgets/{self.user.budget_id}/transactions' data = {'transaction': { - "account_id": self.user.account.account_id, + "account_id": self.user.account_id, "date": t.transaction_date.strftime("%Y-%m-%d"), "amount": - t.amount, "payee_name": t.payee_name, @@ -75,15 +64,17 @@ def _insert(self, t: RootTransaction, iteration: int): "approved": False, "import_id": f's||{t.share_id}||{iteration}' }} - r = requests.post(url, json=data, headers=self._header()) + r = self.session.post(url, json=data) r.raise_for_status() def fetch_balance(self) -> int: - url = f'{YNAB_BASE_URL}budgets/{self.user.account.budget_id}/accounts/{self.user.account.account_id}' - data_dict = self._get(url) - return data_dict['account']['cleared_balance'] + url = f'{YNAB_BASE_URL}budgets/{self.user.budget_id}/accounts/{self.user.account_id}' + r = self.session.get(url) + r.raise_for_status() + balance = r.json()['data']['account']['cleared_balance'] + return balance def delete_complement(self, transaction_id: str) -> None: - url = f'{YNAB_BASE_URL}budgets/{self.user.account.budget_id}/transactions/{transaction_id}' - r = requests.delete(url, headers=self._header()) + url = f'{YNAB_BASE_URL}budgets/{self.user.budget_id}/transactions/{transaction_id}' + r = self.session.delete(url) r.raise_for_status() diff --git a/ynabsplitbudget/ynabsplitbudget.py b/ynabsplitbudget/ynabsplitbudget.py index b384463..dcdd7d1 100644 --- a/ynabsplitbudget/ynabsplitbudget.py +++ b/ynabsplitbudget/ynabsplitbudget.py @@ -5,7 +5,7 @@ from ynabtransactionadjuster import Credentials from ynabsplitbudget.adjusters import ReconcileAdjuster, SplitAdjuster, ClearAdjuster -from ynabsplitbudget.client import SyncClient +from ynabsplitbudget.syncclient import SyncClient from ynabsplitbudget.fileloader import FileLoader from ynabsplitbudget.models.config import Config from ynabsplitbudget.models.exception import BalancesDontMatch From 936994766bdd264184792d9df1b3de68b0492578 Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Wed, 1 May 2024 08:04:10 +0200 Subject: [PATCH 10/28] simplified clients to one object --- ynabsplitbudget/baseclient.py | 37 --------------- ynabsplitbudget/{syncclient.py => client.py} | 50 +++++++++++++++----- ynabsplitbudget/models/user.py | 4 +- ynabsplitbudget/syncrepository.py | 8 ++-- ynabsplitbudget/transactionbuilder.py | 5 +- ynabsplitbudget/ynabsplitbudget.py | 4 +- 6 files changed, 49 insertions(+), 59 deletions(-) delete mode 100644 ynabsplitbudget/baseclient.py rename ynabsplitbudget/{syncclient.py => client.py} (56%) diff --git a/ynabsplitbudget/baseclient.py b/ynabsplitbudget/baseclient.py deleted file mode 100644 index d70c33c..0000000 --- a/ynabsplitbudget/baseclient.py +++ /dev/null @@ -1,37 +0,0 @@ -import requests - -from ynabsplitbudget.models.account import Account -from ynabsplitbudget.models.exception import BudgetNotFound, AccountNotFound - -YNAB_BASE_URL = 'https://api.ynab.com/v1/' - - -class BaseClient: - - def __init__(self, token: str, user_name: str): - self.session = requests.Session() - self.session.headers = {'Authorization': f'Bearer {token}'} - self.user_name = user_name - - def fetch_account(self, budget_id: str, account_id: str) -> Account: - r = self.session.get(f'{YNAB_BASE_URL}budgets', params=dict(include_accounts=True)) - r.raise_for_status() - data_dict = r.json()['data'] - - try: - budget = next(b for b in data_dict['budgets'] if b['id'] == budget_id) - except StopIteration: - raise BudgetNotFound(f"No budget with id '{budget_id} found for {self.user_name}'") - - try: - account = next(a for a in budget['accounts'] if a['id'] == account_id and a['deleted'] is False) - except StopIteration: - raise AccountNotFound(f"No Account with id '{account_id}' fund in budget '{budget['name']} " - f"for user {self.user_name}'") - - return Account(budget_id=budget_id, - budget_name=budget['name'], - account_id=account_id, - account_name=account['name'], - transfer_payee_id=account['transfer_payee_id'], - currency=budget['currency_format']['iso_code']) diff --git a/ynabsplitbudget/syncclient.py b/ynabsplitbudget/client.py similarity index 56% rename from ynabsplitbudget/syncclient.py rename to ynabsplitbudget/client.py index 9fde66e..3e45189 100644 --- a/ynabsplitbudget/syncclient.py +++ b/ynabsplitbudget/client.py @@ -5,24 +5,50 @@ import requests from requests import HTTPError +from ynabsplitbudget.models.account import Account +from ynabsplitbudget.models.exception import BudgetNotFound, AccountNotFound from ynabsplitbudget.transactionbuilder import TransactionBuilder from ynabsplitbudget.models.transaction import RootTransaction, LookupTransaction, ComplementTransaction -from ynabsplitbudget.models.user import User YNAB_BASE_URL = 'https://api.ynab.com/v1/' @dataclass -class SyncClient: +class Client: - def __init__(self, user: User): + def __init__(self, user_name: str, budget_id: str, account_id: str, token: str): self.session = requests.Session() - self.session.headers.update({'Authorization': f'Bearer {user.token}'}) - self.user = user - self.transaction_builder = TransactionBuilder(user=user) + self.session.headers.update({'Authorization': f'Bearer {token}'}) + self.user_name = user_name + self.budget_id = budget_id + self.account_id = account_id + self.transaction_builder = TransactionBuilder(account_id=self.account_id) + + def fetch_account(self, budget_id: str, account_id: str) -> Account: + r = self.session.get(f'{YNAB_BASE_URL}budgets', params=dict(include_accounts=True)) + r.raise_for_status() + data_dict = r.json()['data'] + + try: + budget = next(b for b in data_dict['budgets'] if b['id'] == budget_id) + except StopIteration: + raise BudgetNotFound(f"No budget with id '{budget_id} found for {self.user_name}'") + + try: + account = next(a for a in budget['accounts'] if a['id'] == account_id and a['deleted'] is False) + except StopIteration: + raise AccountNotFound(f"No Account with id '{account_id}' fund in budget '{budget['name']} " + f"for user {self.user_name}'") + + return Account(budget_id=budget_id, + budget_name=budget['name'], + account_id=account_id, + account_name=account['name'], + transfer_payee_id=account['transfer_payee_id'], + currency=budget['currency_format']['iso_code']) def fetch_roots(self, since: date) -> List[RootTransaction]: - url = f'{YNAB_BASE_URL}budgets/{self.user.budget_id}/accounts/{self.user.account_id}/transactions' + url = f'{YNAB_BASE_URL}budgets/{self.budget_id}/accounts/{self.account_id}/transactions' r = self.session.get(url, params={'since_date': datetime.strftime(since, '%Y-%m-%d')}) r.raise_for_status() transactions_dicts = r.json()['data']['transactions'] @@ -34,7 +60,7 @@ def fetch_roots(self, since: date) -> List[RootTransaction]: return transactions def fetch_lookup(self, since: date) -> List[Union[RootTransaction, LookupTransaction, ComplementTransaction]]: - url = f'{YNAB_BASE_URL}budgets/{self.user.budget_id}/transactions' + url = f'{YNAB_BASE_URL}budgets/{self.budget_id}/transactions' r = self.session.get(url, params={'since_date': datetime.strftime(since, '%Y-%m-%d')}) r.raise_for_status() data_dict = r.json()['data']['transactions'] @@ -53,9 +79,9 @@ def insert_complement(self, t: RootTransaction): iteration += 1 def _insert(self, t: RootTransaction, iteration: int): - url = f'{YNAB_BASE_URL}budgets/{self.user.budget_id}/transactions' + url = f'{YNAB_BASE_URL}budgets/{self.budget_id}/transactions' data = {'transaction': { - "account_id": self.user.account_id, + "account_id": self.account_id, "date": t.transaction_date.strftime("%Y-%m-%d"), "amount": - t.amount, "payee_name": t.payee_name, @@ -68,13 +94,13 @@ def _insert(self, t: RootTransaction, iteration: int): r.raise_for_status() def fetch_balance(self) -> int: - url = f'{YNAB_BASE_URL}budgets/{self.user.budget_id}/accounts/{self.user.account_id}' + url = f'{YNAB_BASE_URL}budgets/{self.budget_id}/accounts/{self.account_id}' r = self.session.get(url) r.raise_for_status() balance = r.json()['data']['account']['cleared_balance'] return balance def delete_complement(self, transaction_id: str) -> None: - url = f'{YNAB_BASE_URL}budgets/{self.user.budget_id}/transactions/{transaction_id}' + url = f'{YNAB_BASE_URL}budgets/{self.budget_id}/transactions/{transaction_id}' r = self.session.delete(url) r.raise_for_status() diff --git a/ynabsplitbudget/models/user.py b/ynabsplitbudget/models/user.py index 9ffb8b2..b29b649 100644 --- a/ynabsplitbudget/models/user.py +++ b/ynabsplitbudget/models/user.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Literal -from ynabsplitbudget.baseclient import BaseClient +from ynabsplitbudget.client import Client from ynabsplitbudget.models.account import Account @@ -26,7 +26,7 @@ def fetch_account(self) -> Account: :return: Account object with budget and account name, transfer_payee_id and currency """ - client = BaseClient(token=self.token, user_name=self.name) + client = Client(token=self.token, user_name=self.name, budget_id=self.budget_id, account_id=self.account_id) return client.fetch_account(budget_id=self.budget_id, account_id=self.account_id) @classmethod diff --git a/ynabsplitbudget/syncrepository.py b/ynabsplitbudget/syncrepository.py index 3abcb6d..b16ea5e 100644 --- a/ynabsplitbudget/syncrepository.py +++ b/ynabsplitbudget/syncrepository.py @@ -1,7 +1,7 @@ from datetime import date from typing import List, Union -from ynabsplitbudget.client import SyncClient +from ynabsplitbudget.client import Client from ynabsplitbudget.models.transaction import RootTransaction, ComplementTransaction, LookupTransaction from ynabsplitbudget.models.user import User @@ -9,8 +9,10 @@ class SyncRepository: def __init__(self, user: User, partner: User): - self._user_client = SyncClient(user) - self._partner_client = SyncClient(partner) + self._user_client = Client(token=user.token, budget_id=user.budget_id, account_id=user.account_id, + user_name=user.name) + self._partner_client = Client(token=partner.token, budget_id=partner.budget_id, account_id=partner.account_id, + user_name=partner.name) def fetch_roots_wo_complement(self, since: date) -> List[RootTransaction]: roots = self._user_client.fetch_roots(since=since) diff --git a/ynabsplitbudget/transactionbuilder.py b/ynabsplitbudget/transactionbuilder.py index 1b7be75..0fa0c14 100644 --- a/ynabsplitbudget/transactionbuilder.py +++ b/ynabsplitbudget/transactionbuilder.py @@ -5,17 +5,16 @@ from typing import Union from ynabsplitbudget.models.transaction import RootTransaction, ComplementTransaction, LookupTransaction -from ynabsplitbudget.models.user import User @dataclass class TransactionBuilder: - user: User + account_id: str def build(self, t_dict: dict) -> Union[RootTransaction, ComplementTransaction, LookupTransaction]: if t_dict['import_id'] and 's||' in t_dict['import_id']: return self.build_complement(t_dict) - if t_dict['account_id'] not in self.user.account.account_id: + if t_dict['account_id'] not in self.account_id: return self.build_lookup(t_dict) return self.build_root(t_dict) diff --git a/ynabsplitbudget/ynabsplitbudget.py b/ynabsplitbudget/ynabsplitbudget.py index dcdd7d1..55c5d1d 100644 --- a/ynabsplitbudget/ynabsplitbudget.py +++ b/ynabsplitbudget/ynabsplitbudget.py @@ -5,7 +5,7 @@ from ynabtransactionadjuster import Credentials from ynabsplitbudget.adjusters import ReconcileAdjuster, SplitAdjuster, ClearAdjuster -from ynabsplitbudget.syncclient import SyncClient +from ynabsplitbudget.client import Client from ynabsplitbudget.fileloader import FileLoader from ynabsplitbudget.models.config import Config from ynabsplitbudget.models.exception import BalancesDontMatch @@ -98,7 +98,7 @@ def delete_orphans(self, since: date = None) -> List[ComplementTransaction]: """ since = self._substitute_default_since(since) orphaned_complements = SyncRepository(user=self.user, partner=self.partner).find_orphaned_partner_complements(since) - c = SyncClient(self.partner) + c = Client(self.partner) [c.delete_complement(oc.id) for oc in orphaned_complements] logging.getLogger(__name__).info(f'deleted {len(orphaned_complements)} orphaned complements in account of {self.partner.name}') logging.getLogger(__name__).info(orphaned_complements) From 0cabfe91e92aa19bba4c3c838a8156b00083069f Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Wed, 1 May 2024 08:12:09 +0200 Subject: [PATCH 11/28] deleted userloader --- tests/test_userloader.py | 70 ----------------------------------- ynabsplitbudget/userloader.py | 32 ---------------- 2 files changed, 102 deletions(-) delete mode 100644 tests/test_userloader.py delete mode 100644 ynabsplitbudget/userloader.py diff --git a/tests/test_userloader.py b/tests/test_userloader.py deleted file mode 100644 index 778afba..0000000 --- a/tests/test_userloader.py +++ /dev/null @@ -1,70 +0,0 @@ -import json -from io import StringIO -from unittest.mock import patch, MagicMock - -import pytest -from yaml.parser import ParserError - -from ynabsplitbudget.models.exception import UserNotFound, ConfigNotValid -from ynabsplitbudget.models.user import User -from ynabsplitbudget.userloader import UserLoader - - -def test_user_loader_pass(mock_config_dict): - ul = UserLoader(mock_config_dict) - assert isinstance(ul, UserLoader) - - -@pytest.mark.parametrize('test_input', [{'u1': {}}, - {'u1': {}, 'u2': {}, 'u3': {}}, - {'u1': {}, 'u2': {}}]) -def test_user_loader_config_not_valid(test_input): - - # Act - with pytest.raises(ConfigNotValid) as e: - UserLoader(config_dict=test_input) - - -def test_user_loader_config_valid(mock_config_dict): - # Act - ul = UserLoader(mock_config_dict) - - # Assert - assert isinstance(ul, UserLoader) - assert ul._config_dict == mock_config_dict - - -def test_load_fail(mock_config_dict): - # Arrange - ul = UserLoader(mock_config_dict) - # Act - with pytest.raises(UserNotFound): - u = ul.load('xxx') - - -@patch('ynabsplitbudget.userloader.BaseClient.fetch_account', return_value=MagicMock()) -def test_load_pass(mock_account, mock_config_dict): - # Arrange - ul = UserLoader(mock_config_dict) - # Act - u = ul.load('user_1') - # Assert - assert isinstance(u, User) - assert u.name == 'user_1' - assert u.flag == 'purple' - assert u.token == 'token1' - - -@patch('ynabsplitbudget.userloader.BaseClient.fetch_account', return_value=MagicMock()) -def test_partner(mock_account, mock_config_dict): - # Arrange - ul = UserLoader(mock_config_dict) - - # Act - p = ul.load_partner('user_1') - - # Assert - assert isinstance(p, User) - assert p.name == 'user_2' - assert p.flag == 'red' - assert p.token == 'token2' diff --git a/ynabsplitbudget/userloader.py b/ynabsplitbudget/userloader.py deleted file mode 100644 index 138baf0..0000000 --- a/ynabsplitbudget/userloader.py +++ /dev/null @@ -1,32 +0,0 @@ -from ynabsplitbudget.client import BaseClient -from ynabsplitbudget.models.exception import ConfigNotValid, UserNotFound -from ynabsplitbudget.models.user import User - - -class UserLoader: - - def __init__(self, config_dict: dict): - self._config_dict = config_dict - - if len(self._config_dict) != 2: - raise ConfigNotValid('Config needs to have exactly 2 user entries') - - for u, entries in self._config_dict.items(): - for e in ['account', 'budget', 'token', 'flag']: - if e not in entries.keys(): - raise ConfigNotValid(f"'{e}' missing for user '{u}'") - - def load(self, user) -> User: - - try: - user_dict = self._config_dict[user] - c = BaseClient(token=user_dict['token'], user_name=user) - account = c.fetch_account(budget_id=user_dict['budget'], account_id=user_dict['account']) - return User(name=user, token=user_dict['token'], account=account, flag=user_dict['flag']) - - except KeyError: - raise UserNotFound(f"{user} not found in config") - - def load_partner(self, user: str) -> User: - partner = next(k for k in self._config_dict.keys() if k != user) - return self.load(user=partner) From 7e09658f317fb17b2735be0ac68c7f5df5ad6119 Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Wed, 1 May 2024 08:12:27 +0200 Subject: [PATCH 12/28] deleted config and fileloader --- ..._fileloader.py => test_ynabsplitbudget.py} | 11 ++++++----- ynabsplitbudget/fileloader.py | 14 -------------- ynabsplitbudget/models/config.py | 19 ------------------- ynabsplitbudget/ynabsplitbudget.py | 13 ++++++++----- 4 files changed, 14 insertions(+), 43 deletions(-) rename tests/{test_fileloader.py => test_ynabsplitbudget.py} (54%) delete mode 100644 ynabsplitbudget/fileloader.py delete mode 100644 ynabsplitbudget/models/config.py diff --git a/tests/test_fileloader.py b/tests/test_ynabsplitbudget.py similarity index 54% rename from tests/test_fileloader.py rename to tests/test_ynabsplitbudget.py index c2ccdd3..5cb9dc6 100644 --- a/tests/test_fileloader.py +++ b/tests/test_ynabsplitbudget.py @@ -4,17 +4,18 @@ import pytest from yaml.parser import ParserError -from ynabsplitbudget.fileloader import FileLoader +from ynabsplitbudget import YnabSplitBudget -def test_load_file_not_found(): +def test_from_yaml_file_not_found(): + with pytest.raises(FileNotFoundError): - ul = FileLoader(path='/test/path/config.yaml').load() + YnabSplitBudget.from_yaml(path='/test/path/config.yaml') -@patch('ynabsplitbudget.fileloader.Path.open') +@patch('ynabsplitbudget.ynabsplitbudget.Path.open') def test_user_loader_wrong_file_format(mock_config, mock_config_dict): mock_config.return_value = StringIO('{{xxx') with pytest.raises(ParserError): - ul = FileLoader(path='/test/path/config.yaml').load() + YnabSplitBudget.from_yaml(path='/test/path/config.yaml') diff --git a/ynabsplitbudget/fileloader.py b/ynabsplitbudget/fileloader.py deleted file mode 100644 index cba3db6..0000000 --- a/ynabsplitbudget/fileloader.py +++ /dev/null @@ -1,14 +0,0 @@ -from pathlib import Path - -import yaml - - -class FileLoader: - - def __init__(self, path: str): - self._path = path - - def load(self) -> dict: - with Path(self._path).open(mode='r') as f: - config_dict = yaml.safe_load(f) - return config_dict diff --git a/ynabsplitbudget/models/config.py b/ynabsplitbudget/models/config.py deleted file mode 100644 index 92d7fd7..0000000 --- a/ynabsplitbudget/models/config.py +++ /dev/null @@ -1,19 +0,0 @@ -from dataclasses import dataclass - -from ynabsplitbudget.models.user import User - - -@dataclass -class Config: - """Configuration object to use for YnabSplitBudget instance - - :ivar user: User which to use for the instance - :ivar partner: Partner which to use for the instance - """ - user: User - partner: User - - @classmethod - def from_dict(cls, data: dict) -> 'Config': - """Creates a Config object from a dict.""" - return cls(user=User.from_dict(data['user']), partner=User.from_dict(data['partner'])) diff --git a/ynabsplitbudget/ynabsplitbudget.py b/ynabsplitbudget/ynabsplitbudget.py index 55c5d1d..7bea545 100644 --- a/ynabsplitbudget/ynabsplitbudget.py +++ b/ynabsplitbudget/ynabsplitbudget.py @@ -1,13 +1,13 @@ import logging from datetime import date, timedelta, datetime +from pathlib import Path from typing import List, Optional +import yaml from ynabtransactionadjuster import Credentials from ynabsplitbudget.adjusters import ReconcileAdjuster, SplitAdjuster, ClearAdjuster from ynabsplitbudget.client import Client -from ynabsplitbudget.fileloader import FileLoader -from ynabsplitbudget.models.config import Config from ynabsplitbudget.models.exception import BalancesDontMatch from ynabsplitbudget.models.transaction import ComplementTransaction from ynabsplitbudget.models.user import User @@ -34,9 +34,12 @@ def from_yaml(cls, path: str) -> 'YnabSplitBudget': :returns: instance of YnabSplitBudget class """ - config_dict = FileLoader(path).load() - config = Config.from_dict(config_dict) - return cls(user=config.user, partner=config.partner) + with Path(path).open(mode='r') as f: + config_dict = yaml.safe_load(f) + + user = User.from_dict(config_dict['user']) + partner = User.from_dict(config_dict['partner']) + return cls(user=user, partner=partner) def push(self, since: date = None) -> int: """Pushes transactions from user split account to partner split account. By default, considers transactions of From ce5445b333eb9b7902a9c3a8b3f80c65aa2aff49 Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Wed, 1 May 2024 08:30:45 +0200 Subject: [PATCH 13/28] updated tests --- tests/test_client.py | 90 ++++++++++++++------------------ tests/test_syncrepository.py | 12 ++--- tests/test_transactionbuilder.py | 3 +- 3 files changed, 47 insertions(+), 58 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index dfa772d..13502f6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,40 +1,45 @@ from datetime import date -from unittest.mock import patch, MagicMock, ANY +from unittest.mock import MagicMock, ANY import pytest from requests import Response -from ynabsplitbudget.client import BaseClient, SyncClient +from ynabsplitbudget.client import Client from ynabsplitbudget.models.account import Account from ynabsplitbudget.models.exception import BudgetNotFound, AccountNotFound from ynabsplitbudget.models.transaction import RootTransaction, LookupTransaction +@pytest.fixture +def mock_client(): + client = Client(token='', user_name='', budget_id='', account_id='') + client.session = MagicMock() + return client + + +def mock_response(data: dict) -> MagicMock: + mock_resp_obj = MagicMock(spec=Response) + mock_resp_obj.json.return_value = data + return mock_resp_obj + + @pytest.mark.parametrize('budget, account, expected', [('bullshit', 'bullshit', BudgetNotFound), ('sample_budget_id', 'bullshit', AccountNotFound)]) -@patch('ynabsplitbudget.client.requests.get') -def test_fetch_account_fails(mock_response, mock_budget, budget, account, expected): +def test_fetch_account_fails(mock_client, mock_budget, budget, account, expected): # Arrange - mock_resp_obj = MagicMock(spec=Response) - mock_resp_obj.json.return_value = {'data': {'budgets': [mock_budget]}} - mock_response.return_value = mock_resp_obj + mock_client.session.get.return_value = mock_response({'data': {'budgets': [mock_budget]}}) # Act with pytest.raises(expected): - c = BaseClient(token='', user_name='') - c.fetch_account(budget_id=budget, account_id=account) + mock_client.fetch_account(budget_id=budget, account_id=account) -@patch('ynabsplitbudget.client.requests.get') -def test_fetch_account_passes(mock_response, mock_budget): +def test_fetch_account_passes(mock_client, mock_budget): # Arrange - mock_resp_obj = MagicMock(spec=Response) - mock_resp_obj.json.return_value = {'data': {'budgets': [mock_budget]}} - mock_response.return_value = mock_resp_obj + mock_client.session.get.return_value = mock_response({'data': {'budgets': [mock_budget]}}) # Act - c = BaseClient(token='', user_name='') - a = c.fetch_account(budget_id='sample_budget_id', account_id='sample_account_id') + a = mock_client.fetch_account(budget_id='sample_budget_id', account_id='sample_account_id') # Assert assert isinstance(a, Account) @@ -46,16 +51,12 @@ def test_fetch_account_passes(mock_response, mock_budget): assert a.transfer_payee_id == 'sample_transfer_payee_id' -@patch('ynabsplitbudget.client.requests.get') -def test_fetch_new(mock_response, mock_transaction_dict): +def test_fetch_new(mock_client, mock_transaction_dict): # Arrange - mock_resp_obj = MagicMock() - mock_resp_obj.json.return_value = {'data': {'transactions': [mock_transaction_dict], - 'server_knowledge': 100}} - mock_response.return_value = mock_resp_obj + mock_client.session.get.return_value = mock_response({'data': {'transactions': [mock_transaction_dict], + 'server_knowledge': 100}}) # Act - c = SyncClient(MagicMock(account=MagicMock(account_id='sample_account'))) - r = c.fetch_roots(since=date(2024,1, 1)) + r = mock_client.fetch_roots(since=date(2024,1, 1)) # Assert t = r[0] @@ -69,56 +70,43 @@ def test_fetch_new(mock_response, mock_transaction_dict): assert t.transaction_date == date(2024, 1, 1) -@patch('ynabsplitbudget.client.requests.get') -def test_fetch_new_empty(mock_response, mock_transaction_dict): +def test_fetch_new_empty(mock_client, mock_transaction_dict): # Arrange - mock_resp_obj = MagicMock() - mock_resp_obj.json.return_value = {'data': {'transactions': [], - 'server_knowledge': 100}} - mock_response.return_value = mock_resp_obj + mock_client.session.get.return_value = mock_response({'data': {'transactions': [], + 'server_knowledge': 100}}) # Act - c = SyncClient(MagicMock()) - r = c.fetch_roots(since=date(2024, 1, 1)) + r = mock_client.fetch_roots(since=date(2024, 1, 1)) # Assert assert len(r) == 0 -@patch('ynabsplitbudget.client.requests.get') -def test_fetch_balance(mock_response): +def test_fetch_balance(mock_client): # Arrange - r = MagicMock() - r.json.return_value = {'data': {'account': {'cleared_balance': 100}}} - mock_response.return_value = r + mock_client.session.get.return_value = mock_response({'data': {'account': {'cleared_balance': 100}}}) # Act - - c = SyncClient(user=MagicMock()) - b = c.fetch_balance() + b = mock_client.fetch_balance() # Assert assert b == 100 -@patch('ynabsplitbudget.client.SyncClient._get') -def test_fetch_lookup_no_since(mock_response, mock_transaction_dict): +def test_fetch_lookup_no_since(mock_client, mock_transaction_dict): # Arrange - mock_response.return_value = {'transactions': [mock_transaction_dict]} + mock_client.session.get.return_value = mock_response({'data': {'transactions': [mock_transaction_dict]}}) # Act - c = SyncClient(MagicMock()) - r = c.fetch_lookup(since=date(2024, 1, 1)) + r = mock_client.fetch_lookup(since=date(2024, 1, 1)) assert len(r) == 1 assert isinstance(r[0], LookupTransaction) -@patch('ynabsplitbudget.client.SyncClient._get') -def test_fetch_lookup_since(mock_get, mock_transaction_dict): +def test_fetch_lookup_since(mock_client, mock_transaction_dict): # Arrange - mock_get.return_value = {'transactions': [mock_transaction_dict]} + mock_client.session.get.return_value = mock_response({'data': {'transactions': [mock_transaction_dict]}}) # Act - c = SyncClient(MagicMock()) - c.fetch_lookup(since=date(2024, 1, 1)) + mock_client.fetch_lookup(since=date(2024, 1, 1)) - mock_get.assert_called_with(ANY, params={'since_date': '2024-01-01'}) + mock_client.session.get.assert_called_with(ANY, params={'since_date': '2024-01-01'}) diff --git a/tests/test_syncrepository.py b/tests/test_syncrepository.py index a366e13..fbd1f4a 100644 --- a/tests/test_syncrepository.py +++ b/tests/test_syncrepository.py @@ -5,8 +5,8 @@ from ynabsplitbudget.syncrepository import SyncRepository -@patch('ynabsplitbudget.client.SyncClient.fetch_roots') -@patch('ynabsplitbudget.client.SyncClient.fetch_lookup') +@patch('ynabsplitbudget.client.Client.fetch_roots') +@patch('ynabsplitbudget.client.Client.fetch_lookup') def test_fetch_new_to_insert_new(mock_lookup, mock_changed): # Arrange mock_transaction = MagicMock(spec=RootTransaction, transaction_date=date(2023, 10, 1), id='id', @@ -21,8 +21,8 @@ def test_fetch_new_to_insert_new(mock_lookup, mock_changed): assert isinstance(t[0], RootTransaction) -@patch('ynabsplitbudget.client.SyncClient.fetch_roots') -@patch('ynabsplitbudget.client.SyncClient.fetch_lookup') +@patch('ynabsplitbudget.client.Client.fetch_roots') +@patch('ynabsplitbudget.client.Client.fetch_lookup') def test_fetch_new_to_insert_not_new(mock_lookup, mock_changed): # Arrange mock_transaction = MagicMock(spec=RootTransaction, transaction_date=date(2023, 10, 1), id='id', @@ -37,8 +37,8 @@ def test_fetch_new_to_insert_not_new(mock_lookup, mock_changed): assert len(t) == 0 -@patch('ynabsplitbudget.client.SyncClient.fetch_roots') -@patch('ynabsplitbudget.client.SyncClient.fetch_lookup') +@patch('ynabsplitbudget.client.Client.fetch_roots') +@patch('ynabsplitbudget.client.Client.fetch_lookup') def test_fetch_new_to_insert_empty(mock_lookup, mock_changed): # Arrange mock_changed.return_value = [] diff --git a/tests/test_transactionbuilder.py b/tests/test_transactionbuilder.py index d14f2cd..1bd2a30 100644 --- a/tests/test_transactionbuilder.py +++ b/tests/test_transactionbuilder.py @@ -5,10 +5,11 @@ from ynabsplitbudget.models.transaction import ComplementTransaction from ynabsplitbudget.transactionbuilder import TransactionBuilder + @pytest.mark.parametrize('test_input, expected', [('s||xxx||1', 1), ('s||xxx', 0)] ) def test_build_complement_import_ids(test_input, expected, mock_transaction_dict): # Arrange - tb = TransactionBuilder(user=MagicMock()) + tb = TransactionBuilder(account_id='') mock_transaction_dict['import_id'] = test_input # Act From 4487889de3815e893492086e5aea0e8fd15d2da0 Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Wed, 1 May 2024 08:40:32 +0200 Subject: [PATCH 14/28] renamed clear attribute, changed return value from split to actual transactions --- ynabsplitbudget/ynabsplitbudget.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/ynabsplitbudget/ynabsplitbudget.py b/ynabsplitbudget/ynabsplitbudget.py index 7bea545..93a9615 100644 --- a/ynabsplitbudget/ynabsplitbudget.py +++ b/ynabsplitbudget/ynabsplitbudget.py @@ -4,7 +4,7 @@ from typing import List, Optional import yaml -from ynabtransactionadjuster import Credentials +from ynabtransactionadjuster import Credentials, Transaction from ynabsplitbudget.adjusters import ReconcileAdjuster, SplitAdjuster, ClearAdjuster from ynabsplitbudget.client import Client @@ -55,12 +55,12 @@ def push(self, since: date = None) -> int: logging.getLogger(__name__).info(f'inserted {len(transactions)} complements into account of {self.partner.name}') return len(transactions) - def split(self, clear_splits: bool = False) -> int: + def split(self, clear: bool = False) -> List[Transaction]: """Splits transactions (by default 50%) into subtransaction with original category and transfer subtransaction to split account - :param clear_splits: If set to true transactions in split account will automatically be set to cleared - :return: count of split transactions + :param clear: If set to true transactions in split account will automatically be set to cleared + :return: list with split transactions """ creds = Credentials(token=self.user.token, budget=self.user.budget_id) s = SplitAdjuster(creds, flag_color=self.user.flag_color, transfer_payee_id=self.user.fetch_account().transfer_payee_id, @@ -69,7 +69,7 @@ def split(self, clear_splits: bool = False) -> int: updated_transactions = s.update(mod_trans) logging.getLogger(__name__).info(f'split {len(updated_transactions)} transactions for {self.user.name}') - if clear_splits: + if clear: transfer_transaction_ids = [st.transfer_transaction_id for t in updated_transactions for st in t.subtransactions if st.transfer_transaction_id] creds = Credentials(token=self.user.token, budget=self.user.budget_id, @@ -79,7 +79,7 @@ def split(self, clear_splits: bool = False) -> int: count_clear = len(s.update(mod_trans_clear)) logging.getLogger(__name__).info(f'cleared {count_clear} transactions for {self.user.name}') - return len(updated_transactions) + return updated_transactions def raise_on_balances_off(self): """Evaluates cleared balances in both accounts @@ -101,7 +101,8 @@ def delete_orphans(self, since: date = None) -> List[ComplementTransaction]: """ since = self._substitute_default_since(since) orphaned_complements = SyncRepository(user=self.user, partner=self.partner).find_orphaned_partner_complements(since) - c = Client(self.partner) + c = Client(user_name=self.partner.name, budget_id=self.partner.budget_id, account_id=self.partner.account_id, + token=self.partner.token) [c.delete_complement(oc.id) for oc in orphaned_complements] logging.getLogger(__name__).info(f'deleted {len(orphaned_complements)} orphaned complements in account of {self.partner.name}') logging.getLogger(__name__).info(orphaned_complements) From de03b3805424e887133007bda2c8f4189720ab34 Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Wed, 1 May 2024 08:41:24 +0200 Subject: [PATCH 15/28] updated documentation --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4e8aade..73879a1 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,8 @@ ynab_split_budget = YnabSplitBudget.from_yaml(path='path/to/config.yaml', user=' ### 3. Split transactions Call the `split()` method of the instance. It will split flagged transactions in the budget into a subtransaction with the original category and a transfer to the split account. By default, the transfer transactions will show up as -uncleared in the split account. The optional `clear_split` parameter allows to automatically clear the transactions in -the split account. The function returns the count of split transactions. +uncleared in the split account. The optional `clear` parameter allows to automatically clear the transactions in +the split account. The function returns the updated transactions after applying the split. ```py ynab_split_budget.split() ``` From bed4fd54f77667ea2b3ac4880764b3479a70e62a Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Wed, 1 May 2024 08:46:09 +0200 Subject: [PATCH 16/28] imported ComplementTransaction --- ynabsplitbudget/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ynabsplitbudget/__init__.py b/ynabsplitbudget/__init__.py index 4f809aa..21358ee 100644 --- a/ynabsplitbudget/__init__.py +++ b/ynabsplitbudget/__init__.py @@ -1,2 +1,3 @@ from ynabsplitbudget.ynabsplitbudget import YnabSplitBudget +from ynabsplitbudget.models.transaction import ComplementTransaction From 3f92f8ea3829ced49d265ce812a3ad4a2481f81d Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Wed, 1 May 2024 08:48:27 +0200 Subject: [PATCH 17/28] imported User --- ynabsplitbudget/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ynabsplitbudget/__init__.py b/ynabsplitbudget/__init__.py index 21358ee..f8971ea 100644 --- a/ynabsplitbudget/__init__.py +++ b/ynabsplitbudget/__init__.py @@ -1,3 +1,4 @@ from ynabsplitbudget.ynabsplitbudget import YnabSplitBudget from ynabsplitbudget.models.transaction import ComplementTransaction +from ynabsplitbudget.models.user import User From a11d36400fb34fb2dac00e9e95513000479f89ef Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Wed, 1 May 2024 08:59:25 +0200 Subject: [PATCH 18/28] refactored from yaml classmethod into user object --- tests/test_user.py | 21 +++++++++++++++++++++ tests/test_ynabsplitbudget.py | 17 ----------------- ynabsplitbudget/models/user.py | 16 ++++++++++++++++ ynabsplitbudget/ynabsplitbudget.py | 15 --------------- 4 files changed, 37 insertions(+), 32 deletions(-) create mode 100644 tests/test_user.py diff --git a/tests/test_user.py b/tests/test_user.py new file mode 100644 index 0000000..bfd1f55 --- /dev/null +++ b/tests/test_user.py @@ -0,0 +1,21 @@ +from io import StringIO +from unittest.mock import patch + +import pytest +from yaml.parser import ParserError + +from ynabsplitbudget import User + + +def test_from_yaml_file_not_found(): + + with pytest.raises(FileNotFoundError): + User.from_yaml(path='/test/path/config.yaml') + + +@patch('ynabsplitbudget.models.user.Path.open') +def test_user_loader_wrong_file_format(mock_config, mock_config_dict): + mock_config.return_value = StringIO('{{xxx') + + with pytest.raises(ParserError): + User.from_yaml(path='/test/path/config.yaml') diff --git a/tests/test_ynabsplitbudget.py b/tests/test_ynabsplitbudget.py index 5cb9dc6..fd40910 100644 --- a/tests/test_ynabsplitbudget.py +++ b/tests/test_ynabsplitbudget.py @@ -1,21 +1,4 @@ -from io import StringIO -from unittest.mock import patch -import pytest -from yaml.parser import ParserError -from ynabsplitbudget import YnabSplitBudget -def test_from_yaml_file_not_found(): - - with pytest.raises(FileNotFoundError): - YnabSplitBudget.from_yaml(path='/test/path/config.yaml') - - -@patch('ynabsplitbudget.ynabsplitbudget.Path.open') -def test_user_loader_wrong_file_format(mock_config, mock_config_dict): - mock_config.return_value = StringIO('{{xxx') - - with pytest.raises(ParserError): - YnabSplitBudget.from_yaml(path='/test/path/config.yaml') diff --git a/ynabsplitbudget/models/user.py b/ynabsplitbudget/models/user.py index b29b649..afcabf8 100644 --- a/ynabsplitbudget/models/user.py +++ b/ynabsplitbudget/models/user.py @@ -1,6 +1,9 @@ from dataclasses import dataclass +from pathlib import Path from typing import Literal +import yaml + from ynabsplitbudget.client import Client from ynabsplitbudget.models.account import Account @@ -34,3 +37,16 @@ def from_dict(cls, data: dict) -> 'User': """Creates user object from dict""" return cls(name=data['name'], token=data['token'], budget_id=data['budget_id'], account_id=data['account_id'], flag_color=data['flag_color']) + + @classmethod + def from_yaml(cls, path: str) -> 'User': + """Create instance by loading config from YAML file + + :param path: Path to the YAML file + + :returns: instance of YnabSplitBudget class + """ + with Path(path).open(mode='r') as f: + config_dict = yaml.safe_load(f) + + return cls.from_dict(config_dict) diff --git a/ynabsplitbudget/ynabsplitbudget.py b/ynabsplitbudget/ynabsplitbudget.py index 93a9615..d4e94df 100644 --- a/ynabsplitbudget/ynabsplitbudget.py +++ b/ynabsplitbudget/ynabsplitbudget.py @@ -26,21 +26,6 @@ def __init__(self, user: User, partner: User): self.partner = partner self.logger = self._set_up_logger() - @classmethod - def from_yaml(cls, path: str) -> 'YnabSplitBudget': - """Create instance by loading config from YAML file - - :param path: Path to the YAML file - - :returns: instance of YnabSplitBudget class - """ - with Path(path).open(mode='r') as f: - config_dict = yaml.safe_load(f) - - user = User.from_dict(config_dict['user']) - partner = User.from_dict(config_dict['partner']) - return cls(user=user, partner=partner) - def push(self, since: date = None) -> int: """Pushes transactions from user split account to partner split account. By default, considers transactions of last 30 days. From 25d7b67c3b2d50727538b9eedc3f220613cd32c5 Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Wed, 1 May 2024 08:59:41 +0200 Subject: [PATCH 19/28] updated readme --- README.md | 59 +++++++++++++------------------------------------------ 1 file changed, 14 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 73879a1..a1cfb99 100644 --- a/README.md +++ b/README.md @@ -19,59 +19,28 @@ account. ```bash pip install ynab-split-budget ``` +## Usage +### 1. Create a transaction +Create a transaction in the budget and add a specific flag in a specific color to be used for the splits. The +transaction needs to be cleared in order to be considered by this library. By default, the transaction will be split +in half, but you can specify a different split by adding `@x%` for percentage or `@x` for specific amount in the memo +of the transaction. The amount you specify in this split will be transferred to your sharing account. You can also +create a plain transfer to the shared account which will be completely allocated to the partner account. -## Create config -Create a config `dict` with the below structure. -```py -CONFIG = { - '': { - 'budget': '', - 'account': '', - 'token': '', - 'flag': ''}, - '': { - 'budget': '', - 'account': '', - 'token': '', - 'flag': ''} -} -``` +### 2. Initialize library You can find the ID of the budget and of the account if you go to https://app.ynab.com/ and open the target account by clicking on the name on the left hand side menu. The URL does now contain both IDs `https://app.ynab.com//accounts/` Possible colors for the flag value are `red`, `orange`, `yellow`, `green`, `blue` and `purple` - -Alternatively you can save the config in a yaml file with the below structure and provide the path to the library -when initializing -```yaml -: - token: - budget: - account: - flag: -: - token: - budget: - account: - flag: -``` - -## Usage -### 1. Create a transaction -Create a transaction in your budget and add the defined color flag. Only cleared transactions will be considered. -By default, the transaction will be split in half, but you can specify a different split by adding `@x%` for -percentage or `@x` for specific amount in the memo of the transaction. The amount you specify in this split will be -transferred to your sharing account. You can also create a plain transfer to the shared account which will be -completely allocated to the partner account. -### 2. Initialize library ```py -from ynabsplitbudget import YnabSplitBudget +from ynabsplitbudget import YnabSplitBudget, User -# initialize from config dict -ynab_split_budget = YnabSplitBudget(config=CONFIG, user='') -# or alternatively from yaml -ynab_split_budget = YnabSplitBudget.from_yaml(path='path/to/config.yaml', user='', token='', budget_id='', account_id='', token='', budget_id='', account_id=' Date: Wed, 1 May 2024 09:23:57 +0200 Subject: [PATCH 20/28] added error handling for yaml file --- ynabsplitbudget/models/user.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ynabsplitbudget/models/user.py b/ynabsplitbudget/models/user.py index afcabf8..688eddc 100644 --- a/ynabsplitbudget/models/user.py +++ b/ynabsplitbudget/models/user.py @@ -49,4 +49,7 @@ def from_yaml(cls, path: str) -> 'User': with Path(path).open(mode='r') as f: config_dict = yaml.safe_load(f) - return cls.from_dict(config_dict) + try: + return cls.from_dict(config_dict) + except KeyError as e: + raise ValueError(f'Could not load config from YAML file: {path}') from e From f3f5abd61e1c9c4364eeb9a23ef315ced557bc28 Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Wed, 1 May 2024 09:38:00 +0200 Subject: [PATCH 21/28] ignore all yaml files in repo --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 44e3ce6..72194d1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ .idea .DS_Store env*/ -config.yaml +*.yaml dist/ live.py .pytest_cache/ From 6e94454602c5e1f5a5bfb9ffb1a0f2c36cca2b7a Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Wed, 1 May 2024 09:38:45 +0200 Subject: [PATCH 22/28] refactored bash interface to comply with new module structure --- README.md | 10 +++--- ynabsplitbudget/__main__.py | 63 ++++++++++++++++++++++++------------- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index a1cfb99..4dddda6 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,11 @@ import logging logging.basicConfig(level='INFO') ``` ### Run via bash commands -You can run this package also from bash with the following commands +You can run this library also from bash with the following basic structure ```bash -$ python -m ynabsplitbudget -c -s | --split-transactions -$ python -m ynabsplitbudget -c -i | --insert-complements [-d | --since-date "YYYY-mm-dd"] -$ python -m ynabsplitbudget -c -b | --check-balances +$ python -m ynabsplitbudget -u -p --split +``` +For a complete list of available bash options please use +```bash +$ python -m ynabsplitbudget -h | -- help ``` diff --git a/ynabsplitbudget/__main__.py b/ynabsplitbudget/__main__.py index 19a07eb..01d3185 100644 --- a/ynabsplitbudget/__main__.py +++ b/ynabsplitbudget/__main__.py @@ -4,6 +4,7 @@ import logging from datetime import datetime +from ynabsplitbudget import User from ynabsplitbudget.ynabsplitbudget import YnabSplitBudget logging.basicConfig(level=logging.INFO) @@ -14,36 +15,56 @@ def custom_warn(message, category, filename, lineno, file=None, line=None): if __name__ == '__main__': - # execute only if run as the entry point into the program - parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter, - usage='ynabsplitaccount [-c | --config] ' - '[-s | --split-transactions] [-i | --insert-complements] ' - '[-d | --since-date "YYYY-mm-dd"]') - parser.add_argument("-c", "--config", type=str, help="path of config YAML to use and user", required=True) - parser.add_argument("-s", "--split-transactions", action="store_true", + usage='ynabsplitbudget [-u | --user] ' + '[-p | --partner] ' + '[-s | --split] ' + '[-i | --push] ' + '[-b | --balances]' + '[-d | --delete-orphans]' + '[-r | --reconcile]' + '[--since "YYYY-mm-dd"]') + parser.add_argument("-u", "--user", type=str, required=True, + help="path of config YAML to use for user") + parser.add_argument("-p", "--partner", type=str, required=True, + help="path of config YAML to use for partner") + parser.add_argument("-s", "--split", action="store_true", help="split transactions from account") - parser.add_argument("-i", "--insert-complements", action="store_true", - help="fetch new transactions from both accounts and insert complements") - parser.add_argument("-b", "--check-balances", action="store_true", + parser.add_argument("-i", "--push", action="store_true", + help="push split transactions to partner account") + parser.add_argument("-b", "--balances", action="store_true", help="raise error if balances of the two accounts don't match") - parser.add_argument("-d", "--since-date", action="store_true", - help='provide date since which insert function should compare transactions in the format of "YYYY-mm-dd"') + parser.add_argument("-d", "--delete-orphans", action="store_true", + help="deletes orphaned transactions in partner account") + parser.add_argument("-r", "--reconcile", action="store_true", + help="reconciles account if balance matches with partner account") + parser.add_argument("--since", type=str, + help='provide optional date if library should use something else than 30 days default') + args = parser.parse_args() warnings.showwarning = custom_warn - path, user_name = args.config.split('#') - ysb = YnabSplitBudget.from_yaml(path=path, user=user_name) + user = User.from_yaml(args.user) + partner = User.from_yaml(args.partner) + ysb = YnabSplitBudget(user=user, partner=partner) + + if args.since: + try: + since = datetime.strptime(args.since, "%Y-%m-%d") + except ValueError as e: + raise ValueError(f"Incorrect date format {args.since}, should be YYYY-mm-dd") from e + else: + since = None - if args.split_transactions: + if args.split: ysb.split() - if args.insert_complements: - if args.since_date: - since = datetime.strptime(args.since_date, "%Y-%m-%d") - else: - since = None + if args.push: ysb.push(since=since) - if args.check_balances: + if args.delete_orphans: + ysb.delete_orphans(since=since) + if args.balances or args.reconcile: ysb.raise_on_balances_off() + if args.reconcile: + ysb.reconcile() From d0ef647bbb94d2c8090ecb58d3713f7069e3c6b3 Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Thu, 2 May 2024 06:26:19 +0200 Subject: [PATCH 23/28] clean up --- ynabsplitbudget/ynabsplitbudget.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ynabsplitbudget/ynabsplitbudget.py b/ynabsplitbudget/ynabsplitbudget.py index d4e94df..e1eb1a3 100644 --- a/ynabsplitbudget/ynabsplitbudget.py +++ b/ynabsplitbudget/ynabsplitbudget.py @@ -1,9 +1,7 @@ import logging from datetime import date, timedelta, datetime -from pathlib import Path from typing import List, Optional -import yaml from ynabtransactionadjuster import Credentials, Transaction from ynabsplitbudget.adjusters import ReconcileAdjuster, SplitAdjuster, ClearAdjuster @@ -48,7 +46,8 @@ def split(self, clear: bool = False) -> List[Transaction]: :return: list with split transactions """ creds = Credentials(token=self.user.token, budget=self.user.budget_id) - s = SplitAdjuster(creds, flag_color=self.user.flag_color, transfer_payee_id=self.user.fetch_account().transfer_payee_id, + s = SplitAdjuster(creds, flag_color=self.user.flag_color, + transfer_payee_id=self.user.fetch_account().transfer_payee_id, account_id=self.user.account_id) mod_trans = s.apply() updated_transactions = s.update(mod_trans) From 3dfa25fee5c4a9191aff876acf7e34286a654ffb Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Thu, 2 May 2024 06:29:44 +0200 Subject: [PATCH 24/28] updated documentation --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4dddda6..e7c30fa 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,10 @@ Possible colors for the flag value are `red`, `orange`, `yellow`, `green`, `blue ```py from ynabsplitbudget import YnabSplitBudget, User -user = User(name='', token='', budget_id='', account_id='', token='', budget_id='', account_id='', token='', budget_id='', + account_id='', token='', budget_id='', + account_id=' Date: Thu, 2 May 2024 06:30:48 +0200 Subject: [PATCH 25/28] updated documentation --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e7c30fa..609c894 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ from ynabsplitbudget import YnabSplitBudget, User user = User(name='', token='', budget_id='', account_id='', token='', budget_id='', account_id=' -p --split From 744dd5dc758a2d849534def1226dfe0f82bcd02d Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Thu, 2 May 2024 06:41:10 +0200 Subject: [PATCH 26/28] made reconcile function check balances --- README.md | 18 ++++++++++++------ ynabsplitbudget/ynabsplitbudget.py | 2 ++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 609c894..49040f8 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ user = User(name='', token='', budget_id='', partner = User(name='', token='', budget_id='', account_id=' int: """Reconciles cleared transactions in the current account :returns: count of reconciled transactions + :raises BalancesDontMatch: if cleared amounts in both accounts don't match """ + self.raise_on_balances_off() creds = Credentials(token=self.user.token, budget=self.user.budget_id, account=self.user.account_id) ra = ReconcileAdjuster(creds) From 036aebe9a0a60e855d6c2e3d0471f6cb8f881f52 Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Thu, 2 May 2024 07:56:11 +0200 Subject: [PATCH 27/28] made push function return inserted complement transactions --- tests/test_client.py | 54 ++++++++++++++++++++++++++---- ynabsplitbudget/client.py | 12 +++---- ynabsplitbudget/syncrepository.py | 4 +-- ynabsplitbudget/ynabsplitbudget.py | 11 +++--- 4 files changed, 61 insertions(+), 20 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 13502f6..7976b11 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,18 +1,18 @@ -from datetime import date +from datetime import date, datetime from unittest.mock import MagicMock, ANY import pytest -from requests import Response +from requests import Response, HTTPError from ynabsplitbudget.client import Client from ynabsplitbudget.models.account import Account from ynabsplitbudget.models.exception import BudgetNotFound, AccountNotFound -from ynabsplitbudget.models.transaction import RootTransaction, LookupTransaction +from ynabsplitbudget.models.transaction import RootTransaction, LookupTransaction, ComplementTransaction @pytest.fixture def mock_client(): - client = Client(token='', user_name='', budget_id='', account_id='') + client = Client(token='', user_name='', budget_id='', account_id='account_id') client.session = MagicMock() return client @@ -54,9 +54,9 @@ def test_fetch_account_passes(mock_client, mock_budget): def test_fetch_new(mock_client, mock_transaction_dict): # Arrange mock_client.session.get.return_value = mock_response({'data': {'transactions': [mock_transaction_dict], - 'server_knowledge': 100}}) + 'server_knowledge': 100}}) # Act - r = mock_client.fetch_roots(since=date(2024,1, 1)) + r = mock_client.fetch_roots(since=date(2024, 1, 1)) # Assert t = r[0] @@ -73,7 +73,7 @@ def test_fetch_new(mock_client, mock_transaction_dict): def test_fetch_new_empty(mock_client, mock_transaction_dict): # Arrange mock_client.session.get.return_value = mock_response({'data': {'transactions': [], - 'server_knowledge': 100}}) + 'server_knowledge': 100}}) # Act r = mock_client.fetch_roots(since=date(2024, 1, 1)) @@ -110,3 +110,43 @@ def test_fetch_lookup_since(mock_client, mock_transaction_dict): mock_client.fetch_lookup(since=date(2024, 1, 1)) mock_client.session.get.assert_called_with(ANY, params={'since_date': '2024-01-01'}) + + +def test_insert_complement(mock_client, mock_transaction_dict): + # Arrange + mock_root = RootTransaction(id='id', share_id='share_id', account_id='account_id_o', + transaction_date=date(2024, 1, 1), memo='memo', payee_name='payee_name', amount=1000) + mock_transaction_dict['import_id'] = 's||xxx' + mock_client.session.post.return_value = mock_response({'data': {'transaction': mock_transaction_dict}}) + + # Act + c = mock_client.insert_complement(mock_root) + + # Assert + mock_client.session.post.assert_called_once() + post_dict = mock_client.session.post.call_args_list[0][1]['json']['transaction'] + assert post_dict['account_id'] == mock_client.account_id + assert post_dict['date'] == datetime.strftime(mock_root.transaction_date, '%Y-%m-%d') + assert post_dict['amount'] == - mock_root.amount + assert post_dict['payee_name'] == mock_root.payee_name + assert post_dict['memo'] == mock_root.memo + assert post_dict['import_id'] == f's||{mock_root.share_id}||0' + + assert isinstance(c, ComplementTransaction) + + +def test_insert_complement_iteration(mock_client, mock_transaction_dict): + # Arrange + mock_root = RootTransaction(id='id', share_id='share_id', account_id='account_id_o', + transaction_date=date(2024, 1, 1), memo='memo', payee_name='payee_name', amount=1000) + mock_transaction_dict['import_id'] = 's||xxx' + mock_client.session.post.side_effect = [HTTPError(response=MagicMock(status_code=409)), + mock_response({'data': {'transaction': mock_transaction_dict}})] + + # Act + c = mock_client.insert_complement(mock_root) + + # Assert + assert mock_client.session.post.call_count == 2 + post_dict = mock_client.session.post.call_args_list[1][1]['json']['transaction'] + assert post_dict['import_id'] == f's||{mock_root.share_id}||1' diff --git a/ynabsplitbudget/client.py b/ynabsplitbudget/client.py index 3e45189..5e4be35 100644 --- a/ynabsplitbudget/client.py +++ b/ynabsplitbudget/client.py @@ -67,18 +67,16 @@ def fetch_lookup(self, since: date) -> List[Union[RootTransaction, LookupTransac transactions = [self.transaction_builder.build(t_dict=t) for t in data_dict] return transactions - def insert_complement(self, t: RootTransaction): + def insert_complement(self, t: RootTransaction) -> ComplementTransaction: iteration = 0 - try_again = True - while try_again: + while True: try: - self._insert(t, iteration=iteration) - try_again = False + return self._insert(t, iteration=iteration) except HTTPError as e: if e.response.status_code == 409: iteration += 1 - def _insert(self, t: RootTransaction, iteration: int): + def _insert(self, t: RootTransaction, iteration: int) -> ComplementTransaction: url = f'{YNAB_BASE_URL}budgets/{self.budget_id}/transactions' data = {'transaction': { "account_id": self.account_id, @@ -92,6 +90,8 @@ def _insert(self, t: RootTransaction, iteration: int): }} r = self.session.post(url, json=data) r.raise_for_status() + complement = self.transaction_builder.build_complement(r.json()['data']['transaction']) + return complement def fetch_balance(self) -> int: url = f'{YNAB_BASE_URL}budgets/{self.budget_id}/accounts/{self.account_id}' diff --git a/ynabsplitbudget/syncrepository.py b/ynabsplitbudget/syncrepository.py index b16ea5e..33a0b06 100644 --- a/ynabsplitbudget/syncrepository.py +++ b/ynabsplitbudget/syncrepository.py @@ -22,8 +22,8 @@ def fetch_roots_wo_complement(self, since: date) -> List[RootTransaction]: lookup_date=since) return transactions_replaced_payee - def insert_complements(self, transactions: List[RootTransaction]): - [self._partner_client.insert_complement(t) for t in transactions] + def insert_complements(self, transactions: List[RootTransaction]) -> List[ComplementTransaction]: + return [self._partner_client.insert_complement(t) for t in transactions] def replace_payee(self, transactions: List[RootTransaction], lookup_date: date) -> List[RootTransaction]: ul = self._user_client.fetch_lookup(lookup_date) diff --git a/ynabsplitbudget/ynabsplitbudget.py b/ynabsplitbudget/ynabsplitbudget.py index 1521fe9..8368807 100644 --- a/ynabsplitbudget/ynabsplitbudget.py +++ b/ynabsplitbudget/ynabsplitbudget.py @@ -7,7 +7,7 @@ from ynabsplitbudget.adjusters import ReconcileAdjuster, SplitAdjuster, ClearAdjuster from ynabsplitbudget.client import Client from ynabsplitbudget.models.exception import BalancesDontMatch -from ynabsplitbudget.models.transaction import ComplementTransaction +from ynabsplitbudget.models.transaction import ComplementTransaction, RootTransaction from ynabsplitbudget.models.user import User from ynabsplitbudget.syncrepository import SyncRepository @@ -24,7 +24,7 @@ def __init__(self, user: User, partner: User): self.partner = partner self.logger = self._set_up_logger() - def push(self, since: date = None) -> int: + def push(self, since: date = None) -> List[ComplementTransaction]: """Pushes transactions from user split account to partner split account. By default, considers transactions of last 30 days. @@ -34,9 +34,10 @@ def push(self, since: date = None) -> int: repo = SyncRepository(user=self.user, partner=self.partner) transactions = repo.fetch_roots_wo_complement(since=since) - repo.insert_complements(transactions) - logging.getLogger(__name__).info(f'inserted {len(transactions)} complements into account of {self.partner.name}') - return len(transactions) + complement_transactions = repo.insert_complements(transactions) + logging.getLogger(__name__).info(f'inserted {len(complement_transactions)} complements into account of ' + f'{self.partner.name}') + return complement_transactions def split(self, clear: bool = False) -> List[Transaction]: """Splits transactions (by default 50%) into subtransaction with original category and transfer subtransaction From fdc697d4d0d90f8451506f46c07f0154d0277775 Mon Sep 17 00:00:00 2001 From: Daniel Basta Date: Thu, 2 May 2024 07:57:17 +0200 Subject: [PATCH 28/28] updated docstring --- ynabsplitbudget/ynabsplitbudget.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ynabsplitbudget/ynabsplitbudget.py b/ynabsplitbudget/ynabsplitbudget.py index 8368807..5722bf0 100644 --- a/ynabsplitbudget/ynabsplitbudget.py +++ b/ynabsplitbudget/ynabsplitbudget.py @@ -29,6 +29,7 @@ def push(self, since: date = None) -> List[ComplementTransaction]: last 30 days. :param since: If set to date, will push transactions from that date onwards instead of default 30 days + :return: List of inserted transactions in partner split account """ since = self._substitute_default_since(since) repo = SyncRepository(user=self.user, partner=self.partner)