From fa33428c7b8e1954991de434f65717045087be97 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sat, 20 Apr 2024 08:12:28 +0200 Subject: [PATCH 01/39] refactored function calls to module level --- docs/detailed_usage.md | 84 ++++++++-------- ynabtransactionadjuster/__init__.py | 5 +- ynabtransactionadjuster/adjusterbase.py | 59 ++++++++++++ ynabtransactionadjuster/client.py | 6 +- ynabtransactionadjuster/functions.py | 72 ++++++++++++++ ynabtransactionadjuster/models/credentials.py | 14 +++ .../{adjuster.py => serializer.py} | 2 +- .../ynabtransactionadjuster.py | 95 ------------------- 8 files changed, 197 insertions(+), 140 deletions(-) create mode 100644 ynabtransactionadjuster/adjusterbase.py create mode 100644 ynabtransactionadjuster/functions.py create mode 100644 ynabtransactionadjuster/models/credentials.py rename ynabtransactionadjuster/{adjuster.py => serializer.py} (99%) delete mode 100644 ynabtransactionadjuster/ynabtransactionadjuster.py diff --git a/docs/detailed_usage.md b/docs/detailed_usage.md index 2974807..8ae0af4 100644 --- a/docs/detailed_usage.md +++ b/docs/detailed_usage.md @@ -7,21 +7,21 @@ recommended to ensure you only assign valid categories to the modifier. The libr categories and specifying a non-existing category will raise an error. ```py -from ynabtransactionadjuster import YnabTransactionAdjuster +from ynabtransactionadjuster import AdjusterBase -class MyAdjusterFactory(YnabTransactionAdjuster): - - def filter(self, transactions): - return transactions - - def adjust(self, original, modifier): - my_category = self.categories.fetch_by_name('my_category') - # or alternatively - my_category = self.categories.fetch_by_id('category_id') - modifier.category = my_category +class MyAdjusterFactory(AdjusterBase): - return modifier + def filter(self, transactions): + return transactions + + def adjust(self, original, modifier): + my_category = self.categories.fetch_by_name('my_category') + # or alternatively + my_category = self.categories.fetch_by_id('category_id') + modifier.category = my_category + + return modifier ``` The [`CategoryRepo`][repos.CategoryRepo] instance gets build when the adjuster gets initialized and can also be accessed from the main instance (e.g. for finding category ids to be used in the parser later). The `fetch_all()` method fetches @@ -39,26 +39,26 @@ called with `fetch_by_transfer_account_id()` to fetch a transfer payee. You can account following the method mentioned in the [preparations](#preparations) section. ```py -from ynabtransactionadjuster import YnabTransactionAdjuster +from ynabtransactionadjuster import AdjusterBase from ynabtransactionadjuster.models import Payee -class MyAdjuster(YnabTransactionAdjuster): - - def filter(self, transactions): - return transactions - - def adjust(self, original, modifier): - my_payee = Payee(name='My Payee') - # or - my_payee = self.payees.fetch_by_name('My Payee') - # or - my_payee = self.payees.fetch_by_id('payee_id') - # or for transfers - my_payee = self.payees.fetch_by_transfer_account_id('transfer_account_id') - modifier.payee = my_payee - - return modifier +class MyAdjuster(AdjusterBase): + + def filter(self, transactions): + return transactions + + def adjust(self, original, modifier): + my_payee = Payee(name='My Payee') + # or + my_payee = self.payees.fetch_by_name('My Payee') + # or + my_payee = self.payees.fetch_by_id('payee_id') + # or for transfers + my_payee = self.payees.fetch_by_transfer_account_id('transfer_account_id') + modifier.payee = my_payee + + return modifier ``` The [`PayeeRepo`][repos.PayeeRepo] instance gets build when the adjuster gets initialized and can also be accessed from the main instance. The `fetch_all()` method fetches all payees in the budget. @@ -75,24 +75,24 @@ There must be at least two subtransactions and the sum of their amounts must be transaction. ```py -from ynabtransactionadjuster import YnabTransactionAdjuster +from ynabtransactionadjuster import AdjusterBase from ynabtransactionadjuster.models import SubTransaction -class MyAdjuster(YnabTransactionAdjuster): - - def filter(self, transactions): - return transactions - - def adjust(self, original, modifier): - # example for splitting a transaction in two equal amount subtransactions with different categories - subtransaction_1 = SubTransaction(amount=original.amount / 2, - category=original.category) - subtransaction_2 = SubTransaction(amount=original.amount / 2, +class MyAdjuster(AdjusterBase): + + def filter(self, transactions): + return transactions + + def adjust(self, original, modifier): + # example for splitting a transaction in two equal amount subtransactions with different categories + subtransaction_1 = SubTransaction(amount=original.amount / 2, + category=original.category) + subtransaction_2 = SubTransaction(amount=original.amount / 2, category=self.categories.fetch_by_name('My 2nd Category')) - modifier.subtransactions = [subtransaction_1, subtransaction_2] + modifier.subtransactions = [subtransaction_1, subtransaction_2] - return modifier + return modifier ``` diff --git a/ynabtransactionadjuster/__init__.py b/ynabtransactionadjuster/__init__.py index ab61083..e06f0e0 100644 --- a/ynabtransactionadjuster/__init__.py +++ b/ynabtransactionadjuster/__init__.py @@ -1 +1,4 @@ -from ynabtransactionadjuster.ynabtransactionadjuster import YnabTransactionAdjuster +from ynabtransactionadjuster.adjusterbase import AdjusterBase +from ynabtransactionadjuster.repos import CategoryRepo, PayeeRepo +from ynabtransactionadjuster.models.credentials import Credentials +from ynabtransactionadjuster.functions import fetch_payees, fetch_categories, run_adjuster, test_adjuster diff --git a/ynabtransactionadjuster/adjusterbase.py b/ynabtransactionadjuster/adjusterbase.py new file mode 100644 index 0000000..c782cc0 --- /dev/null +++ b/ynabtransactionadjuster/adjusterbase.py @@ -0,0 +1,59 @@ +from abc import abstractmethod +from typing import List + +from ynabtransactionadjuster.models.credentials import Credentials +from ynabtransactionadjuster.client import Client +from ynabtransactionadjuster.models import OriginalTransaction +from ynabtransactionadjuster.models import TransactionModifier +from ynabtransactionadjuster.repos import CategoryRepo +from ynabtransactionadjuster.repos import PayeeRepo + + +class AdjusterBase: + """Abstract class which modifies transactions according to concrete implementation. You need to create your own + child class and implement the `filter()`and `adjust()` method in it according to your needs. It has attributes + which allow you to lookup categories and payees from your budget. + + :param budget: The YNAB budget id to use + :param account: The YNAB account id to use + :param token: The YNAB token to use + + :ivar categories: Collection of current categories in YNAB budget + :ivar payees: Collection of current payees in YNAB budget + """ + def __init__(self, categories: CategoryRepo, payees: PayeeRepo) -> None: + self.categories = categories + self.payees = payees + + @classmethod + def from_credentials(cls, credentials: Credentials): + """Instantiate a Adjuster class from a Credentials object + + :param credentials: Credentials to use for YNAB API + """ + client = Client.from_credentials(credentials=credentials) + categories = CategoryRepo(client.fetch_categories()) + payees = PayeeRepo(client.fetch_payees()) + return cls(categories=categories, payees=payees) + + @abstractmethod + def filter(self, transactions: List[OriginalTransaction]) -> List[OriginalTransaction]: + """Function which implements filtering for the list of transactions from YNAB account. It receives a list of + the original transactions which can be filtered. Must return the filtered list or just the list if no filtering + is intended. + + :param transactions: List of original transactions from YNAB + :return: Method needs to return a list of filtered transactions""" + pass + + @abstractmethod + def adjust(self, original: OriginalTransaction, modifier: TransactionModifier) -> TransactionModifier: + """Function which implements the actual modification of a transaction. It receives the original transaction from + YNAB and a prefilled modifier. The modifier can be altered and must be returned. + + :param original: Original transaction + :param modifier: Transaction modifier prefilled with values from original transaction. All attributes can be + changed and will modify the transaction + :returns: Method needs to return the transaction modifier after modification + """ + pass diff --git a/ynabtransactionadjuster/client.py b/ynabtransactionadjuster/client.py index fd8af82..cee83ae 100644 --- a/ynabtransactionadjuster/client.py +++ b/ynabtransactionadjuster/client.py @@ -6,7 +6,7 @@ from ynabtransactionadjuster.models import CategoryGroup, ModifiedTransaction from ynabtransactionadjuster.models import OriginalTransaction from ynabtransactionadjuster.models import Payee - +from ynabtransactionadjuster.models.credentials import Credentials YNAB_BASE_URL = 'https://api.ynab.com/v1' @@ -24,6 +24,10 @@ def __init__(self, token: str, budget: str, account: str): self._budget = budget self._account = account + @classmethod + def from_credentials(cls, credentials: Credentials): + return cls(token=credentials.token, budget=credentials.budget, account=credentials.account) + def fetch_categories(self) -> List[CategoryGroup]: """Fetches categories from YNAB""" r = requests.get(f'{YNAB_BASE_URL}/budgets/{self._budget}/categories', headers=self._header) diff --git a/ynabtransactionadjuster/functions.py b/ynabtransactionadjuster/functions.py new file mode 100644 index 0000000..589a764 --- /dev/null +++ b/ynabtransactionadjuster/functions.py @@ -0,0 +1,72 @@ +from typing import List + +from ynabtransactionadjuster.adjusterbase import AdjusterBase +from ynabtransactionadjuster.client import Client +from ynabtransactionadjuster.models.credentials import Credentials +from ynabtransactionadjuster.repos import CategoryRepo, PayeeRepo +from ynabtransactionadjuster.serializer import Serializer + + +def fetch_categories(credentials: Credentials) -> CategoryRepo: + """Fetches categories from YNAB budget. + + :param credentials: Credentials to use for YNAB API + + :return: Collection of categories from YNAB budget + """ + client = Client.from_credentials(credentials=credentials) + return CategoryRepo(client.fetch_categories()) + + +def fetch_payees(credentials: Credentials) -> PayeeRepo: + """Fetches payees from YNAB budget. + + :param credentials: Credentials to use for YNAB API + + :return: Collection of payees from YNAB budget + """ + client = Client.from_credentials(credentials=credentials) + return PayeeRepo(client.fetch_payees()) + + +def test_adjuster(adjuster: AdjusterBase, credentials: Credentials) -> List[dict]: + + """Tests the adjuster. It will fetch transactions from the YNAB account, filter & adjust them as per + implementation of the two methods. This function doesn't update records in YNAB but returns the modified + transactions so that they can be inspected. + + :param adjuster: Adjuster to use + :param credentials: Credentials to use for YNAB API + + :return: List of modified transactions in the format + :raises AdjustError: if there is any error during the adjust process + :raises HTTPError: if there is any error with the YNAB API (e.g. wrong credentials) + """ + client = Client.from_credentials(credentials=credentials) + transactions = client.fetch_transactions() + filtered_transactions = adjuster.filter(transactions) + s = Serializer(transactions=filtered_transactions, adjust_func=adjuster.adjust, categories=adjuster.categories) + modified_transactions = [{'original': mt.original_transaction, 'changes': mt.changed_attributes()} for mt in s.run()] + return modified_transactions + + +def run_adjuster(adjuster: AdjusterBase, credentials: Credentials) -> int: + """Run the adjuster. It will fetch transactions from the YNAB account, filter & adjust them as per + implementation of the two methods and push the updated transactions back to YNAB + + :param adjuster: Adjuster to use + :param credentials: Credentials to use for YNAB API + + :return: count of adjusted transactions which have been updated in YNAB + :raises AdjustError: if there is any error during the adjust process + :raises HTTPError: if there is any error with the YNAB API (e.g. wrong credentials) + """ + client = Client.from_credentials(credentials=credentials) + transactions = client.fetch_transactions() + filtered_transactions = adjuster.filter(transactions) + adjuster = Serializer(transactions=filtered_transactions, adjust_func=adjuster.adjust, categories=adjuster.categories) + modified_transactions = adjuster.run() + if modified_transactions: + updated = client.update_transactions(modified_transactions) + return updated + return 0 diff --git a/ynabtransactionadjuster/models/credentials.py b/ynabtransactionadjuster/models/credentials.py new file mode 100644 index 0000000..bbe4e89 --- /dev/null +++ b/ynabtransactionadjuster/models/credentials.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + + +@dataclass +class Credentials: + """Credentials to use for YNAB + + :ivar token: The YNAB token to use + :ivar budget: The YNAB budget id to use + :ivar account: The YNAB account id to use + """ + token: str + budget: str + account: str diff --git a/ynabtransactionadjuster/adjuster.py b/ynabtransactionadjuster/serializer.py similarity index 99% rename from ynabtransactionadjuster/adjuster.py rename to ynabtransactionadjuster/serializer.py index 11a849c..afb3e7d 100644 --- a/ynabtransactionadjuster/adjuster.py +++ b/ynabtransactionadjuster/serializer.py @@ -5,7 +5,7 @@ from ynabtransactionadjuster.repos import CategoryRepo -class Adjuster: +class Serializer: def __init__(self, transactions: List[OriginalTransaction], adjust_func: Callable, categories: CategoryRepo): self._transactions = transactions diff --git a/ynabtransactionadjuster/ynabtransactionadjuster.py b/ynabtransactionadjuster/ynabtransactionadjuster.py deleted file mode 100644 index 5f60107..0000000 --- a/ynabtransactionadjuster/ynabtransactionadjuster.py +++ /dev/null @@ -1,95 +0,0 @@ -from abc import abstractmethod -from typing import List - -from ynabtransactionadjuster.adjuster import Adjuster -from ynabtransactionadjuster.client import Client -from ynabtransactionadjuster.models import OriginalTransaction -from ynabtransactionadjuster.models import TransactionModifier -from ynabtransactionadjuster.repos import CategoryRepo -from ynabtransactionadjuster.repos import PayeeRepo - - -class YnabTransactionAdjuster: - """Abstract class which modifies transactions according to concrete implementation. You need to create your own - child class and implement the `filter()`and `adjust()` method in it according to your needs. It has attributes - which allow you to lookup categories and payees from your budget. - - :param budget: The YNAB budget id to use - :param account: The YNAB account id to use - :param token: The YNAB token to use - - :ivar categories: Collection of current categories in YNAB budget - :ivar payees: Collection of current payees in YNAB budget - """ - def __init__(self, budget: str, account: str, token: str) -> None: - self._budget = budget - self._account = account - self._client = Client(token=token, budget=budget, account=account) - self.categories: CategoryRepo = CategoryRepo(self._client.fetch_categories()) - self.payees: PayeeRepo = PayeeRepo(self._client.fetch_payees()) - - def run(self) -> int: - """Run the adjuster. It will fetch transactions from the YNAB account, filter & adjust them as per - implementation of the two methods and push the updated transactions back to YNAB - - :return: count of adjusted transactions which have been updated in YNAB - :raises AdjustError: if there is any error during the adjust process - :raises HTTPError: if there is any error with the YNAB API (e.g. wrong credentials) - """ - transactions = self._client.fetch_transactions() - filtered_transactions = self.filter(transactions) - adjuster = Adjuster(transactions=filtered_transactions, adjust_func=self.adjust, categories=self.categories) - modified_transactions = adjuster.run() - if modified_transactions: - updated = self._client.update_transactions(modified_transactions) - return updated - return 0 - - def test(self) -> List[dict]: - """Tests the adjuster. It will fetch transactions from the YNAB account, filter & adjust them as per - implementation of the two methods. This function doesn't update records in YNAB but returns the modified - transactions so that they can be inspected. `#! select * from source` - - The returned dicts have the following structure: - - { - "original": "", - "changes": { - "": { - "original": "", - "changed": "" - } - } - } - - :return: List of modified transactions in the format - :raises AdjustError: if there is any error during the adjust process - :raises HTTPError: if there is any error with the YNAB API (e.g. wrong credentials) - """ - transactions = self._client.fetch_transactions() - filtered_transactions = self.filter(transactions) - sa = Adjuster(transactions=filtered_transactions, adjust_func=self.adjust, categories=self.categories) - modified_transactions = [{'original': mt.original_transaction, 'changes': mt.changed_attributes()} for mt in sa.run()] - return modified_transactions - - @abstractmethod - def filter(self, transactions: List[OriginalTransaction]) -> List[OriginalTransaction]: - """Function which implements filtering for the list of transactions from YNAB account. It receives a list of - the original transactions which can be filtered. Must return the filtered list or just the list if no filtering - is intended. - - :param transactions: List of original transactions from YNAB - :return: Method needs to return a list of filtered transactions""" - pass - - @abstractmethod - def adjust(self, original: OriginalTransaction, modifier: TransactionModifier) -> TransactionModifier: - """Function which implements the actual modification of a transaction. It receives the original transaction from - YNAB and a prefilled modifier. The modifier can be altered and must be returned. - - :param original: Original transaction - :param modifier: Transaction modifier prefilled with values from original transaction. All attributes can be - changed and will modify the transaction - :returns: Method needs to return the transaction modifier after modification - """ - pass From bbdabe7a5c8d920b1fa66e2c3e6ea2140ed3b4ab Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sat, 20 Apr 2024 08:15:50 +0200 Subject: [PATCH 02/39] refactored to public attribute for all data --- ynabtransactionadjuster/repos/categoryrepo.py | 16 ++++------------ ynabtransactionadjuster/repos/payeerepo.py | 14 ++++---------- 2 files changed, 8 insertions(+), 22 deletions(-) diff --git a/ynabtransactionadjuster/repos/categoryrepo.py b/ynabtransactionadjuster/repos/categoryrepo.py index 8a514c0..703345a 100644 --- a/ynabtransactionadjuster/repos/categoryrepo.py +++ b/ynabtransactionadjuster/repos/categoryrepo.py @@ -8,7 +8,7 @@ class CategoryRepo: """Repository which holds all categories from your YNAB budget""" def __init__(self, categories: List[CategoryGroup]): - self._categories = categories + self.categories = categories def fetch_by_name(self, category_name: str, group_name: str = None) -> Category: """Fetches a YNAB category by its name @@ -20,9 +20,9 @@ def fetch_by_name(self, category_name: str, group_name: str = None) -> Category: :raises MultipleMatchingCategoriesError: if multiple matching categories are found """ if group_name: - cat_groups = [c for c in self._categories if c.name == group_name] + cat_groups = [c for c in self.categories if c.name == group_name] else: - cat_groups = self._categories + cat_groups = self.categories cats = [c for cg in cat_groups for c in cg.categories if category_name == c.name] @@ -39,14 +39,6 @@ def fetch_by_id(self, category_id: str) -> Category: :raises NoMatchingCategoryError: if no matching category is found """ try: - return next(c for cg in self._categories for c in cg.categories if c.id == category_id) + return next(c for cg in self.categories for c in cg.categories if c.id == category_id) except StopIteration: raise NoMatchingCategoryError(category_id) - - def fetch_all(self) -> Dict[str, List[Category]]: - """Fetches all Categories from YNAB budget - - :return: Dictionary with group names as keys and list of categories as values - """ - - return {cg.name: list(cg.categories) for cg in self._categories} diff --git a/ynabtransactionadjuster/repos/payeerepo.py b/ynabtransactionadjuster/repos/payeerepo.py index af3d789..ff760a9 100644 --- a/ynabtransactionadjuster/repos/payeerepo.py +++ b/ynabtransactionadjuster/repos/payeerepo.py @@ -8,7 +8,7 @@ class PayeeRepo: """Repository which holds all payees from your YNAB budget""" def __init__(self, payees: List[Payee]): - self._payees = payees + self.payees = payees def fetch_by_name(self, payee_name: str) -> Payee: """Fetches a payee by its name @@ -18,7 +18,7 @@ def fetch_by_name(self, payee_name: str) -> Payee: :raises NoMatchingPayeeError: if no matching payee is found """ try: - return next(p for p in self._payees if p.name == payee_name) + return next(p for p in self.payees if p.name == payee_name) except StopIteration: raise NoMatchingPayeeError(f"No payee with name '{payee_name}") @@ -30,7 +30,7 @@ def fetch_by_id(self, payee_id: str) -> Payee: :raises NoMatchingPayeeError: if no matching payee is found """ try: - return next(p for p in self._payees if p.id == payee_id) + return next(p for p in self.payees if p.id == payee_id) except StopIteration: raise NoMatchingPayeeError(f"No payee with id '{payee_id}") @@ -42,12 +42,6 @@ def fetch_by_transfer_account_id(self, transfer_account_id: str) -> Payee: :raises NoMatchingPayeeError: if no matching payee is found """ try: - return next(p for p in self._payees if p.transfer_account_id == transfer_account_id) + return next(p for p in self.payees if p.transfer_account_id == transfer_account_id) except StopIteration: raise NoMatchingPayeeError(f"No payee found for transfer_account_id {transfer_account_id}") - - def fetch_all(self) -> List[Payee]: - """Fetches all payees from YNAB budget - - :return: List of all payees in budget""" - return self._payees From bbbe150b40771f825005b4b28ff10baea94584c9 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sat, 20 Apr 2024 08:21:45 +0200 Subject: [PATCH 03/39] added public attributes to docstring --- ynabtransactionadjuster/repos/categoryrepo.py | 5 ++++- ynabtransactionadjuster/repos/payeerepo.py | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/ynabtransactionadjuster/repos/categoryrepo.py b/ynabtransactionadjuster/repos/categoryrepo.py index 703345a..47aea6d 100644 --- a/ynabtransactionadjuster/repos/categoryrepo.py +++ b/ynabtransactionadjuster/repos/categoryrepo.py @@ -6,7 +6,10 @@ class CategoryRepo: - """Repository which holds all categories from your YNAB budget""" + """Repository which holds all categories from your YNAB budget + + :ivar categories: List of Category Groups in YNAB budget + """ def __init__(self, categories: List[CategoryGroup]): self.categories = categories diff --git a/ynabtransactionadjuster/repos/payeerepo.py b/ynabtransactionadjuster/repos/payeerepo.py index ff760a9..5459246 100644 --- a/ynabtransactionadjuster/repos/payeerepo.py +++ b/ynabtransactionadjuster/repos/payeerepo.py @@ -5,8 +5,10 @@ class PayeeRepo: - """Repository which holds all payees from your YNAB budget""" + """Repository which holds all payees from your YNAB budget + :ivar payees: List of payees in YNAB budget + """ def __init__(self, payees: List[Payee]): self.payees = payees From e9c8f4ae46f159ff148a81c119a1eddd89419c1b Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sat, 20 Apr 2024 08:43:31 +0200 Subject: [PATCH 04/39] updated docstring --- ynabtransactionadjuster/adjusterbase.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ynabtransactionadjuster/adjusterbase.py b/ynabtransactionadjuster/adjusterbase.py index c782cc0..3fbc72c 100644 --- a/ynabtransactionadjuster/adjusterbase.py +++ b/ynabtransactionadjuster/adjusterbase.py @@ -14,10 +14,6 @@ class AdjusterBase: child class and implement the `filter()`and `adjust()` method in it according to your needs. It has attributes which allow you to lookup categories and payees from your budget. - :param budget: The YNAB budget id to use - :param account: The YNAB account id to use - :param token: The YNAB token to use - :ivar categories: Collection of current categories in YNAB budget :ivar payees: Collection of current payees in YNAB budget """ From bb88c409be0926bda4b41e1f60fd3a38bb60cec0 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sat, 20 Apr 2024 08:43:46 +0200 Subject: [PATCH 05/39] updated docs --- README.md | 52 ++++++++++++++++++++++++------------------ docs/basic_usage.md | 51 +++++++++++++++++++++++------------------ docs/detailed_usage.md | 19 +++++++-------- docs/reference.md | 12 ++++++++-- 4 files changed, 79 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index e1ae464..516d85a 100644 --- a/README.md +++ b/README.md @@ -33,46 +33,54 @@ method receives a list of `OriginalTransaction` objects which can be filtered be adjustement. The `adjust()` method receives a singular `OriginalTransaction` and a `TransactionModifier`. The latter is prefilled with values from the original transaction. Its attributes can be modified, and it needs to be returned at the end of the function. -Please check the [detailed usage](https://ynab-transaction-adjuster.readthedocs.io/en/latest/detailed_usage/) section for explanations how to change different attributes. +Please check the [detailed usage](https://ynab-transaction-adjuster.readthedocs.io/en/latest/detailed_usage/) section +for explanations how to change different attributes. + ```py -from ynabtransactionadjuster import YnabTransactionAdjuster +from ynabtransactionadjuster import AdjusterBase from ynabtransactionadjuster.models import OriginalTransaction, TransactionModifier -class MyAdjuster(YnabTransactionAdjuster): - - def filter(self, transactions: List[OriginalTransaction]) -> List[OriginalTransaction]: - # your implementation - - # return the filtered list of transactions - return transactions - - def adjust(self, original: OriginalTransaction, modifier: TransactionModifier) -> TransactionModifier: - # your implementation +class MyAdjuster(AdjusterBase): + + def filter(self, transactions: List[OriginalTransaction]) -> List[OriginalTransaction]: + # your implementation + + # return the filtered list of transactions + return transactions + + def adjust(self, original: OriginalTransaction, modifier: TransactionModifier) -> TransactionModifier: + # your implementation # return the altered modifier return modifier ``` ### Initialize -Initalize the adjuster with `token`, `budget` and `account` from YNAB +Create a `Credentials` object and initialize Adjuster class with it ```py -my_adjuster = MyAdjuster(token='', budget='', account='') +from ynabtransactionadjuster import Credentials + +my_credentials = Credentials(token='', budget='', account='') +my_adjuster = MyAdjuster.from_credentials(credentials=my_credentials) ``` ### Test -Test the adjuster on records fetched via the `test()`method. The method fetches and executes the -adjustments but doesn't write the results back to YNAB. Instead it returns a list of -the changed transactions which can be inspected for the changed properties. +Test the adjuster on records fetched via the `test_adjuster()` method. The method fetches and executes the adjustments +but doesn't write the results back to YNAB. Instead it returns a list of the changed transactions which can be +inspected for the changed properties. ```py -mod_transactions = my_adjuster.test() +from ynabtransactionadjuster import test_adjuster + +mod_transactions = test_adjuster(adjuster=my_adjuster, credentials=my_credentials) ``` ### Run -If you are satisfied with the functionality you can execute the adjuster with the `run()` method. This will run the -adjustments and will update the changed transactions in YNAB. The method returns an integer with the number of -successfully updated records. +If you are satisfied with the functionality you can execute the adjuster with the `run_adjuster()` method. This will +run the adjustments and will update the changed transactions in YNAB. The method returns an integer with the number +of successfully updated records. ```py -count_of_updated_transactions = my_adjuster.run() +from ynabtransactionadjuster import run_adjuster +count_of_updated_transactions = run_adjuster(adjuster=my_adjuster, credentials=my_credentials) ``` diff --git a/docs/basic_usage.md b/docs/basic_usage.md index a59f953..0340daf 100644 --- a/docs/basic_usage.md +++ b/docs/basic_usage.md @@ -1,52 +1,59 @@ # Basic Usage ### Create an Adjuster -Create a child class of [`YnabTransactionAdjuster`][ynabtransactionadjuster.YnabTransactionAdjuster]. +Create a child class of [`AdjusterBase`][ynabtransactionadjuster.AdjusterBase]. This class needs to implement a `filter()` and an `adjust()` method which contain the intended logic. The `filter()` method receives a list of [`OriginalTransaction`][models.OriginalTransaction] objects which can be filtered before adjustement. The `adjust()` method receives a singular [`OriginalTransaction`][models.OriginalTransaction] and a [`TransactionModifier`][models.TransactionModifier]. The latter is prefilled with values from the original transaction. Its attributes can be modified, and it needs to be returned at the end of the function. Please check the [detailed usage](detailed_usage.md) section for explanations how to change different attributes. + ```py -from ynabtransactionadjuster import YnabTransactionAdjuster +from ynabtransactionadjuster import AdjusterBase from ynabtransactionadjuster.models import OriginalTransaction, TransactionModifier -class MyAdjuster(YnabTransactionAdjuster): - - def filter(self, transactions: List[OriginalTransaction]) -> List[OriginalTransaction]: - # your implementation - - # return the filtered list of transactions - return transactions - - def adjust(self, original: OriginalTransaction, modifier: TransactionModifier) -> TransactionModifier: - # your implementation +class MyAdjuster(AdjusterBase): + + def filter(self, transactions: List[OriginalTransaction]) -> List[OriginalTransaction]: + # your implementation + + # return the filtered list of transactions + return transactions + + def adjust(self, original: OriginalTransaction, modifier: TransactionModifier) -> TransactionModifier: + # your implementation # return the altered modifier return modifier ``` ### Initialize -Initalize the adjuster with `token`, `budget` and `account` from YNAB +Create a [`Credentials`][models.Credentials] object and initialize Adjuster class with it ```py -my_adjuster = MyAdjuster(token='', budget='', account='') +from ynabtransactionadjuster import Credentials + +my_credentials = Credentials(token='', budget='', account='') +my_adjuster = MyAdjuster.from_credentials(credentials=my_credentials) ``` ### Test -Test the adjuster on records fetched via the `test()`method. The method fetches and executes the -adjustments but doesn't write the results back to YNAB. Instead it returns a list of -the changed transactions which can be inspected for the changed properties. +Test the adjuster on records fetched via the [`test_adjuster()`][functions.test_adjuster] method. The method fetches +and executes the adjustments but doesn't write the results back to YNAB. Instead it returns a list of the changed +transactions which can be inspected for the changed properties. ```py -mod_transactions = my_adjuster.test() +from ynabtransactionadjuster import test_adjuster + +mod_transactions = test_adjuster(adjuster=my_adjuster, credentials=my_credentials) ``` ### Run -If you are satisfied with the functionality you can execute the adjuster with the `run()` method. This will run the -adjustments and will update the changed transactions in YNAB. The method returns an integer with the number of -successfully updated records. +If you are satisfied with the functionality you can execute the adjuster with the [`run_adjuster()`][functions.run_adjuster] +method. This will run the adjustments and will update the changed transactions in YNAB. The method +returns an integer with the number of successfully updated records. ```py -count_of_updated_transactions = my_adjuster.run() +from ynabtransactionadjuster import run_adjuster +count_of_updated_transactions = run_adjuster(adjuster=my_adjuster, credentials=my_credentials) ``` diff --git a/docs/detailed_usage.md b/docs/detailed_usage.md index 8ae0af4..b75bc53 100644 --- a/docs/detailed_usage.md +++ b/docs/detailed_usage.md @@ -23,12 +23,12 @@ class MyAdjusterFactory(AdjusterBase): return modifier ``` -The [`CategoryRepo`][repos.CategoryRepo] instance gets build when the adjuster gets initialized and can also be accessed -from the main instance (e.g. for finding category ids to be used in the parser later). The `fetch_all()` method fetches -all categories and returns a dict with group name as key and list of categories as values. +The [`CategoryRepo`][repos.CategoryRepo] instance can also get fetched via the [`fetch_categories()`][functions.fetch_categories] +method using an [`Credentials`][models.Credentials] object. ```py -my_adjuster = MyAdjuster(token='', budget='', account='') -categories = my_adjuster.categories.fetch_all() +from ynabtransactionadjuster import fetch_categories + +categories = fetch_categories(credentials=my_credentials) ``` ## Change the payee @@ -60,12 +60,13 @@ class MyAdjuster(AdjusterBase): return modifier ``` -The [`PayeeRepo`][repos.PayeeRepo] instance gets build when the adjuster gets initialized and can also be accessed -from the main instance. The `fetch_all()` method fetches all payees in the budget. +The [`PayeeRepo`][repos.PayeeRepo] instance can also get fetched via the [`fetch_payees()`][functions.fetch_payees] +method using an [`Credentials`][models.Credentials] object. ```py -my_adjuster = MyAdjuster(token='', budget='', account='') -payees = my_adjuster.payees.fetch_all() +from ynabtransactionadjuster import fetch_payees + +payees = fetch_payees(credentials=my_credentials) ``` ## Split the transaction diff --git a/docs/reference.md b/docs/reference.md index 2dc8b3f..db5b3d5 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1,7 +1,15 @@ # Reference +## Module level functions -::: ynabtransactionadjuster.YnabTransactionAdjuster +:::functions.test_adjuster +:::functions.run_adjuster +:::functions.fetch_categories +:::functions.fetch_payees + +## AdjusterBase class + +::: ynabtransactionadjuster.AdjusterBase options: merge_init_into_class: true show_root_full_path: false @@ -12,7 +20,7 @@ ::: repos.PayeeRepo ## Models - +::: models.Credentials ::: models.OriginalTransaction ::: models.OriginalSubTransaction ::: models.TransactionModifier From 7b80a86d03335fadddee1d2a38f3961a589fc1a7 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sat, 20 Apr 2024 09:01:56 +0200 Subject: [PATCH 06/39] renamed test_adjuster to dry_run_adjuster --- README.md | 4 ++-- docs/basic_usage.md | 7 ++++--- docs/reference.md | 2 +- ynabtransactionadjuster/__init__.py | 2 +- ynabtransactionadjuster/functions.py | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 516d85a..800e227 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,9 @@ but doesn't write the results back to YNAB. Instead it returns a list of the cha inspected for the changed properties. ```py -from ynabtransactionadjuster import test_adjuster +from ynabtransactionadjuster import dry_run_adjuster -mod_transactions = test_adjuster(adjuster=my_adjuster, credentials=my_credentials) +mod_transactions = dry_run_adjuster(adjuster=my_adjuster, credentials=my_credentials) ``` ### Run diff --git a/docs/basic_usage.md b/docs/basic_usage.md index 0340daf..554dccd 100644 --- a/docs/basic_usage.md +++ b/docs/basic_usage.md @@ -39,14 +39,15 @@ my_adjuster = MyAdjuster.from_credentials(credentials=my_credentials) ``` ### Test -Test the adjuster on records fetched via the [`test_adjuster()`][functions.test_adjuster] method. The method fetches +Test the adjuster on records fetched via the [`dry_run_adjuster()`][functions.dry_run_adjuster] method. The method +fetches and executes the adjustments but doesn't write the results back to YNAB. Instead it returns a list of the changed transactions which can be inspected for the changed properties. ```py -from ynabtransactionadjuster import test_adjuster +from ynabtransactionadjuster import dry_run_adjuster -mod_transactions = test_adjuster(adjuster=my_adjuster, credentials=my_credentials) +mod_transactions = dry_run_adjuster(adjuster=my_adjuster, credentials=my_credentials) ``` ### Run diff --git a/docs/reference.md b/docs/reference.md index db5b3d5..7a28e0e 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -2,7 +2,7 @@ ## Module level functions -:::functions.test_adjuster +:::functions.dry_run_adjuster :::functions.run_adjuster :::functions.fetch_categories :::functions.fetch_payees diff --git a/ynabtransactionadjuster/__init__.py b/ynabtransactionadjuster/__init__.py index e06f0e0..225856e 100644 --- a/ynabtransactionadjuster/__init__.py +++ b/ynabtransactionadjuster/__init__.py @@ -1,4 +1,4 @@ from ynabtransactionadjuster.adjusterbase import AdjusterBase from ynabtransactionadjuster.repos import CategoryRepo, PayeeRepo from ynabtransactionadjuster.models.credentials import Credentials -from ynabtransactionadjuster.functions import fetch_payees, fetch_categories, run_adjuster, test_adjuster +from ynabtransactionadjuster.functions import fetch_payees, fetch_categories, run_adjuster, dry_run_adjuster diff --git a/ynabtransactionadjuster/functions.py b/ynabtransactionadjuster/functions.py index 589a764..c9ea472 100644 --- a/ynabtransactionadjuster/functions.py +++ b/ynabtransactionadjuster/functions.py @@ -29,7 +29,7 @@ def fetch_payees(credentials: Credentials) -> PayeeRepo: return PayeeRepo(client.fetch_payees()) -def test_adjuster(adjuster: AdjusterBase, credentials: Credentials) -> List[dict]: +def dry_run_adjuster(adjuster: AdjusterBase, credentials: Credentials) -> List[dict]: """Tests the adjuster. It will fetch transactions from the YNAB account, filter & adjust them as per implementation of the two methods. This function doesn't update records in YNAB but returns the modified From 46221f26a0c08542806e779e1d17512e6d70d834 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sat, 20 Apr 2024 09:02:10 +0200 Subject: [PATCH 07/39] updated tests --- tests/test_categoryrepo.py | 6 --- tests/test_functions.py | 60 ++++++++++++++++++++++++++ tests/test_ynabtransactionadjuster.py | 62 --------------------------- 3 files changed, 60 insertions(+), 68 deletions(-) create mode 100644 tests/test_functions.py delete mode 100644 tests/test_ynabtransactionadjuster.py diff --git a/tests/test_categoryrepo.py b/tests/test_categoryrepo.py index 4ef5127..9fbc29f 100644 --- a/tests/test_categoryrepo.py +++ b/tests/test_categoryrepo.py @@ -27,9 +27,3 @@ def test_fetch_by_id_fail(mock_category_repo): with pytest.raises(NoMatchingCategoryError): mock_category_repo.fetch_by_id(category_id='xxx') - -def test_fetch_all(mock_category_repo): - r = mock_category_repo.fetch_all() - assert isinstance(r, dict) - assert r['group1'][0].id == 'cid1' - assert r['group2'][0].id == 'cid2' diff --git a/tests/test_functions.py b/tests/test_functions.py new file mode 100644 index 0000000..5ddcccc --- /dev/null +++ b/tests/test_functions.py @@ -0,0 +1,60 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from ynabtransactionadjuster import AdjusterBase, dry_run_adjuster, run_adjuster + + +class MockYnabTransactionAdjuster(AdjusterBase): + + def __init__(self, memo: str): + self.categories = None + self.payees = None + self.memo = memo + + def filter(self, transactions): + return transactions + + def adjust(self, original, modifier): + modifier.memo = self.memo + return modifier + + +@patch('ynabtransactionadjuster.functions.Client.fetch_transactions') +def test_test_adjuster(mock_client, mock_category_repo, caplog, mock_original_transaction): + # Arrange + mock_client.return_value = [mock_original_transaction] + memo = 'test' + my_adjuster = MockYnabTransactionAdjuster(memo=memo) + my_adjuster.categories = mock_category_repo + + # Act + r = dry_run_adjuster(adjuster=my_adjuster, credentials=MagicMock()) + + # Assert + assert len(r) == 1 + assert r[0]['changes']['memo']['changed'] == memo + +@patch('ynabtransactionadjuster.functions.Client.fetch_transactions') +@patch('ynabtransactionadjuster.functions.Client.update_transactions') +def test_run(mock_update, mock_fetch, mock_category_repo, caplog, mock_original_transaction): + # Arrange + my_adjuster = MockYnabTransactionAdjuster(memo='test') + my_adjuster.categories = mock_category_repo + mock_fetch.return_value = [mock_original_transaction] + + c = run_adjuster(my_adjuster, credentials=MagicMock()) + mock_update.assert_called_once() + + +@pytest.mark.parametrize('test_input', ['a', 'b']) +@patch('ynabtransactionadjuster.functions.Client.fetch_transactions') +@patch('ynabtransactionadjuster.functions.Client.update_transactions') +def test_run_no_modified(mock_update, mock_fetch, mock_category_repo, caplog, test_input, mock_original_transaction): + # Arrange + my_adjuster = MockYnabTransactionAdjuster(memo='memo') + my_adjuster.categories = mock_category_repo + mock_fetch.return_value = [mock_original_transaction] if test_input == 'a' else [] + + c = run_adjuster(my_adjuster, credentials=MagicMock()) + mock_update.assert_not_called() diff --git a/tests/test_ynabtransactionadjuster.py b/tests/test_ynabtransactionadjuster.py deleted file mode 100644 index fe56253..0000000 --- a/tests/test_ynabtransactionadjuster.py +++ /dev/null @@ -1,62 +0,0 @@ -from unittest.mock import MagicMock - -import pytest - -from ynabtransactionadjuster import YnabTransactionAdjuster - - -class MockYnabTransactionAdjuster(YnabTransactionAdjuster): - - def __init__(self, memo: str): - self._client = None - self.categories = None - self.payees = None - self.memo = memo - - def filter(self, transactions): - return transactions - - def adjust(self, original, modifier): - modifier.memo = self.memo - return modifier - - -def test_test(mock_category_repo, caplog, mock_original_transaction): - # Arrange - memo = 'test' - my_adjuster = MockYnabTransactionAdjuster(memo=memo) - mock_client = MagicMock() - mock_client.fetch_transactions.return_value = [mock_original_transaction] - my_adjuster._client = mock_client - my_adjuster.categories = mock_category_repo - # Act - r = my_adjuster.test() - - # Assert - assert len(r) == 1 - assert r[0]['changes']['memo']['changed'] == memo - - -def test_run(mock_category_repo, caplog, mock_original_transaction): - # Arrange - my_adjuster = MockYnabTransactionAdjuster(memo='test') - mock_client = MagicMock() - mock_client.fetch_transactions.return_value = [mock_original_transaction] - my_adjuster._client = mock_client - my_adjuster.categories = mock_category_repo - - my_adjuster.run() - my_adjuster._client.update_transactions.assert_called_once() - - -@pytest.mark.parametrize('test_input', ['a', 'b']) -def test_run_no_modified(mock_category_repo, caplog, test_input, mock_original_transaction): - # Arrange - my_adjuster = MockYnabTransactionAdjuster(memo='memo') - mock_client = MagicMock() - mock_client.fetch_transactions.return_value = [mock_original_transaction] if test_input == 'a' else [] - my_adjuster._client = mock_client - my_adjuster.categories = mock_category_repo - - my_adjuster.run() - my_adjuster._client.update_transactions.assert_not_called() From f204f958673b203156d8411eaafa40529ecae023 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sat, 20 Apr 2024 09:02:30 +0200 Subject: [PATCH 08/39] added Credentials --- ynabtransactionadjuster/models/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ynabtransactionadjuster/models/__init__.py b/ynabtransactionadjuster/models/__init__.py index 12a2607..b157edd 100644 --- a/ynabtransactionadjuster/models/__init__.py +++ b/ynabtransactionadjuster/models/__init__.py @@ -6,3 +6,4 @@ from .subtransaction import SubTransaction from .transactionmodifier import TransactionModifier from .modifiedtransaction import ModifiedTransaction +from .credentials import Credentials From 623a1d365645f9a5cafd1b381e28da04e3e6d884 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sun, 21 Apr 2024 08:13:51 +0200 Subject: [PATCH 09/39] refactored adjusterbase to include run methods --- ynabtransactionadjuster/adjusterbase.py | 42 +++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/ynabtransactionadjuster/adjusterbase.py b/ynabtransactionadjuster/adjusterbase.py index 3fbc72c..e0d3597 100644 --- a/ynabtransactionadjuster/adjusterbase.py +++ b/ynabtransactionadjuster/adjusterbase.py @@ -7,6 +7,7 @@ from ynabtransactionadjuster.models import TransactionModifier from ynabtransactionadjuster.repos import CategoryRepo from ynabtransactionadjuster.repos import PayeeRepo +from ynabtransactionadjuster.serializer import Serializer class AdjusterBase: @@ -16,10 +17,15 @@ class AdjusterBase: :ivar categories: Collection of current categories in YNAB budget :ivar payees: Collection of current payees in YNAB budget + :ivar transactions: Transactions from YNAB Account + :ivar credentials: Credentials for YNAB API """ - def __init__(self, categories: CategoryRepo, payees: PayeeRepo) -> None: + def __init__(self, categories: CategoryRepo, payees: PayeeRepo, transactions: List[OriginalTransaction], + credentials: Credentials) -> None: self.categories = categories self.payees = payees + self.transactions = transactions + self.credentials = credentials @classmethod def from_credentials(cls, credentials: Credentials): @@ -30,7 +36,8 @@ def from_credentials(cls, credentials: Credentials): client = Client.from_credentials(credentials=credentials) categories = CategoryRepo(client.fetch_categories()) payees = PayeeRepo(client.fetch_payees()) - return cls(categories=categories, payees=payees) + transactions = client.fetch_transactions() + return cls(categories=categories, payees=payees, transactions=transactions, credentials=credentials) @abstractmethod def filter(self, transactions: List[OriginalTransaction]) -> List[OriginalTransaction]: @@ -53,3 +60,34 @@ def adjust(self, original: OriginalTransaction, modifier: TransactionModifier) - :returns: Method needs to return the transaction modifier after modification """ pass + + def dry_run(self) -> List[dict]: + """Tests the adjuster. It will fetch transactions from the YNAB account, filter & adjust them as per + implementation of the two methods. This function doesn't update records in YNAB but returns the modified + transactions so that they can be inspected. + + :return: List of modified transactions in the format + :raises AdjustError: if there is any error during the adjust process + :raises HTTPError: if there is any error with the YNAB API (e.g. wrong credentials) + """ + filtered_transactions = self.filter(self.transactions) + s = Serializer(transactions=self.transactions, adjust_func=self.adjust, categories=self.categories) + modified_transactions = [{'original': mt.original_transaction, 'changes': mt.changed_attributes()} for mt in s.run()] + return modified_transactions + + def run(self) -> int: + """Run the adjuster. It will fetch transactions from the YNAB account, filter & adjust them as per + implementation of the two methods and push the updated transactions back to YNAB + + :return: count of adjusted transactions which have been updated in YNAB + :raises AdjustError: if there is any error during the adjust process + :raises HTTPError: if there is any error with the YNAB API (e.g. wrong credentials) + """ + filtered_transactions = self.filter(self.transactions) + s = Serializer(transactions=filtered_transactions, adjust_func=self.adjust, categories=self.categories) + modified_transactions = s.run() + if modified_transactions: + client = Client.from_credentials(credentials=self.credentials) + updated = client.update_transactions(modified_transactions) + return updated + return 0 From 410250cfbfa0b808969a79d59b480a30e8182628 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sun, 21 Apr 2024 08:14:06 +0200 Subject: [PATCH 10/39] deleted functions --- ynabtransactionadjuster/__init__.py | 1 - ynabtransactionadjuster/functions.py | 72 ---------------------------- 2 files changed, 73 deletions(-) delete mode 100644 ynabtransactionadjuster/functions.py diff --git a/ynabtransactionadjuster/__init__.py b/ynabtransactionadjuster/__init__.py index 225856e..f3ac6ba 100644 --- a/ynabtransactionadjuster/__init__.py +++ b/ynabtransactionadjuster/__init__.py @@ -1,4 +1,3 @@ from ynabtransactionadjuster.adjusterbase import AdjusterBase from ynabtransactionadjuster.repos import CategoryRepo, PayeeRepo from ynabtransactionadjuster.models.credentials import Credentials -from ynabtransactionadjuster.functions import fetch_payees, fetch_categories, run_adjuster, dry_run_adjuster diff --git a/ynabtransactionadjuster/functions.py b/ynabtransactionadjuster/functions.py deleted file mode 100644 index c9ea472..0000000 --- a/ynabtransactionadjuster/functions.py +++ /dev/null @@ -1,72 +0,0 @@ -from typing import List - -from ynabtransactionadjuster.adjusterbase import AdjusterBase -from ynabtransactionadjuster.client import Client -from ynabtransactionadjuster.models.credentials import Credentials -from ynabtransactionadjuster.repos import CategoryRepo, PayeeRepo -from ynabtransactionadjuster.serializer import Serializer - - -def fetch_categories(credentials: Credentials) -> CategoryRepo: - """Fetches categories from YNAB budget. - - :param credentials: Credentials to use for YNAB API - - :return: Collection of categories from YNAB budget - """ - client = Client.from_credentials(credentials=credentials) - return CategoryRepo(client.fetch_categories()) - - -def fetch_payees(credentials: Credentials) -> PayeeRepo: - """Fetches payees from YNAB budget. - - :param credentials: Credentials to use for YNAB API - - :return: Collection of payees from YNAB budget - """ - client = Client.from_credentials(credentials=credentials) - return PayeeRepo(client.fetch_payees()) - - -def dry_run_adjuster(adjuster: AdjusterBase, credentials: Credentials) -> List[dict]: - - """Tests the adjuster. It will fetch transactions from the YNAB account, filter & adjust them as per - implementation of the two methods. This function doesn't update records in YNAB but returns the modified - transactions so that they can be inspected. - - :param adjuster: Adjuster to use - :param credentials: Credentials to use for YNAB API - - :return: List of modified transactions in the format - :raises AdjustError: if there is any error during the adjust process - :raises HTTPError: if there is any error with the YNAB API (e.g. wrong credentials) - """ - client = Client.from_credentials(credentials=credentials) - transactions = client.fetch_transactions() - filtered_transactions = adjuster.filter(transactions) - s = Serializer(transactions=filtered_transactions, adjust_func=adjuster.adjust, categories=adjuster.categories) - modified_transactions = [{'original': mt.original_transaction, 'changes': mt.changed_attributes()} for mt in s.run()] - return modified_transactions - - -def run_adjuster(adjuster: AdjusterBase, credentials: Credentials) -> int: - """Run the adjuster. It will fetch transactions from the YNAB account, filter & adjust them as per - implementation of the two methods and push the updated transactions back to YNAB - - :param adjuster: Adjuster to use - :param credentials: Credentials to use for YNAB API - - :return: count of adjusted transactions which have been updated in YNAB - :raises AdjustError: if there is any error during the adjust process - :raises HTTPError: if there is any error with the YNAB API (e.g. wrong credentials) - """ - client = Client.from_credentials(credentials=credentials) - transactions = client.fetch_transactions() - filtered_transactions = adjuster.filter(transactions) - adjuster = Serializer(transactions=filtered_transactions, adjust_func=adjuster.adjust, categories=adjuster.categories) - modified_transactions = adjuster.run() - if modified_transactions: - updated = client.update_transactions(modified_transactions) - return updated - return 0 From edc258722c664f30a4ca52de13ee5d8721ab5b3d Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sun, 21 Apr 2024 08:35:55 +0200 Subject: [PATCH 11/39] renamed adjusterbase to adjuster --- README.md | 38 +++++------- docs/basic_usage.md | 28 ++++----- docs/detailed_usage.md | 12 ++-- docs/reference.md | 11 +--- tests/test_adjusterbase.py | 60 +++++++++++++++++++ tests/test_functions.py | 60 ------------------- ynabtransactionadjuster/__init__.py | 4 +- .../{adjusterbase.py => adjuster.py} | 2 +- 8 files changed, 97 insertions(+), 118 deletions(-) create mode 100644 tests/test_adjusterbase.py delete mode 100644 tests/test_functions.py rename ynabtransactionadjuster/{adjusterbase.py => adjuster.py} (99%) diff --git a/README.md b/README.md index 800e227..b6c92e6 100644 --- a/README.md +++ b/README.md @@ -27,21 +27,18 @@ A detailed documentation is available at https://ynab-transaction-adjuster.readt # Basic Usage ### Create an Adjuster -Create a child class of `YnabTransactionAdjuster`. -This class needs to implement a `filter()` and an `adjust()` method which contain the intended logic. The `filter()` -method receives a list of `OriginalTransaction` objects which can be filtered before -adjustement. The `adjust()` method receives a singular `OriginalTransaction` and a -`TransactionModifier`. The latter is prefilled with values from the original transaction. -Its attributes can be modified, and it needs to be returned at the end of the function. +Create a child class of `Adjuster`. This class needs to implement a `filter()` and an `adjust()` method which contain +the intended logic. The `filter()` method receives a list of `OriginalTransaction` objects which can be filtered before +adjustement. The `adjust()` method receives a singular `OriginalTransaction` and a `TransactionModifier`. The latter is +prefilled with values from the original transaction. Its attributes can be modified, and it needs to be returned at +the end of the function. Please check the [detailed usage](https://ynab-transaction-adjuster.readthedocs.io/en/latest/detailed_usage/) section for explanations how to change different attributes. ```py -from ynabtransactionadjuster import AdjusterBase -from ynabtransactionadjuster.models import OriginalTransaction, TransactionModifier +from ynabtransactionadjuster import Adjuster, OriginalTransaction, TransactionModifier - -class MyAdjuster(AdjusterBase): +class MyAdjuster(Adjuster): def filter(self, transactions: List[OriginalTransaction]) -> List[OriginalTransaction]: # your implementation @@ -57,7 +54,7 @@ class MyAdjuster(AdjusterBase): ``` ### Initialize -Create a `Credentials` object and initialize Adjuster class with it +Create a [`Credentials`][models.Credentials] object and initialize Adjuster class with it ```py from ynabtransactionadjuster import Credentials @@ -66,21 +63,18 @@ my_adjuster = MyAdjuster.from_credentials(credentials=my_credentials) ``` ### Test -Test the adjuster on records fetched via the `test_adjuster()` method. The method fetches and executes the adjustments -but doesn't write the results back to YNAB. Instead it returns a list of the changed transactions which can be -inspected for the changed properties. +Test the adjuster on records fetched via the `dry_run()` method. It executes the adjustments but doesn't write the +results back to YNAB. Instead it returns a list of the changed transactions which can be inspected for the changed +properties. ```py -from ynabtransactionadjuster import dry_run_adjuster - -mod_transactions = dry_run_adjuster(adjuster=my_adjuster, credentials=my_credentials) +mod_transactions = my_adjuster.dry_run() ``` ### Run -If you are satisfied with the functionality you can execute the adjuster with the `run_adjuster()` method. This will -run the adjustments and will update the changed transactions in YNAB. The method returns an integer with the number -of successfully updated records. +If you are satisfied with the functionality you can execute the adjuster with the `run()` method. This will run the +adjustments and will update the changed transactions in YNAB. The method returns an integer with the number of +successfully updated records. ```py -from ynabtransactionadjuster import run_adjuster -count_of_updated_transactions = run_adjuster(adjuster=my_adjuster, credentials=my_credentials) +count_of_updated_transactions = my_adjuster.run() ``` diff --git a/docs/basic_usage.md b/docs/basic_usage.md index 554dccd..2ac0e3b 100644 --- a/docs/basic_usage.md +++ b/docs/basic_usage.md @@ -1,7 +1,7 @@ # Basic Usage ### Create an Adjuster -Create a child class of [`AdjusterBase`][ynabtransactionadjuster.AdjusterBase]. +Create a child class of [`Adjuster`][ynabtransactionadjuster.Adjuster]. This class needs to implement a `filter()` and an `adjust()` method which contain the intended logic. The `filter()` method receives a list of [`OriginalTransaction`][models.OriginalTransaction] objects which can be filtered before adjustement. The `adjust()` method receives a singular [`OriginalTransaction`][models.OriginalTransaction] and a @@ -10,11 +10,9 @@ Its attributes can be modified, and it needs to be returned at the end of the fu Please check the [detailed usage](detailed_usage.md) section for explanations how to change different attributes. ```py -from ynabtransactionadjuster import AdjusterBase -from ynabtransactionadjuster.models import OriginalTransaction, TransactionModifier +from ynabtransactionadjuster import Adjuster, OriginalTransaction, TransactionModifier - -class MyAdjuster(AdjusterBase): +class MyAdjuster(Adjuster): def filter(self, transactions: List[OriginalTransaction]) -> List[OriginalTransaction]: # your implementation @@ -39,22 +37,18 @@ my_adjuster = MyAdjuster.from_credentials(credentials=my_credentials) ``` ### Test -Test the adjuster on records fetched via the [`dry_run_adjuster()`][functions.dry_run_adjuster] method. The method -fetches -and executes the adjustments but doesn't write the results back to YNAB. Instead it returns a list of the changed -transactions which can be inspected for the changed properties. +Test the adjuster on records fetched via the `dry_run()` method. It executes the adjustments but doesn't write the +results back to YNAB. Instead it returns a list of the changed transactions which can be inspected for the changed +properties. ```py -from ynabtransactionadjuster import dry_run_adjuster - -mod_transactions = dry_run_adjuster(adjuster=my_adjuster, credentials=my_credentials) +mod_transactions = my_adjuster.dry_run() ``` ### Run -If you are satisfied with the functionality you can execute the adjuster with the [`run_adjuster()`][functions.run_adjuster] -method. This will run the adjustments and will update the changed transactions in YNAB. The method -returns an integer with the number of successfully updated records. +If you are satisfied with the functionality you can execute the adjuster with the `run()` method. This will run the +adjustments and will update the changed transactions in YNAB. The method returns an integer with the number of +successfully updated records. ```py -from ynabtransactionadjuster import run_adjuster -count_of_updated_transactions = run_adjuster(adjuster=my_adjuster, credentials=my_credentials) +count_of_updated_transactions = my_adjuster.run() ``` diff --git a/docs/detailed_usage.md b/docs/detailed_usage.md index b75bc53..b7dea5f 100644 --- a/docs/detailed_usage.md +++ b/docs/detailed_usage.md @@ -7,10 +7,10 @@ recommended to ensure you only assign valid categories to the modifier. The libr categories and specifying a non-existing category will raise an error. ```py -from ynabtransactionadjuster import AdjusterBase +from ynabtransactionadjuster import Adjuster -class MyAdjusterFactory(AdjusterBase): +class MyAdjusterFactory(Adjuster): def filter(self, transactions): return transactions @@ -39,11 +39,11 @@ called with `fetch_by_transfer_account_id()` to fetch a transfer payee. You can account following the method mentioned in the [preparations](#preparations) section. ```py -from ynabtransactionadjuster import AdjusterBase +from ynabtransactionadjuster import Adjuster from ynabtransactionadjuster.models import Payee -class MyAdjuster(AdjusterBase): +class MyAdjuster(Adjuster): def filter(self, transactions): return transactions @@ -76,11 +76,11 @@ There must be at least two subtransactions and the sum of their amounts must be transaction. ```py -from ynabtransactionadjuster import AdjusterBase +from ynabtransactionadjuster import Adjuster from ynabtransactionadjuster.models import SubTransaction -class MyAdjuster(AdjusterBase): +class MyAdjuster(Adjuster): def filter(self, transactions): return transactions diff --git a/docs/reference.md b/docs/reference.md index 7a28e0e..b42a318 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1,15 +1,6 @@ # Reference -## Module level functions - -:::functions.dry_run_adjuster -:::functions.run_adjuster -:::functions.fetch_categories -:::functions.fetch_payees - -## AdjusterBase class - -::: ynabtransactionadjuster.AdjusterBase +::: ynabtransactionadjuster.Adjuster options: merge_init_into_class: true show_root_full_path: false diff --git a/tests/test_adjusterbase.py b/tests/test_adjusterbase.py new file mode 100644 index 0000000..32e35ae --- /dev/null +++ b/tests/test_adjusterbase.py @@ -0,0 +1,60 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from ynabtransactionadjuster import Adjuster + + +class MockYnabTransactionAdjuster(Adjuster): + + def __init__(self, memo: str): + self.categories = None + self.payees = None + self.transactions = None + self.credentials = MagicMock() + self.memo = memo + + def filter(self, transactions): + return transactions + + def adjust(self, original, modifier): + modifier.memo = self.memo + return modifier + + +def test_dry_run(mock_category_repo, caplog, mock_original_transaction): + # Arrange + memo = 'test' + my_adjuster = MockYnabTransactionAdjuster(memo=memo) + my_adjuster.transactions = [mock_original_transaction] + my_adjuster.categories = mock_category_repo + + # Act + r = my_adjuster.dry_run() + + # Assert + assert len(r) == 1 + assert r[0]['changes']['memo']['changed'] == memo + + +@patch('ynabtransactionadjuster.adjusterbase.Client.update_transactions') +def test_run(mock_update, mock_category_repo, caplog, mock_original_transaction): + # Arrange + my_adjuster = MockYnabTransactionAdjuster(memo='test') + my_adjuster.categories = mock_category_repo + my_adjuster.transactions = [mock_original_transaction] + + c = my_adjuster.run() + mock_update.assert_called_once() + + +@pytest.mark.parametrize('test_input', ['a', 'b']) +@patch('ynabtransactionadjuster.adjusterbase.Client.update_transactions') +def test_run_no_modified(mock_update, mock_category_repo, caplog, test_input, mock_original_transaction): + # Arrange + my_adjuster = MockYnabTransactionAdjuster(memo='memo') + my_adjuster.categories = mock_category_repo + my_adjuster.transactions = [mock_original_transaction] if test_input == 'a' else [] + + c = my_adjuster.run() + mock_update.assert_not_called() diff --git a/tests/test_functions.py b/tests/test_functions.py deleted file mode 100644 index 5ddcccc..0000000 --- a/tests/test_functions.py +++ /dev/null @@ -1,60 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest - -from ynabtransactionadjuster import AdjusterBase, dry_run_adjuster, run_adjuster - - -class MockYnabTransactionAdjuster(AdjusterBase): - - def __init__(self, memo: str): - self.categories = None - self.payees = None - self.memo = memo - - def filter(self, transactions): - return transactions - - def adjust(self, original, modifier): - modifier.memo = self.memo - return modifier - - -@patch('ynabtransactionadjuster.functions.Client.fetch_transactions') -def test_test_adjuster(mock_client, mock_category_repo, caplog, mock_original_transaction): - # Arrange - mock_client.return_value = [mock_original_transaction] - memo = 'test' - my_adjuster = MockYnabTransactionAdjuster(memo=memo) - my_adjuster.categories = mock_category_repo - - # Act - r = dry_run_adjuster(adjuster=my_adjuster, credentials=MagicMock()) - - # Assert - assert len(r) == 1 - assert r[0]['changes']['memo']['changed'] == memo - -@patch('ynabtransactionadjuster.functions.Client.fetch_transactions') -@patch('ynabtransactionadjuster.functions.Client.update_transactions') -def test_run(mock_update, mock_fetch, mock_category_repo, caplog, mock_original_transaction): - # Arrange - my_adjuster = MockYnabTransactionAdjuster(memo='test') - my_adjuster.categories = mock_category_repo - mock_fetch.return_value = [mock_original_transaction] - - c = run_adjuster(my_adjuster, credentials=MagicMock()) - mock_update.assert_called_once() - - -@pytest.mark.parametrize('test_input', ['a', 'b']) -@patch('ynabtransactionadjuster.functions.Client.fetch_transactions') -@patch('ynabtransactionadjuster.functions.Client.update_transactions') -def test_run_no_modified(mock_update, mock_fetch, mock_category_repo, caplog, test_input, mock_original_transaction): - # Arrange - my_adjuster = MockYnabTransactionAdjuster(memo='memo') - my_adjuster.categories = mock_category_repo - mock_fetch.return_value = [mock_original_transaction] if test_input == 'a' else [] - - c = run_adjuster(my_adjuster, credentials=MagicMock()) - mock_update.assert_not_called() diff --git a/ynabtransactionadjuster/__init__.py b/ynabtransactionadjuster/__init__.py index f3ac6ba..293421c 100644 --- a/ynabtransactionadjuster/__init__.py +++ b/ynabtransactionadjuster/__init__.py @@ -1,3 +1,3 @@ -from ynabtransactionadjuster.adjusterbase import AdjusterBase +from ynabtransactionadjuster.adjuster import Adjuster from ynabtransactionadjuster.repos import CategoryRepo, PayeeRepo -from ynabtransactionadjuster.models.credentials import Credentials +from ynabtransactionadjuster.models import OriginalTransaction, OriginalSubTransaction, Category, CategoryGroup, Payee, Credentials, TransactionModifier, ModifiedTransaction diff --git a/ynabtransactionadjuster/adjusterbase.py b/ynabtransactionadjuster/adjuster.py similarity index 99% rename from ynabtransactionadjuster/adjusterbase.py rename to ynabtransactionadjuster/adjuster.py index e0d3597..ed8d1a5 100644 --- a/ynabtransactionadjuster/adjusterbase.py +++ b/ynabtransactionadjuster/adjuster.py @@ -10,7 +10,7 @@ from ynabtransactionadjuster.serializer import Serializer -class AdjusterBase: +class Adjuster: """Abstract class which modifies transactions according to concrete implementation. You need to create your own child class and implement the `filter()`and `adjust()` method in it according to your needs. It has attributes which allow you to lookup categories and payees from your budget. From 9d9de6efd89be7401e0044e8d246362cc5e0609f Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sun, 21 Apr 2024 08:36:58 +0200 Subject: [PATCH 12/39] fixed adjuster tests --- tests/{test_adjusterbase.py => test_adjuster.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename tests/{test_adjusterbase.py => test_adjuster.py} (91%) diff --git a/tests/test_adjusterbase.py b/tests/test_adjuster.py similarity index 91% rename from tests/test_adjusterbase.py rename to tests/test_adjuster.py index 32e35ae..acbdddb 100644 --- a/tests/test_adjusterbase.py +++ b/tests/test_adjuster.py @@ -37,7 +37,7 @@ def test_dry_run(mock_category_repo, caplog, mock_original_transaction): assert r[0]['changes']['memo']['changed'] == memo -@patch('ynabtransactionadjuster.adjusterbase.Client.update_transactions') +@patch('ynabtransactionadjuster.adjuster.Client.update_transactions') def test_run(mock_update, mock_category_repo, caplog, mock_original_transaction): # Arrange my_adjuster = MockYnabTransactionAdjuster(memo='test') @@ -49,7 +49,7 @@ def test_run(mock_update, mock_category_repo, caplog, mock_original_transaction) @pytest.mark.parametrize('test_input', ['a', 'b']) -@patch('ynabtransactionadjuster.adjusterbase.Client.update_transactions') +@patch('ynabtransactionadjuster.adjuster.Client.update_transactions') def test_run_no_modified(mock_update, mock_category_repo, caplog, test_input, mock_original_transaction): # Arrange my_adjuster = MockYnabTransactionAdjuster(memo='memo') From 7609a910e1eb49b8d017ed6a4adf4adbe3025474 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sun, 21 Apr 2024 08:56:21 +0200 Subject: [PATCH 13/39] renamed library objects for simplicity --- README.md | 11 +++-- docs/basic_usage.md | 13 ++--- docs/detailed_usage.md | 48 ++++++------------- docs/reference.md | 7 +-- tests/conftest.py | 38 +++++++-------- tests/test_modifiedtransaction.py | 14 +++--- ...transationmodifier.py => test_modifier.py} | 30 ++++++------ tests/test_modifiersubtransaction.py | 44 +++++++++++++++++ tests/test_subtransaction.py | 44 ----------------- ...inaltransaction.py => test_transaction.py} | 8 ++-- ynabtransactionadjuster/__init__.py | 2 +- ynabtransactionadjuster/adjuster.py | 10 ++-- ynabtransactionadjuster/client.py | 6 +-- ynabtransactionadjuster/models/__init__.py | 8 ++-- .../models/modifiedtransaction.py | 8 ++-- .../{transactionmodifier.py => modifier.py} | 14 +++--- .../models/modifiersubtransaction.py | 39 +++++++++++++++ .../models/originalsubtransaction.py | 20 -------- .../models/subtransaction.py | 37 ++++---------- ...{originaltransaction.py => transaction.py} | 42 ++++++++-------- ynabtransactionadjuster/serializer.py | 16 +++---- 21 files changed, 222 insertions(+), 237 deletions(-) rename tests/{test_transationmodifier.py => test_modifier.py} (61%) create mode 100644 tests/test_modifiersubtransaction.py delete mode 100644 tests/test_subtransaction.py rename tests/{test_originaltransaction.py => test_transaction.py} (90%) rename ynabtransactionadjuster/models/{transactionmodifier.py => modifier.py} (76%) create mode 100644 ynabtransactionadjuster/models/modifiersubtransaction.py delete mode 100644 ynabtransactionadjuster/models/originalsubtransaction.py rename ynabtransactionadjuster/models/{originaltransaction.py => transaction.py} (61%) diff --git a/README.md b/README.md index b6c92e6..2e15876 100644 --- a/README.md +++ b/README.md @@ -28,25 +28,26 @@ A detailed documentation is available at https://ynab-transaction-adjuster.readt ### Create an Adjuster Create a child class of `Adjuster`. This class needs to implement a `filter()` and an `adjust()` method which contain -the intended logic. The `filter()` method receives a list of `OriginalTransaction` objects which can be filtered before -adjustement. The `adjust()` method receives a singular `OriginalTransaction` and a `TransactionModifier`. The latter is +the intended logic. The `filter()` method receives a list of `Transaction` objects which can be filtered before +adjustement. The `adjust()` method receives a singular `Transaction` and a `Modifier`. The latter is prefilled with values from the original transaction. Its attributes can be modified, and it needs to be returned at the end of the function. Please check the [detailed usage](https://ynab-transaction-adjuster.readthedocs.io/en/latest/detailed_usage/) section for explanations how to change different attributes. ```py -from ynabtransactionadjuster import Adjuster, OriginalTransaction, TransactionModifier +from ynabtransactionadjuster import Adjuster, Transaction, Modifier + class MyAdjuster(Adjuster): - def filter(self, transactions: List[OriginalTransaction]) -> List[OriginalTransaction]: + def filter(self, transactions: List[Transaction]) -> List[Transaction]: # your implementation # return the filtered list of transactions return transactions - def adjust(self, original: OriginalTransaction, modifier: TransactionModifier) -> TransactionModifier: + def adjust(self, transaction: Transaction, modifier: Modifier) -> Modifier: # your implementation # return the altered modifier diff --git a/docs/basic_usage.md b/docs/basic_usage.md index 2ac0e3b..a30f1af 100644 --- a/docs/basic_usage.md +++ b/docs/basic_usage.md @@ -3,24 +3,25 @@ ### Create an Adjuster Create a child class of [`Adjuster`][ynabtransactionadjuster.Adjuster]. This class needs to implement a `filter()` and an `adjust()` method which contain the intended logic. The `filter()` -method receives a list of [`OriginalTransaction`][models.OriginalTransaction] objects which can be filtered before -adjustement. The `adjust()` method receives a singular [`OriginalTransaction`][models.OriginalTransaction] and a -[`TransactionModifier`][models.TransactionModifier]. The latter is prefilled with values from the original transaction. +method receives a list of [`Transaction`][models.Transaction] objects which can be filtered before +adjustement. The `adjust()` method receives a singular [`Transaction`][models.Transaction] and a +[`Modifier`][models.Modifier]. The latter is prefilled with values from the original transaction. Its attributes can be modified, and it needs to be returned at the end of the function. Please check the [detailed usage](detailed_usage.md) section for explanations how to change different attributes. ```py -from ynabtransactionadjuster import Adjuster, OriginalTransaction, TransactionModifier +from ynabtransactionadjuster import Adjuster, Transaction, Modifier + class MyAdjuster(Adjuster): - def filter(self, transactions: List[OriginalTransaction]) -> List[OriginalTransaction]: + def filter(self, transactions: List[Transaction]) -> List[Transaction]: # your implementation # return the filtered list of transactions return transactions - def adjust(self, original: OriginalTransaction, modifier: TransactionModifier) -> TransactionModifier: + def adjust(self, transaction: Transaction, modifier: Modifier) -> Modifier: # your implementation # return the altered modifier diff --git a/docs/detailed_usage.md b/docs/detailed_usage.md index b7dea5f..d4ff32f 100644 --- a/docs/detailed_usage.md +++ b/docs/detailed_usage.md @@ -15,7 +15,7 @@ class MyAdjusterFactory(Adjuster): def filter(self, transactions): return transactions - def adjust(self, original, modifier): + def adjust(self, transaction, modifier): my_category = self.categories.fetch_by_name('my_category') # or alternatively my_category = self.categories.fetch_by_id('category_id') @@ -23,32 +23,23 @@ class MyAdjusterFactory(Adjuster): return modifier ``` -The [`CategoryRepo`][repos.CategoryRepo] instance can also get fetched via the [`fetch_categories()`][functions.fetch_categories] -method using an [`Credentials`][models.Credentials] object. -```py -from ynabtransactionadjuster import fetch_categories - -categories = fetch_categories(credentials=my_credentials) -``` ## Change the payee The payee of the transaction can be changed either by creating a new [`Payee`][models.Payee] object or fetching an existing payee from the [`PayeeRepo`][repos.PayeeRepo] which can be used in the adjust function via `self.payees`. The repo can be called with either `fetch_by_name()` or `fetch_by_id()` method to fetch an existing payee. It can also be -called with `fetch_by_transfer_account_id()` to fetch a transfer payee. You can find the account id for the transfer -account following the method mentioned in the [preparations](#preparations) section. +called with `fetch_by_transfer_account_id()` to fetch a transfer payee or with `fetch_all()`to get all payees. +You can find the account id for the transfer account following the method mentioned in the [preparations](#preparations) section. ```py -from ynabtransactionadjuster import Adjuster -from ynabtransactionadjuster.models import Payee - +from ynabtransactionadjuster import Adjuster, Payee class MyAdjuster(Adjuster): def filter(self, transactions): return transactions - def adjust(self, original, modifier): + def adjust(self, transaction, modifier): my_payee = Payee(name='My Payee') # or my_payee = self.payees.fetch_by_name('My Payee') @@ -60,24 +51,15 @@ class MyAdjuster(Adjuster): return modifier ``` -The [`PayeeRepo`][repos.PayeeRepo] instance can also get fetched via the [`fetch_payees()`][functions.fetch_payees] -method using an [`Credentials`][models.Credentials] object. - -```py -from ynabtransactionadjuster import fetch_payees - -payees = fetch_payees(credentials=my_credentials) -``` ## Split the transaction -The transaction can be splitted if the original transaction is not already a split (YNAB doesn't allow updating splits -of an existing split transaction). Splits can be created by using [`SubTransaction`][models.SubTransaction] instances. -There must be at least two subtransactions and the sum of their amounts must be equal to the amount of the original -transaction. +The transaction can be split if the original transaction is not already a split (YNAB doesn't allow updating splits +of an existing split transaction). Splits can be created by using [`ModifierSubTransaction`][models.ModifierSubTransaction] +instances. There must be at least two subtransactions and the sum of their amounts must be equal to the amount of the +original transaction. ```py -from ynabtransactionadjuster import Adjuster -from ynabtransactionadjuster.models import SubTransaction +from ynabtransactionadjuster import Adjuster, ModifierSubTransaction class MyAdjuster(Adjuster): @@ -85,12 +67,12 @@ class MyAdjuster(Adjuster): def filter(self, transactions): return transactions - def adjust(self, original, modifier): + def adjust(self, transaction, modifier): # example for splitting a transaction in two equal amount subtransactions with different categories - subtransaction_1 = SubTransaction(amount=original.amount / 2, - category=original.category) - subtransaction_2 = SubTransaction(amount=original.amount / 2, - category=self.categories.fetch_by_name('My 2nd Category')) + subtransaction_1 = ModifierSubTransaction(amount=transaction.amount / 2, + category=transaction.category) + subtransaction_2 = ModifierSubTransaction(amount=transaction.amount / 2, + category=self.categories.fetch_by_name('My 2nd Category')) modifier.subtransactions = [subtransaction_1, subtransaction_2] return modifier diff --git a/docs/reference.md b/docs/reference.md index b42a318..1b96909 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -12,10 +12,11 @@ ## Models ::: models.Credentials -::: models.OriginalTransaction -::: models.OriginalSubTransaction -::: models.TransactionModifier +::: models.Transaction ::: models.SubTransaction +::: models.Modifier +::: models.ModifierSubTransaction +::: models.ModifiedTransaction ::: models.Category ::: models.Payee diff --git a/tests/conftest.py b/tests/conftest.py index 4075a82..64f590d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,15 +2,15 @@ import pytest -from ynabtransactionadjuster.models import OriginalTransaction, Category, Payee, OriginalSubTransaction, SubTransaction, CategoryGroup +from ynabtransactionadjuster.models import Transaction, Category, Payee, SubTransaction, ModifierSubTransaction, CategoryGroup from ynabtransactionadjuster.repos import CategoryRepo @pytest.fixture def mock_subtransaction(request): - return SubTransaction(memo='memo', amount=500, - category=Category(name='cname', id='cid'), - payee=Payee(name='pname')) + return ModifierSubTransaction(memo='memo', amount=500, + category=Category(name='cname', id='cid'), + payee=Payee(name='pname')) @pytest.fixture @@ -21,26 +21,26 @@ def mock_original_transaction(request): flag_color = 'red' if hasattr(request, 'param'): if request.param == 'subtransactions': - st = OriginalSubTransaction(memo='memo1', amount=500, - category=Category(name='cname', id='cid'), - payee=Payee(name='pname')) + st = SubTransaction(memo='memo1', amount=500, + category=Category(name='cname', id='cid'), + payee=Payee(name='pname')) subs = (st, st) if request.param == 'optional_none': memo = None category = None flag_color = None - return OriginalTransaction(id='id', - memo=memo, - category=category, - payee=Payee(id='pid', name='pname'), - subtransactions=subs, - flag_color=flag_color, - amount=1000, - import_payee_name='ipn', - import_payee_name_original='ipno', - transaction_date=date(2024, 1, 1), - approved=False, - cleared='uncleared') + return Transaction(id='id', + memo=memo, + category=category, + payee=Payee(id='pid', name='pname'), + subtransactions=subs, + flag_color=flag_color, + amount=1000, + import_payee_name='ipn', + import_payee_name_original='ipno', + transaction_date=date(2024, 1, 1), + approved=False, + cleared='uncleared') @pytest.fixture diff --git a/tests/test_modifiedtransaction.py b/tests/test_modifiedtransaction.py index dbd309c..1611733 100644 --- a/tests/test_modifiedtransaction.py +++ b/tests/test_modifiedtransaction.py @@ -3,7 +3,7 @@ import pytest from pydantic import ValidationError -from ynabtransactionadjuster.models import Category, Payee, TransactionModifier, ModifiedTransaction +from ynabtransactionadjuster.models import Category, Payee, Modifier, ModifiedTransaction @pytest.mark.parametrize('test_attribute, test_input', [ @@ -16,7 +16,7 @@ ('cleared', 'cleared')]) def test_is_changed_true(test_attribute, test_input, mock_original_transaction): # Arrange - mock_modifier = TransactionModifier.from_original_transaction(mock_original_transaction) + mock_modifier = Modifier.from_original_transaction(mock_original_transaction) mock_modifier.__setattr__(test_attribute, test_input) modified = ModifiedTransaction(original_transaction=mock_original_transaction, transaction_modifier=mock_modifier) @@ -33,7 +33,7 @@ def test_is_changed_true(test_attribute, test_input, mock_original_transaction): ]) def test_is_changed_true_none_values_in_original(test_attribute, test_input, mock_original_transaction): # Arrange - mock_modifier = TransactionModifier.from_original_transaction(mock_original_transaction) + mock_modifier = Modifier.from_original_transaction(mock_original_transaction) mock_modifier.__setattr__(test_attribute, test_input) modified = ModifiedTransaction(original_transaction=mock_original_transaction, transaction_modifier=mock_modifier) @@ -45,7 +45,7 @@ def test_is_changed_true_none_values_in_original(test_attribute, test_input, moc @pytest.mark.parametrize('mock_original_transaction', [None, 'optional_none'], indirect=True) def test_changed_false(mock_original_transaction): # Arrange - mock_modifier = TransactionModifier.from_original_transaction(mock_original_transaction) + mock_modifier = Modifier.from_original_transaction(mock_original_transaction) modified = ModifiedTransaction(original_transaction=mock_original_transaction, transaction_modifier=mock_modifier) # Act @@ -56,7 +56,7 @@ def test_changed_false(mock_original_transaction): @pytest.mark.parametrize('mock_original_transaction', ['subtransactions'], indirect=True) def test_invalid_subtransactions(mock_original_transaction, mock_subtransaction): # Arrange - mock_modifier = TransactionModifier.from_original_transaction(mock_original_transaction) + mock_modifier = Modifier.from_original_transaction(mock_original_transaction) mock_modifier.subtransactions = [mock_subtransaction, mock_subtransaction] with pytest.raises(ValidationError): ModifiedTransaction(original_transaction=mock_original_transaction, transaction_modifier=mock_modifier) @@ -64,7 +64,7 @@ def test_invalid_subtransactions(mock_original_transaction, mock_subtransaction) def test_as_dict(mock_original_transaction, mock_subtransaction): # Arrange - mock_modifier = TransactionModifier.from_original_transaction(mock_original_transaction) + mock_modifier = Modifier.from_original_transaction(mock_original_transaction) mock_modifier.payee = Payee(id='pid2', name='pname2') mock_modifier.category = Category(id='cid2', name='cname2') mock_modifier.flag_color = 'blue' @@ -89,7 +89,7 @@ def test_as_dict(mock_original_transaction, mock_subtransaction): def test_as_dict_none_values(mock_original_transaction): # Arrange - mock_modifier = TransactionModifier.from_original_transaction(mock_original_transaction) + mock_modifier = Modifier.from_original_transaction(mock_original_transaction) mock_modifier.category = None mock_modifier.flag_color = None mt = ModifiedTransaction(original_transaction=mock_original_transaction, transaction_modifier=mock_modifier) diff --git a/tests/test_transationmodifier.py b/tests/test_modifier.py similarity index 61% rename from tests/test_transationmodifier.py rename to tests/test_modifier.py index f176d75..f17254a 100644 --- a/tests/test_transationmodifier.py +++ b/tests/test_modifier.py @@ -3,24 +3,24 @@ import pytest from pydantic import ValidationError -from ynabtransactionadjuster.models import TransactionModifier, Payee, Category, SubTransaction +from ynabtransactionadjuster.models import Modifier, Payee, Category, ModifierSubTransaction @pytest.fixture def mock_modifier(request): - return TransactionModifier(memo='memo', - payee=Payee(name='pname'), - category=Category(name='cname', id='cid'), - flag_color='red', - subtransactions=[], - transaction_date=date(2024, 1, 1), - cleared='uncleared', - approved=False) + return Modifier(memo='memo', + payee=Payee(name='pname'), + category=Category(name='cname', id='cid'), + flag_color='red', + subtransactions=[], + transaction_date=date(2024, 1, 1), + cleared='uncleared', + approved=False) @pytest.fixture def mock_subtransaction(): - return SubTransaction(memo='memo', payee=Payee(name='pname'), category=Category(name='cname', id='cid'), amount=500) + return ModifierSubTransaction(memo='memo', payee=Payee(name='pname'), category=Category(name='cname', id='cid'), amount=500) @pytest.mark.parametrize('test_attr, test_value', [ @@ -39,14 +39,14 @@ def test_invalid_types(test_attr, test_value, mock_modifier): # Act mock_modifier.__setattr__(test_attr, test_value) with pytest.raises(ValidationError): - TransactionModifier.model_validate(mock_modifier.__dict__) + Modifier.model_validate(mock_modifier.__dict__) def test_invalid_subtransactions(mock_modifier, mock_subtransaction): # Arrange mock_modifier.subtransactions = [mock_subtransaction] with pytest.raises(ValidationError): - TransactionModifier.model_validate(mock_modifier.__dict__) + Modifier.model_validate(mock_modifier.__dict__) @pytest.mark.parametrize('test_attr, test_value', [ @@ -58,11 +58,11 @@ def test_valid(test_attr, test_value, mock_modifier): # Arrange mock_modifier.__setattr__(test_attr, test_value) # Assert - TransactionModifier.model_validate(mock_modifier.__dict__) + Modifier.model_validate(mock_modifier.__dict__) def test_valid_subtransactions(mock_modifier, mock_subtransaction): mock_modifier.subtransactions = [mock_subtransaction, mock_subtransaction] - TransactionModifier.model_validate(mock_modifier.__dict__) + Modifier.model_validate(mock_modifier.__dict__) mock_modifier.subtransactions = [mock_subtransaction, mock_subtransaction, mock_subtransaction] - TransactionModifier.model_validate(mock_modifier.__dict__) + Modifier.model_validate(mock_modifier.__dict__) diff --git a/tests/test_modifiersubtransaction.py b/tests/test_modifiersubtransaction.py new file mode 100644 index 0000000..2133660 --- /dev/null +++ b/tests/test_modifiersubtransaction.py @@ -0,0 +1,44 @@ +import pytest +from pydantic import ValidationError + +from ynabtransactionadjuster.models import ModifierSubTransaction, Category, Payee + + +@pytest.fixture +def mock_category(): + return Category(name='cname', id='id') + + +@pytest.fixture +def mock_payee(): + return Payee(name='pname') + + +def test_subtransaction_success(mock_category, mock_payee): + ModifierSubTransaction(memo='memo', amount=1000, category=mock_category, payee=mock_payee) + ModifierSubTransaction(memo=None, amount=1000, category=mock_category, payee=mock_payee) + + +def test_subtransaction_error(mock_category, mock_payee): + with pytest.raises(ValidationError): + ModifierSubTransaction(memo='memo', amount=2.3, payee=mock_payee, category=mock_category) + with pytest.raises(ValidationError): + ModifierSubTransaction(memo='memo', amount=0, payee=mock_payee, category=mock_category) + with pytest.raises(ValidationError): + ModifierSubTransaction(memo='memo', amount=0, payee='xxx', category=mock_category) + with pytest.raises(ValidationError): + ModifierSubTransaction(memo='memo', amount=0, payee=mock_payee, category='xxx') + + +@pytest.mark.parametrize('test_input, expected', [ +(ModifierSubTransaction(amount=1000), dict(amount=1000)), + (ModifierSubTransaction(amount=1000, memo='memo'), dict(amount=1000, memo='memo')), + (ModifierSubTransaction(amount=1000, payee=Payee(name='payee')), dict(amount=1000, payee_name='payee')), + (ModifierSubTransaction(amount=1000, payee=Payee(name='payee', id='payeeid')), dict(amount=1000, payee_name='payee', payee_id='payeeid')), + (ModifierSubTransaction(amount=1000, category=Category(name='category', id='categoryid')), dict(amount=1000, category_id='categoryid'))]) +def test_as_dict(test_input, expected): + # Act + d = test_input.as_dict() + + # Assert + assert d == expected diff --git a/tests/test_subtransaction.py b/tests/test_subtransaction.py deleted file mode 100644 index d5cfbad..0000000 --- a/tests/test_subtransaction.py +++ /dev/null @@ -1,44 +0,0 @@ -import pytest -from pydantic import ValidationError - -from ynabtransactionadjuster.models import SubTransaction, Category, Payee - - -@pytest.fixture -def mock_category(): - return Category(name='cname', id='id') - - -@pytest.fixture -def mock_payee(): - return Payee(name='pname') - - -def test_subtransaction_success(mock_category, mock_payee): - SubTransaction(memo='memo', amount=1000, category=mock_category, payee=mock_payee) - SubTransaction(memo=None, amount=1000, category=mock_category, payee=mock_payee) - - -def test_subtransaction_error(mock_category, mock_payee): - with pytest.raises(ValidationError): - SubTransaction(memo='memo', amount=2.3, payee=mock_payee, category=mock_category) - with pytest.raises(ValidationError): - SubTransaction(memo='memo', amount=0, payee=mock_payee, category=mock_category) - with pytest.raises(ValidationError): - SubTransaction(memo='memo', amount=0, payee='xxx', category=mock_category) - with pytest.raises(ValidationError): - SubTransaction(memo='memo', amount=0, payee=mock_payee, category='xxx') - - -@pytest.mark.parametrize('test_input, expected', [ -(SubTransaction(amount=1000), dict(amount=1000)), - (SubTransaction(amount=1000, memo='memo'), dict(amount=1000, memo='memo')), - (SubTransaction(amount=1000, payee=Payee(name='payee')), dict(amount=1000, payee_name='payee')), - (SubTransaction(amount=1000, payee=Payee(name='payee', id='payeeid')), dict(amount=1000, payee_name='payee', payee_id='payeeid')), - (SubTransaction(amount=1000, category=Category(name='category', id='categoryid')), dict(amount=1000, category_id='categoryid'))]) -def test_as_dict(test_input, expected): - # Act - d = test_input.as_dict() - - # Assert - assert d == expected diff --git a/tests/test_originaltransaction.py b/tests/test_transaction.py similarity index 90% rename from tests/test_originaltransaction.py rename to tests/test_transaction.py index ff84922..0f0c371 100644 --- a/tests/test_originaltransaction.py +++ b/tests/test_transaction.py @@ -2,7 +2,7 @@ import pytest -from ynabtransactionadjuster.models import OriginalTransaction, Category, Payee +from ynabtransactionadjuster.models import Transaction, Category, Payee @pytest.fixture @@ -14,7 +14,7 @@ def mock_transaction_dict(): def test_from_dict(mock_transaction_dict): - o = OriginalTransaction.from_dict(mock_transaction_dict) + o = Transaction.from_dict(mock_transaction_dict) assert o.id == mock_transaction_dict['id'] assert o.amount == mock_transaction_dict['amount'] assert o.transaction_date == datetime.strptime(mock_transaction_dict['date'], '%Y-%m-%d').date() @@ -38,7 +38,7 @@ def test_from_dict_category(mock_transaction_dict, name, cid, expected): mock_transaction_dict['category_name'] = name mock_transaction_dict['category_id'] = cid - o = OriginalTransaction.from_dict(mock_transaction_dict) + o = Transaction.from_dict(mock_transaction_dict) assert o.category == expected @@ -50,7 +50,7 @@ def test_from_dict_subtransactions(mock_transaction_dict): mock_transaction_dict['subtransactions'] = [st, st] # Act - o = OriginalTransaction.from_dict(mock_transaction_dict) + o = Transaction.from_dict(mock_transaction_dict) # Assert assert len(o.subtransactions) == 2 diff --git a/ynabtransactionadjuster/__init__.py b/ynabtransactionadjuster/__init__.py index 293421c..4e7ef43 100644 --- a/ynabtransactionadjuster/__init__.py +++ b/ynabtransactionadjuster/__init__.py @@ -1,3 +1,3 @@ from ynabtransactionadjuster.adjuster import Adjuster from ynabtransactionadjuster.repos import CategoryRepo, PayeeRepo -from ynabtransactionadjuster.models import OriginalTransaction, OriginalSubTransaction, Category, CategoryGroup, Payee, Credentials, TransactionModifier, ModifiedTransaction +from ynabtransactionadjuster.models import Transaction, SubTransaction, Category, CategoryGroup, Payee, Credentials, Modifier, ModifiedTransaction, ModifierSubTransaction diff --git a/ynabtransactionadjuster/adjuster.py b/ynabtransactionadjuster/adjuster.py index ed8d1a5..7448e88 100644 --- a/ynabtransactionadjuster/adjuster.py +++ b/ynabtransactionadjuster/adjuster.py @@ -3,8 +3,8 @@ from ynabtransactionadjuster.models.credentials import Credentials from ynabtransactionadjuster.client import Client -from ynabtransactionadjuster.models import OriginalTransaction -from ynabtransactionadjuster.models import TransactionModifier +from ynabtransactionadjuster.models import Transaction +from ynabtransactionadjuster.models import Modifier from ynabtransactionadjuster.repos import CategoryRepo from ynabtransactionadjuster.repos import PayeeRepo from ynabtransactionadjuster.serializer import Serializer @@ -20,7 +20,7 @@ class Adjuster: :ivar transactions: Transactions from YNAB Account :ivar credentials: Credentials for YNAB API """ - def __init__(self, categories: CategoryRepo, payees: PayeeRepo, transactions: List[OriginalTransaction], + def __init__(self, categories: CategoryRepo, payees: PayeeRepo, transactions: List[Transaction], credentials: Credentials) -> None: self.categories = categories self.payees = payees @@ -40,7 +40,7 @@ def from_credentials(cls, credentials: Credentials): return cls(categories=categories, payees=payees, transactions=transactions, credentials=credentials) @abstractmethod - def filter(self, transactions: List[OriginalTransaction]) -> List[OriginalTransaction]: + def filter(self, transactions: List[Transaction]) -> List[Transaction]: """Function which implements filtering for the list of transactions from YNAB account. It receives a list of the original transactions which can be filtered. Must return the filtered list or just the list if no filtering is intended. @@ -50,7 +50,7 @@ def filter(self, transactions: List[OriginalTransaction]) -> List[OriginalTransa pass @abstractmethod - def adjust(self, original: OriginalTransaction, modifier: TransactionModifier) -> TransactionModifier: + def adjust(self, original: Transaction, modifier: Modifier) -> Modifier: """Function which implements the actual modification of a transaction. It receives the original transaction from YNAB and a prefilled modifier. The modifier can be altered and must be returned. diff --git a/ynabtransactionadjuster/client.py b/ynabtransactionadjuster/client.py index cee83ae..32d98a7 100644 --- a/ynabtransactionadjuster/client.py +++ b/ynabtransactionadjuster/client.py @@ -4,7 +4,7 @@ from requests import HTTPError from ynabtransactionadjuster.models import CategoryGroup, ModifiedTransaction -from ynabtransactionadjuster.models import OriginalTransaction +from ynabtransactionadjuster.models import Transaction from ynabtransactionadjuster.models import Payee from ynabtransactionadjuster.models.credentials import Credentials @@ -46,14 +46,14 @@ def fetch_payees(self) -> List[Payee]: payees = [Payee.from_dict(p) for p in data if p['deleted'] is False] return payees - def fetch_transactions(self) -> List[OriginalTransaction]: + def fetch_transactions(self) -> List[Transaction]: """Fetches transactions from YNAB""" r = requests.get(f'{YNAB_BASE_URL}/budgets/{self._budget}/accounts/{self._account}/transactions', headers=self._header) r.raise_for_status() data = r.json()['data']['transactions'] transaction_dicts = [t for t in data if t['deleted'] is False] - transactions = [OriginalTransaction.from_dict(t) for t in transaction_dicts] + transactions = [Transaction.from_dict(t) for t in transaction_dicts] return transactions def update_transactions(self, transactions: List[ModifiedTransaction]) -> int: diff --git a/ynabtransactionadjuster/models/__init__.py b/ynabtransactionadjuster/models/__init__.py index b157edd..7bb8bb2 100644 --- a/ynabtransactionadjuster/models/__init__.py +++ b/ynabtransactionadjuster/models/__init__.py @@ -1,9 +1,9 @@ -from .originaltransaction import OriginalTransaction +from .transaction import Transaction from .category import Category from .categorygroup import CategoryGroup -from .originalsubtransaction import OriginalSubTransaction -from .payee import Payee from .subtransaction import SubTransaction -from .transactionmodifier import TransactionModifier +from .payee import Payee +from .modifiersubtransaction import ModifierSubTransaction +from .modifier import Modifier from .modifiedtransaction import ModifiedTransaction from .credentials import Credentials diff --git a/ynabtransactionadjuster/models/modifiedtransaction.py b/ynabtransactionadjuster/models/modifiedtransaction.py index f0b2d6e..ee5e0a0 100644 --- a/ynabtransactionadjuster/models/modifiedtransaction.py +++ b/ynabtransactionadjuster/models/modifiedtransaction.py @@ -2,13 +2,13 @@ from pydantic import BaseModel, model_validator -from ynabtransactionadjuster.models.originaltransaction import OriginalTransaction -from ynabtransactionadjuster.models.transactionmodifier import TransactionModifier +from ynabtransactionadjuster.models import Transaction +from ynabtransactionadjuster.models import Modifier class ModifiedTransaction(BaseModel): - original_transaction: OriginalTransaction - transaction_modifier: TransactionModifier + original_transaction: Transaction + transaction_modifier: Modifier def is_changed(self) -> bool: """Helper function to determine if transaction has been altered as compared to original one diff --git a/ynabtransactionadjuster/models/transactionmodifier.py b/ynabtransactionadjuster/models/modifier.py similarity index 76% rename from ynabtransactionadjuster/models/transactionmodifier.py rename to ynabtransactionadjuster/models/modifier.py index 55f9061..ddae410 100644 --- a/ynabtransactionadjuster/models/transactionmodifier.py +++ b/ynabtransactionadjuster/models/modifier.py @@ -2,13 +2,13 @@ from datetime import date from typing import List, Literal, Optional -from ynabtransactionadjuster.models.category import Category -from ynabtransactionadjuster.models.originaltransaction import OriginalTransaction -from ynabtransactionadjuster.models.subtransaction import SubTransaction -from ynabtransactionadjuster.models.payee import Payee +from ynabtransactionadjuster.models import Category +from ynabtransactionadjuster.models import Transaction +from ynabtransactionadjuster.models import ModifierSubTransaction +from ynabtransactionadjuster.models import Payee -class TransactionModifier(BaseModel): +class Modifier(BaseModel): """Transaction object prefilled with values from original transaction which can take modified values :ivar category: The category of the transaction @@ -26,12 +26,12 @@ class TransactionModifier(BaseModel): memo: Optional[str] payee: Payee flag_color: Optional[Literal['red', 'green', 'blue', 'orange', 'purple', 'yellow']] - subtransactions: List[SubTransaction] + subtransactions: List[ModifierSubTransaction] approved: bool cleared: Literal['uncleared', 'cleared', 'reconciled'] @classmethod - def from_original_transaction(cls, original_transaction: OriginalTransaction): + def from_original_transaction(cls, original_transaction: Transaction): return cls(transaction_date=original_transaction.transaction_date, category=original_transaction.category, payee=original_transaction.payee, diff --git a/ynabtransactionadjuster/models/modifiersubtransaction.py b/ynabtransactionadjuster/models/modifiersubtransaction.py new file mode 100644 index 0000000..faf973e --- /dev/null +++ b/ynabtransactionadjuster/models/modifiersubtransaction.py @@ -0,0 +1,39 @@ +from typing import Optional + +from pydantic import BaseModel, model_validator + +from ynabtransactionadjuster.models.category import Category +from ynabtransactionadjuster.models.payee import Payee + + +class ModifierSubTransaction(BaseModel): + """YNAB Subtransaction object for creating split transactions. To be used as element in subtransaction attribute of + Transaction class + + :ivar amount: The amount of the subtransaction in milliunits + :ivar category: The category of the subtransaction + :ivar payee: The payee of the subtransaction + :ivar memo: The memo of the subtransaction + """ + amount: int + payee: Optional[Payee] = None + category: Optional[Category] = None + memo: Optional[str] = None + + def as_dict(self) -> dict: + instance_dict = dict(amount=self.amount) + if self.payee and self.payee.name: + instance_dict['payee_name'] = self.payee.name + if self.payee and self.payee.id: + instance_dict['payee_id'] = self.payee.id + if self.category and self.category.id: + instance_dict['category_id'] = self.category.id + if self.memo: + instance_dict['memo'] = self.memo + return instance_dict + + @model_validator(mode='after') + def check_values(self): + if self.amount == 0: + raise ValueError('Amount needs to be different from 0') + return self diff --git a/ynabtransactionadjuster/models/originalsubtransaction.py b/ynabtransactionadjuster/models/originalsubtransaction.py deleted file mode 100644 index 704dbc4..0000000 --- a/ynabtransactionadjuster/models/originalsubtransaction.py +++ /dev/null @@ -1,20 +0,0 @@ -from dataclasses import dataclass -from typing import Optional - -from ynabtransactionadjuster.models.category import Category -from ynabtransactionadjuster.models.payee import Payee - - -@dataclass(frozen=True) -class OriginalSubTransaction: - """Represents an YNAB Subtransaction as part of an existing split transaction - - :ivar payee: The payee of the subtransaction - :ivar category: The category of the subtransaction - :ivar amount: The amount of the subtransaction in milliunits - :ivar memo: The memo of the subtransaction - """ - payee: Payee - category: Optional[Category] - memo: Optional[str] - amount: int diff --git a/ynabtransactionadjuster/models/subtransaction.py b/ynabtransactionadjuster/models/subtransaction.py index 3b5df48..bd5d980 100644 --- a/ynabtransactionadjuster/models/subtransaction.py +++ b/ynabtransactionadjuster/models/subtransaction.py @@ -1,39 +1,20 @@ +from dataclasses import dataclass from typing import Optional -from pydantic import BaseModel, model_validator - from ynabtransactionadjuster.models.category import Category from ynabtransactionadjuster.models.payee import Payee -class SubTransaction(BaseModel): - """YNAB Subtransaction object for creating split transactions. To be used as element in subtransaction attribute of - Transaction class +@dataclass(frozen=True) +class SubTransaction: + """Represents an YNAB Subtransaction as part of an existing split transaction - :ivar amount: The amount of the subtransaction in milliunits - :ivar category: The category of the subtransaction :ivar payee: The payee of the subtransaction + :ivar category: The category of the subtransaction + :ivar amount: The amount of the subtransaction in milliunits :ivar memo: The memo of the subtransaction """ + payee: Payee + category: Optional[Category] + memo: Optional[str] amount: int - payee: Optional[Payee] = None - category: Optional[Category] = None - memo: Optional[str] = None - - def as_dict(self) -> dict: - instance_dict = dict(amount=self.amount) - if self.payee and self.payee.name: - instance_dict['payee_name'] = self.payee.name - if self.payee and self.payee.id: - instance_dict['payee_id'] = self.payee.id - if self.category and self.category.id: - instance_dict['category_id'] = self.category.id - if self.memo: - instance_dict['memo'] = self.memo - return instance_dict - - @model_validator(mode='after') - def check_values(self): - if self.amount == 0: - raise ValueError('Amount needs to be different from 0') - return self diff --git a/ynabtransactionadjuster/models/originaltransaction.py b/ynabtransactionadjuster/models/transaction.py similarity index 61% rename from ynabtransactionadjuster/models/originaltransaction.py rename to ynabtransactionadjuster/models/transaction.py index 3a5f83d..c100f77 100644 --- a/ynabtransactionadjuster/models/originaltransaction.py +++ b/ynabtransactionadjuster/models/transaction.py @@ -4,11 +4,11 @@ from ynabtransactionadjuster.models.category import Category from ynabtransactionadjuster.models.payee import Payee -from ynabtransactionadjuster.models.originalsubtransaction import OriginalSubTransaction +from ynabtransactionadjuster.models.subtransaction import SubTransaction @dataclass(frozen=True, eq=True) -class OriginalTransaction: +class Transaction: """Represents original transaction from YNAB :ivar id: The original transaction id @@ -32,12 +32,12 @@ class OriginalTransaction: flag_color: Optional[Literal['red', 'green', 'blue', 'orange', 'purple', 'yellow']] import_payee_name_original: Optional[str] import_payee_name: Optional[str] - subtransactions: Tuple[OriginalSubTransaction, ...] + subtransactions: Tuple[SubTransaction, ...] cleared: Literal['uncleared', 'cleared', 'reconciled'] approved: bool @classmethod - def from_dict(cls, t_dict: dict) -> 'OriginalTransaction': + def from_dict(cls, t_dict: dict) -> 'Transaction': def build_category(t_dict: dict) -> Optional[Category]: if not t_dict['category_name'] in ('Uncategorized', 'Split'): @@ -47,24 +47,24 @@ def build_payee(t_dict: dict) -> Payee: return Payee(id=t_dict['payee_id'], name=t_dict['payee_name'], transfer_account_id=t_dict['transfer_account_id']) - def build_subtransaction(s_dict: dict) -> OriginalSubTransaction: - return OriginalSubTransaction(payee=build_payee(s_dict), - category=build_category(s_dict), - amount=s_dict['amount'], - memo=s_dict['memo']) + def build_subtransaction(s_dict: dict) -> SubTransaction: + return SubTransaction(payee=build_payee(s_dict), + category=build_category(s_dict), + amount=s_dict['amount'], + memo=s_dict['memo']) - return OriginalTransaction(id=t_dict['id'], - transaction_date=datetime.strptime(t_dict['date'], '%Y-%m-%d').date(), - category=build_category(t_dict), - memo=t_dict['memo'], - import_payee_name_original=t_dict['import_payee_name_original'], - import_payee_name=t_dict['import_payee_name'], - flag_color=t_dict['flag_color'], - payee=build_payee(t_dict), - subtransactions=tuple([build_subtransaction(st) for st in t_dict['subtransactions']]), - amount=t_dict['amount'], - approved=t_dict['approved'], - cleared=t_dict['cleared']) + return Transaction(id=t_dict['id'], + transaction_date=datetime.strptime(t_dict['date'], '%Y-%m-%d').date(), + category=build_category(t_dict), + memo=t_dict['memo'], + import_payee_name_original=t_dict['import_payee_name_original'], + import_payee_name=t_dict['import_payee_name'], + flag_color=t_dict['flag_color'], + payee=build_payee(t_dict), + subtransactions=tuple([build_subtransaction(st) for st in t_dict['subtransactions']]), + amount=t_dict['amount'], + approved=t_dict['approved'], + cleared=t_dict['cleared']) def as_dict(self) -> dict: return asdict(self) diff --git a/ynabtransactionadjuster/serializer.py b/ynabtransactionadjuster/serializer.py index afb3e7d..732a644 100644 --- a/ynabtransactionadjuster/serializer.py +++ b/ynabtransactionadjuster/serializer.py @@ -1,13 +1,13 @@ from typing import List, Callable, Optional from ynabtransactionadjuster.exceptions import AdjustError, NoMatchingCategoryError -from ynabtransactionadjuster.models import OriginalTransaction, ModifiedTransaction, TransactionModifier, Category +from ynabtransactionadjuster.models import Transaction, ModifiedTransaction, Modifier, Category from ynabtransactionadjuster.repos import CategoryRepo class Serializer: - def __init__(self, transactions: List[OriginalTransaction], adjust_func: Callable, categories: CategoryRepo): + def __init__(self, transactions: List[Transaction], adjust_func: Callable, categories: CategoryRepo): self._transactions = transactions self._adjust_func = adjust_func self._categories = categories @@ -18,8 +18,8 @@ def run(self) -> List[ModifiedTransaction]: filtered_transactions = [t for t in modified_transactions if t.is_changed()] return filtered_transactions - def adjust_single(self, original: OriginalTransaction, adjust_func: Callable) -> ModifiedTransaction: - modifier = TransactionModifier.from_original_transaction(original_transaction=original) + def adjust_single(self, original: Transaction, adjust_func: Callable) -> ModifiedTransaction: + modifier = Modifier.from_original_transaction(original_transaction=original) try: modifier_return = adjust_func(original=original, modifier=modifier) self.validate_instance(modifier_return) @@ -36,10 +36,10 @@ def validate_category(self, category: Category): self._categories.fetch_by_id(category.id) @staticmethod - def validate_attributes(modifier: TransactionModifier): - TransactionModifier.model_validate(modifier.__dict__) + def validate_attributes(modifier: Modifier): + Modifier.model_validate(modifier.__dict__) @staticmethod - def validate_instance(modifier: Optional[TransactionModifier]): - if not isinstance(modifier, TransactionModifier): + def validate_instance(modifier: Optional[Modifier]): + if not isinstance(modifier, Modifier): raise AdjustError(f"Adjust function doesn't return TransactionModifier object") From cd5eb9afb93a292720c6d2547b0b81d972d2a7a2 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sun, 21 Apr 2024 08:57:59 +0200 Subject: [PATCH 14/39] implemented fetch_all method for categories --- ynabtransactionadjuster/repos/categoryrepo.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/ynabtransactionadjuster/repos/categoryrepo.py b/ynabtransactionadjuster/repos/categoryrepo.py index 47aea6d..8bc372c 100644 --- a/ynabtransactionadjuster/repos/categoryrepo.py +++ b/ynabtransactionadjuster/repos/categoryrepo.py @@ -8,10 +8,10 @@ class CategoryRepo: """Repository which holds all categories from your YNAB budget - :ivar categories: List of Category Groups in YNAB budget + :ivar _categories: List of Category Groups in YNAB budget """ def __init__(self, categories: List[CategoryGroup]): - self.categories = categories + self._categories = categories def fetch_by_name(self, category_name: str, group_name: str = None) -> Category: """Fetches a YNAB category by its name @@ -23,9 +23,9 @@ def fetch_by_name(self, category_name: str, group_name: str = None) -> Category: :raises MultipleMatchingCategoriesError: if multiple matching categories are found """ if group_name: - cat_groups = [c for c in self.categories if c.name == group_name] + cat_groups = [c for c in self._categories if c.name == group_name] else: - cat_groups = self.categories + cat_groups = self._categories cats = [c for cg in cat_groups for c in cg.categories if category_name == c.name] @@ -42,6 +42,14 @@ def fetch_by_id(self, category_id: str) -> Category: :raises NoMatchingCategoryError: if no matching category is found """ try: - return next(c for cg in self.categories for c in cg.categories if c.id == category_id) + return next(c for cg in self._categories for c in cg.categories if c.id == category_id) except StopIteration: raise NoMatchingCategoryError(category_id) + + def fetch_all(self) -> Dict[str, List[Category]]: + """Fetches all Categories from YNAB budget + + :return: Dictionary with group names as keys and list of categories as values + """ + + return {cg.name: list(cg._categories) for cg in self._categories} From 47423af974434267d772c4979710e36327f974bc Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sun, 21 Apr 2024 08:59:11 +0200 Subject: [PATCH 15/39] implemented fetch_all functionality for payees --- ynabtransactionadjuster/repos/payeerepo.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/ynabtransactionadjuster/repos/payeerepo.py b/ynabtransactionadjuster/repos/payeerepo.py index 5459246..421b862 100644 --- a/ynabtransactionadjuster/repos/payeerepo.py +++ b/ynabtransactionadjuster/repos/payeerepo.py @@ -7,10 +7,10 @@ class PayeeRepo: """Repository which holds all payees from your YNAB budget - :ivar payees: List of payees in YNAB budget + :ivar _payees: List of payees in YNAB budget """ def __init__(self, payees: List[Payee]): - self.payees = payees + self._payees = payees def fetch_by_name(self, payee_name: str) -> Payee: """Fetches a payee by its name @@ -20,7 +20,7 @@ def fetch_by_name(self, payee_name: str) -> Payee: :raises NoMatchingPayeeError: if no matching payee is found """ try: - return next(p for p in self.payees if p.name == payee_name) + return next(p for p in self._payees if p.name == payee_name) except StopIteration: raise NoMatchingPayeeError(f"No payee with name '{payee_name}") @@ -32,7 +32,7 @@ def fetch_by_id(self, payee_id: str) -> Payee: :raises NoMatchingPayeeError: if no matching payee is found """ try: - return next(p for p in self.payees if p.id == payee_id) + return next(p for p in self._payees if p.id == payee_id) except StopIteration: raise NoMatchingPayeeError(f"No payee with id '{payee_id}") @@ -44,6 +44,12 @@ def fetch_by_transfer_account_id(self, transfer_account_id: str) -> Payee: :raises NoMatchingPayeeError: if no matching payee is found """ try: - return next(p for p in self.payees if p.transfer_account_id == transfer_account_id) + return next(p for p in self._payees if p.transfer_account_id == transfer_account_id) except StopIteration: raise NoMatchingPayeeError(f"No payee found for transfer_account_id {transfer_account_id}") + + def fetch_all(self) -> List[Payee]: + """Fetches all payees from YNAB budget + + :return: List of all payees in budget""" + return self._payees From 4d0235ab32fe9cb1b295a29b6ad7ea926bd9cd17 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sun, 21 Apr 2024 09:01:19 +0200 Subject: [PATCH 16/39] implemented test for fetch_all --- tests/test_categoryrepo.py | 6 ++++++ ynabtransactionadjuster/repos/categoryrepo.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_categoryrepo.py b/tests/test_categoryrepo.py index 9fbc29f..4ef5127 100644 --- a/tests/test_categoryrepo.py +++ b/tests/test_categoryrepo.py @@ -27,3 +27,9 @@ def test_fetch_by_id_fail(mock_category_repo): with pytest.raises(NoMatchingCategoryError): mock_category_repo.fetch_by_id(category_id='xxx') + +def test_fetch_all(mock_category_repo): + r = mock_category_repo.fetch_all() + assert isinstance(r, dict) + assert r['group1'][0].id == 'cid1' + assert r['group2'][0].id == 'cid2' diff --git a/ynabtransactionadjuster/repos/categoryrepo.py b/ynabtransactionadjuster/repos/categoryrepo.py index 8bc372c..dc9d966 100644 --- a/ynabtransactionadjuster/repos/categoryrepo.py +++ b/ynabtransactionadjuster/repos/categoryrepo.py @@ -52,4 +52,4 @@ def fetch_all(self) -> Dict[str, List[Category]]: :return: Dictionary with group names as keys and list of categories as values """ - return {cg.name: list(cg._categories) for cg in self._categories} + return {cg.name: list(cg.categories) for cg in self._categories} From 500c26ed1e1b018b3de877f3a9855eeb9f23b0e1 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sun, 21 Apr 2024 09:06:48 +0200 Subject: [PATCH 17/39] updated with fetch_all references --- docs/detailed_usage.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/detailed_usage.md b/docs/detailed_usage.md index d4ff32f..f26f0e2 100644 --- a/docs/detailed_usage.md +++ b/docs/detailed_usage.md @@ -2,9 +2,10 @@ ## Change the category The `adjust()` method allows changing the category of the transaction. For that purpose the adjuster class comes with a [`CategoryRepo`][repos.CategoryRepo] instance attached which can be used in the method via `self.categories`. The repo -can be called with either `fetch_by_name()` or `fetch_by_id()` method to fetch a valid category. Using the repo is -recommended to ensure you only assign valid categories to the modifier. The library doesn't allow creating new -categories and specifying a non-existing category will raise an error. +can be called with either `fetch_by_name()` or `fetch_by_id()` method to fetch a valid category. `fetch_all()` will +return a `dict` with group names as key and a list of categories as values. It can be used for custom search patterns +if needed. Using the category lookup is recommended to ensure only assign valid categories are assigned. The library +doesn't allow creating new categories and specifying a non-existing category will raise an error. ```py from ynabtransactionadjuster import Adjuster From f32bf7a2b804ceb46527ce54e4a462a72eabe94b Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sun, 21 Apr 2024 09:22:50 +0200 Subject: [PATCH 18/39] renamed classmethod --- tests/test_modifiedtransaction.py | 12 ++++++------ ynabtransactionadjuster/models/modifier.py | 16 ++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/test_modifiedtransaction.py b/tests/test_modifiedtransaction.py index 1611733..75f61d9 100644 --- a/tests/test_modifiedtransaction.py +++ b/tests/test_modifiedtransaction.py @@ -16,7 +16,7 @@ ('cleared', 'cleared')]) def test_is_changed_true(test_attribute, test_input, mock_original_transaction): # Arrange - mock_modifier = Modifier.from_original_transaction(mock_original_transaction) + mock_modifier = Modifier.from_transaction(mock_original_transaction) mock_modifier.__setattr__(test_attribute, test_input) modified = ModifiedTransaction(original_transaction=mock_original_transaction, transaction_modifier=mock_modifier) @@ -33,7 +33,7 @@ def test_is_changed_true(test_attribute, test_input, mock_original_transaction): ]) def test_is_changed_true_none_values_in_original(test_attribute, test_input, mock_original_transaction): # Arrange - mock_modifier = Modifier.from_original_transaction(mock_original_transaction) + mock_modifier = Modifier.from_transaction(mock_original_transaction) mock_modifier.__setattr__(test_attribute, test_input) modified = ModifiedTransaction(original_transaction=mock_original_transaction, transaction_modifier=mock_modifier) @@ -45,7 +45,7 @@ def test_is_changed_true_none_values_in_original(test_attribute, test_input, moc @pytest.mark.parametrize('mock_original_transaction', [None, 'optional_none'], indirect=True) def test_changed_false(mock_original_transaction): # Arrange - mock_modifier = Modifier.from_original_transaction(mock_original_transaction) + mock_modifier = Modifier.from_transaction(mock_original_transaction) modified = ModifiedTransaction(original_transaction=mock_original_transaction, transaction_modifier=mock_modifier) # Act @@ -56,7 +56,7 @@ def test_changed_false(mock_original_transaction): @pytest.mark.parametrize('mock_original_transaction', ['subtransactions'], indirect=True) def test_invalid_subtransactions(mock_original_transaction, mock_subtransaction): # Arrange - mock_modifier = Modifier.from_original_transaction(mock_original_transaction) + mock_modifier = Modifier.from_transaction(mock_original_transaction) mock_modifier.subtransactions = [mock_subtransaction, mock_subtransaction] with pytest.raises(ValidationError): ModifiedTransaction(original_transaction=mock_original_transaction, transaction_modifier=mock_modifier) @@ -64,7 +64,7 @@ def test_invalid_subtransactions(mock_original_transaction, mock_subtransaction) def test_as_dict(mock_original_transaction, mock_subtransaction): # Arrange - mock_modifier = Modifier.from_original_transaction(mock_original_transaction) + mock_modifier = Modifier.from_transaction(mock_original_transaction) mock_modifier.payee = Payee(id='pid2', name='pname2') mock_modifier.category = Category(id='cid2', name='cname2') mock_modifier.flag_color = 'blue' @@ -89,7 +89,7 @@ def test_as_dict(mock_original_transaction, mock_subtransaction): def test_as_dict_none_values(mock_original_transaction): # Arrange - mock_modifier = Modifier.from_original_transaction(mock_original_transaction) + mock_modifier = Modifier.from_transaction(mock_original_transaction) mock_modifier.category = None mock_modifier.flag_color = None mt = ModifiedTransaction(original_transaction=mock_original_transaction, transaction_modifier=mock_modifier) diff --git a/ynabtransactionadjuster/models/modifier.py b/ynabtransactionadjuster/models/modifier.py index ddae410..0db85e3 100644 --- a/ynabtransactionadjuster/models/modifier.py +++ b/ynabtransactionadjuster/models/modifier.py @@ -31,14 +31,14 @@ class Modifier(BaseModel): cleared: Literal['uncleared', 'cleared', 'reconciled'] @classmethod - def from_original_transaction(cls, original_transaction: Transaction): - return cls(transaction_date=original_transaction.transaction_date, - category=original_transaction.category, - payee=original_transaction.payee, - memo=original_transaction.memo, - flag_color=original_transaction.flag_color, - approved=original_transaction.approved, - cleared=original_transaction.cleared, + def from_transaction(cls, transaction: Transaction): + return cls(transaction_date=transaction.transaction_date, + category=transaction.category, + payee=transaction.payee, + memo=transaction.memo, + flag_color=transaction.flag_color, + approved=transaction.approved, + cleared=transaction.cleared, subtransactions=[]) @model_validator(mode='after') From 506681fcbee2fc75906aeb5c7235fff0c881f326 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sun, 21 Apr 2024 09:23:12 +0200 Subject: [PATCH 19/39] made field names of adjust function flexible --- ynabtransactionadjuster/serializer.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/ynabtransactionadjuster/serializer.py b/ynabtransactionadjuster/serializer.py index 732a644..c4a0bb8 100644 --- a/ynabtransactionadjuster/serializer.py +++ b/ynabtransactionadjuster/serializer.py @@ -1,4 +1,5 @@ -from typing import List, Callable, Optional +import inspect +from typing import List, Callable, Optional, OrderedDict from ynabtransactionadjuster.exceptions import AdjustError, NoMatchingCategoryError from ynabtransactionadjuster.models import Transaction, ModifiedTransaction, Modifier, Category @@ -13,23 +14,24 @@ def __init__(self, transactions: List[Transaction], adjust_func: Callable, categ self._categories = categories def run(self) -> List[ModifiedTransaction]: - modified_transactions = [self.adjust_single(original=t, adjust_func=self._adjust_func) + modified_transactions = [self.adjust_single(transaction=t, adjust_func=self._adjust_func) for t in self._transactions] filtered_transactions = [t for t in modified_transactions if t.is_changed()] return filtered_transactions - def adjust_single(self, original: Transaction, adjust_func: Callable) -> ModifiedTransaction: - modifier = Modifier.from_original_transaction(original_transaction=original) + def adjust_single(self, transaction: Transaction, adjust_func: Callable) -> ModifiedTransaction: + modifier = Modifier.from_transaction(transaction=transaction) try: - modifier_return = adjust_func(original=original, modifier=modifier) + transaction_field, modifier_field = self.find_field_names(adjust_func) + modifier_return = adjust_func(**{transaction_field: transaction, modifier_field: modifier}) self.validate_instance(modifier_return) self.validate_attributes(modifier_return) self.validate_category(modifier_return.category) - modified_transaction = ModifiedTransaction(original_transaction=original, - transaction_modifier=modifier_return) + modified_transaction = ModifiedTransaction(original_transaction=transaction, + transaction_modifier=modifier_return) return modified_transaction except Exception as e: - raise AdjustError(f"Error while adjusting {original.as_dict()}") from e + raise AdjustError(f"Error while adjusting {transaction.as_dict()}") from e def validate_category(self, category: Category): if category: @@ -43,3 +45,10 @@ def validate_attributes(modifier: Modifier): def validate_instance(modifier: Optional[Modifier]): if not isinstance(modifier, Modifier): raise AdjustError(f"Adjust function doesn't return TransactionModifier object") + + @staticmethod + def find_field_names(adjust_func: Callable) -> (str, str): + args_dict = inspect.signature(adjust_func).parameters + transaction_field = next(k for k, v in args_dict.items() if v.annotation == Transaction) + modifier_field = next(k for k, v in args_dict.items() if v.annotation == Modifier) + return transaction_field, modifier_field From b1fd06256a5ef2c926c4a4db2e5489f7882b0a74 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sun, 21 Apr 2024 09:30:32 +0200 Subject: [PATCH 20/39] updated tests --- .../models/modifiedtransaction.py | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/ynabtransactionadjuster/models/modifiedtransaction.py b/ynabtransactionadjuster/models/modifiedtransaction.py index ee5e0a0..8e70d33 100644 --- a/ynabtransactionadjuster/models/modifiedtransaction.py +++ b/ynabtransactionadjuster/models/modifiedtransaction.py @@ -7,8 +7,8 @@ class ModifiedTransaction(BaseModel): - original_transaction: Transaction - transaction_modifier: Modifier + transaction: Transaction + modifier: Modifier def is_changed(self) -> bool: """Helper function to determine if transaction has been altered as compared to original one @@ -21,20 +21,20 @@ def is_changed(self) -> bool: def as_dict(self) -> dict: """Returns a dictionary representation of the transaction which is used for the update call to YNAB""" - t_dict = dict(id=self.original_transaction.id, - payee_name=self.transaction_modifier.payee.name, - payee_id=self.transaction_modifier.payee.id, - date=datetime.strftime(self.transaction_modifier.transaction_date, '%Y-%m-%d'), - approved=self.transaction_modifier.approved, - cleared=self.transaction_modifier.cleared) - if len(self.transaction_modifier.subtransactions) > 0: - t_dict['subtransactions'] = [s.as_dict() for s in self.transaction_modifier.subtransactions] - if self.transaction_modifier.category: - t_dict['category_id'] = self.transaction_modifier.category.id - if self.transaction_modifier.flag_color: - t_dict['flag_color'] = self.transaction_modifier.flag_color - if self.transaction_modifier.memo: - t_dict['memo'] = self.transaction_modifier.memo + t_dict = dict(id=self.transaction.id, + payee_name=self.modifier.payee.name, + payee_id=self.modifier.payee.id, + date=datetime.strftime(self.modifier.transaction_date, '%Y-%m-%d'), + approved=self.modifier.approved, + cleared=self.modifier.cleared) + if len(self.modifier.subtransactions) > 0: + t_dict['subtransactions'] = [s.as_dict() for s in self.modifier.subtransactions] + if self.modifier.category: + t_dict['category_id'] = self.modifier.category.id + if self.modifier.flag_color: + t_dict['flag_color'] = self.modifier.flag_color + if self.modifier.memo: + t_dict['memo'] = self.modifier.memo return t_dict @@ -46,30 +46,30 @@ def changed_attributes(self) -> dict: if self._attribute_changed(a): changed_attributes[a] = self._create_changed_dict(a) - if (self.transaction_modifier.transaction_date.isocalendar() != - self.original_transaction.transaction_date.isocalendar()): + if (self.modifier.transaction_date.isocalendar() != + self.transaction.transaction_date.isocalendar()): changed_attributes['transaction_date'] = self._create_changed_dict('transaction_date') - if len(self.transaction_modifier.subtransactions) > 0: + if len(self.modifier.subtransactions) > 0: changed_attributes['subtransactions'] = self._create_changed_dict('subtransactions') return changed_attributes def _attribute_changed(self, attribute: str) -> bool: - o = self.original_transaction.__getattribute__(attribute) - m = self.transaction_modifier.__getattribute__(attribute) + o = self.transaction.__getattribute__(attribute) + m = self.modifier.__getattribute__(attribute) if o != m: return True def _create_changed_dict(self, attribute: str) -> dict: - return dict(original=self.original_transaction.__getattribute__(attribute), - changed=self.transaction_modifier.__getattribute__(attribute)) + return dict(original=self.transaction.__getattribute__(attribute), + changed=self.modifier.__getattribute__(attribute)) @model_validator(mode='after') def check_values(self): - if len(self.transaction_modifier.subtransactions) > 1: - if len(self.original_transaction.subtransactions) > 1: + if len(self.modifier.subtransactions) > 1: + if len(self.transaction.subtransactions) > 1: raise ValueError(f"Existing Subtransactions can not be updated") - if sum(a.amount for a in self.transaction_modifier.subtransactions) != self.original_transaction.amount: + if sum(a.amount for a in self.modifier.subtransactions) != self.transaction.amount: raise ValueError('Amount of subtransactions needs to be equal to amount of original transaction') return self From aa759cf4c8d739319aed849c1b5279f0f36bf73b Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sun, 21 Apr 2024 09:30:39 +0200 Subject: [PATCH 21/39] updated tests --- tests/test_modifiedtransaction.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/test_modifiedtransaction.py b/tests/test_modifiedtransaction.py index 75f61d9..35ed9c1 100644 --- a/tests/test_modifiedtransaction.py +++ b/tests/test_modifiedtransaction.py @@ -18,7 +18,7 @@ def test_is_changed_true(test_attribute, test_input, mock_original_transaction): # Arrange mock_modifier = Modifier.from_transaction(mock_original_transaction) mock_modifier.__setattr__(test_attribute, test_input) - modified = ModifiedTransaction(original_transaction=mock_original_transaction, transaction_modifier=mock_modifier) + modified = ModifiedTransaction(transaction=mock_original_transaction, modifier=mock_modifier) # Act @@ -35,7 +35,7 @@ def test_is_changed_true_none_values_in_original(test_attribute, test_input, moc # Arrange mock_modifier = Modifier.from_transaction(mock_original_transaction) mock_modifier.__setattr__(test_attribute, test_input) - modified = ModifiedTransaction(original_transaction=mock_original_transaction, transaction_modifier=mock_modifier) + modified = ModifiedTransaction(transaction=mock_original_transaction, modifier=mock_modifier) # Act r = modified.is_changed() @@ -46,7 +46,7 @@ def test_is_changed_true_none_values_in_original(test_attribute, test_input, moc def test_changed_false(mock_original_transaction): # Arrange mock_modifier = Modifier.from_transaction(mock_original_transaction) - modified = ModifiedTransaction(original_transaction=mock_original_transaction, transaction_modifier=mock_modifier) + modified = ModifiedTransaction(transaction=mock_original_transaction, modifier=mock_modifier) # Act r = modified.is_changed() @@ -59,7 +59,7 @@ def test_invalid_subtransactions(mock_original_transaction, mock_subtransaction) mock_modifier = Modifier.from_transaction(mock_original_transaction) mock_modifier.subtransactions = [mock_subtransaction, mock_subtransaction] with pytest.raises(ValidationError): - ModifiedTransaction(original_transaction=mock_original_transaction, transaction_modifier=mock_modifier) + ModifiedTransaction(transaction=mock_original_transaction, modifier=mock_modifier) def test_as_dict(mock_original_transaction, mock_subtransaction): @@ -69,17 +69,17 @@ def test_as_dict(mock_original_transaction, mock_subtransaction): mock_modifier.category = Category(id='cid2', name='cname2') mock_modifier.flag_color = 'blue' mock_modifier.subtransactions = [mock_subtransaction, mock_subtransaction] - mt = ModifiedTransaction(original_transaction=mock_original_transaction, transaction_modifier=mock_modifier) + mt = ModifiedTransaction(transaction=mock_original_transaction, modifier=mock_modifier) # Act d = mt.as_dict() # Assert assert d['id'] == mock_original_transaction.id - assert d['payee_name'] == mt.transaction_modifier.payee.name - assert d['payee_id'] == mt.transaction_modifier.payee.id - assert d['category_id'] == mt.transaction_modifier.category.id - assert d['flag_color'] == mt.transaction_modifier.flag_color + assert d['payee_name'] == mt.modifier.payee.name + assert d['payee_id'] == mt.modifier.payee.id + assert d['category_id'] == mt.modifier.category.id + assert d['flag_color'] == mt.modifier.flag_color assert len(d['subtransactions']) == 2 assert isinstance(d['subtransactions'][0], dict) assert d['date'] == datetime.strftime(mock_modifier.transaction_date, '%Y-%m-%d') @@ -92,7 +92,7 @@ def test_as_dict_none_values(mock_original_transaction): mock_modifier = Modifier.from_transaction(mock_original_transaction) mock_modifier.category = None mock_modifier.flag_color = None - mt = ModifiedTransaction(original_transaction=mock_original_transaction, transaction_modifier=mock_modifier) + mt = ModifiedTransaction(transaction=mock_original_transaction, modifier=mock_modifier) # Act d = mt.as_dict() From 609b83a735c80580d4668929da97fed3361767ab Mon Sep 17 00:00:00 2001 From: dnbasta Date: Sun, 21 Apr 2024 09:31:09 +0200 Subject: [PATCH 22/39] simplified modifiedtransaction attributes --- ynabtransactionadjuster/adjuster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ynabtransactionadjuster/adjuster.py b/ynabtransactionadjuster/adjuster.py index 7448e88..bb50bca 100644 --- a/ynabtransactionadjuster/adjuster.py +++ b/ynabtransactionadjuster/adjuster.py @@ -72,7 +72,7 @@ def dry_run(self) -> List[dict]: """ filtered_transactions = self.filter(self.transactions) s = Serializer(transactions=self.transactions, adjust_func=self.adjust, categories=self.categories) - modified_transactions = [{'original': mt.original_transaction, 'changes': mt.changed_attributes()} for mt in s.run()] + modified_transactions = [{'original': mt.transaction, 'changes': mt.changed_attributes()} for mt in s.run()] return modified_transactions def run(self) -> int: From a0e220d950ff166600a9440919df03a01d87def8 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Mon, 22 Apr 2024 07:01:55 +0200 Subject: [PATCH 23/39] made signature names variable, introduced parameter requirement check --- ynabtransactionadjuster/adjuster.py | 22 ++++++++++++++++------ ynabtransactionadjuster/exceptions.py | 4 ++++ ynabtransactionadjuster/serializer.py | 23 +++++++++++++++-------- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/ynabtransactionadjuster/adjuster.py b/ynabtransactionadjuster/adjuster.py index bb50bca..68cb7a9 100644 --- a/ynabtransactionadjuster/adjuster.py +++ b/ynabtransactionadjuster/adjuster.py @@ -1,6 +1,8 @@ -from abc import abstractmethod -from typing import List +import inspect +from abc import abstractmethod, ABCMeta +from typing import List, Callable +from ynabtransactionadjuster.exceptions import SignatureError from ynabtransactionadjuster.models.credentials import Credentials from ynabtransactionadjuster.client import Client from ynabtransactionadjuster.models import Transaction @@ -10,7 +12,7 @@ from ynabtransactionadjuster.serializer import Serializer -class Adjuster: +class Adjuster(metaclass=ABCMeta): """Abstract class which modifies transactions according to concrete implementation. You need to create your own child class and implement the `filter()`and `adjust()` method in it according to your needs. It has attributes which allow you to lookup categories and payees from your budget. @@ -50,11 +52,11 @@ def filter(self, transactions: List[Transaction]) -> List[Transaction]: pass @abstractmethod - def adjust(self, original: Transaction, modifier: Modifier) -> Modifier: + def adjust(self, transaction: Transaction, modifier: Modifier) -> Modifier: """Function which implements the actual modification of a transaction. It receives the original transaction from YNAB and a prefilled modifier. The modifier can be altered and must be returned. - :param original: Original transaction + :param transaction: Original transaction :param modifier: Transaction modifier prefilled with values from original transaction. All attributes can be changed and will modify the transaction :returns: Method needs to return the transaction modifier after modification @@ -70,8 +72,9 @@ def dry_run(self) -> List[dict]: :raises AdjustError: if there is any error during the adjust process :raises HTTPError: if there is any error with the YNAB API (e.g. wrong credentials) """ + self.check_signature(self.filter) filtered_transactions = self.filter(self.transactions) - s = Serializer(transactions=self.transactions, adjust_func=self.adjust, categories=self.categories) + s = Serializer(transactions=filtered_transactions, adjust_func=self.adjust, categories=self.categories) modified_transactions = [{'original': mt.transaction, 'changes': mt.changed_attributes()} for mt in s.run()] return modified_transactions @@ -83,6 +86,7 @@ def run(self) -> int: :raises AdjustError: if there is any error during the adjust process :raises HTTPError: if there is any error with the YNAB API (e.g. wrong credentials) """ + self.check_signature(self.filter) filtered_transactions = self.filter(self.transactions) s = Serializer(transactions=filtered_transactions, adjust_func=self.adjust, categories=self.categories) modified_transactions = s.run() @@ -91,3 +95,9 @@ def run(self) -> int: updated = client.update_transactions(modified_transactions) return updated return 0 + + @staticmethod + def check_signature(func: Callable): + args_dict = inspect.signature(func).parameters + if len(args_dict) != 1: + raise SignatureError(f"Function '{func.__name__}' needs to have exactly one parameter") diff --git a/ynabtransactionadjuster/exceptions.py b/ynabtransactionadjuster/exceptions.py index d18e44c..fe5ed1a 100644 --- a/ynabtransactionadjuster/exceptions.py +++ b/ynabtransactionadjuster/exceptions.py @@ -25,3 +25,7 @@ class AdjustError(Exception): """Raised when an error occurs while running the factory on a transaction or during validation of the returned results of the run""" pass + + +class SignatureError(Exception): + """ Raised when function is not defined with right signature""" diff --git a/ynabtransactionadjuster/serializer.py b/ynabtransactionadjuster/serializer.py index c4a0bb8..adcf5c7 100644 --- a/ynabtransactionadjuster/serializer.py +++ b/ynabtransactionadjuster/serializer.py @@ -1,7 +1,7 @@ import inspect -from typing import List, Callable, Optional, OrderedDict +from typing import List, Callable, Optional -from ynabtransactionadjuster.exceptions import AdjustError, NoMatchingCategoryError +from ynabtransactionadjuster.exceptions import AdjustError, SignatureError from ynabtransactionadjuster.models import Transaction, ModifiedTransaction, Modifier, Category from ynabtransactionadjuster.repos import CategoryRepo @@ -21,14 +21,14 @@ def run(self) -> List[ModifiedTransaction]: def adjust_single(self, transaction: Transaction, adjust_func: Callable) -> ModifiedTransaction: modifier = Modifier.from_transaction(transaction=transaction) + transaction_field, modifier_field = self.find_field_names(adjust_func) + modifier_return = adjust_func(**{transaction_field: transaction, modifier_field: modifier}) try: - transaction_field, modifier_field = self.find_field_names(adjust_func) - modifier_return = adjust_func(**{transaction_field: transaction, modifier_field: modifier}) self.validate_instance(modifier_return) self.validate_attributes(modifier_return) self.validate_category(modifier_return.category) - modified_transaction = ModifiedTransaction(original_transaction=transaction, - transaction_modifier=modifier_return) + modified_transaction = ModifiedTransaction(transaction=transaction, + modifier=modifier_return) return modified_transaction except Exception as e: raise AdjustError(f"Error while adjusting {transaction.as_dict()}") from e @@ -49,6 +49,13 @@ def validate_instance(modifier: Optional[Modifier]): @staticmethod def find_field_names(adjust_func: Callable) -> (str, str): args_dict = inspect.signature(adjust_func).parameters - transaction_field = next(k for k, v in args_dict.items() if v.annotation == Transaction) - modifier_field = next(k for k, v in args_dict.items() if v.annotation == Modifier) + if len(args_dict) != 2: + raise SignatureError(f"function '{adjust_func.__name__}' needs to have exactly two parameters") + try: + transaction_field = next(k for k, v in args_dict.items() if v.annotation == Transaction) + modifier_field = next(k for k, v in args_dict.items() if v.annotation == Modifier) + except StopIteration: + field_iterator = iter(args_dict) + transaction_field = next(field_iterator) + modifier_field = next(field_iterator) return transaction_field, modifier_field From 43d4f7e79d52841de693110d2106b68564aee658 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Mon, 22 Apr 2024 07:02:25 +0200 Subject: [PATCH 24/39] updated signature name --- tests/test_adjuster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_adjuster.py b/tests/test_adjuster.py index acbdddb..86254a2 100644 --- a/tests/test_adjuster.py +++ b/tests/test_adjuster.py @@ -17,7 +17,7 @@ def __init__(self, memo: str): def filter(self, transactions): return transactions - def adjust(self, original, modifier): + def adjust(self, transaction, modifier): modifier.memo = self.memo return modifier From 625cc1c3fdb116c7fb3d1a542616ab4f00a0ee95 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Mon, 22 Apr 2024 07:07:05 +0200 Subject: [PATCH 25/39] made adjuster dataclass --- ynabtransactionadjuster/adjuster.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ynabtransactionadjuster/adjuster.py b/ynabtransactionadjuster/adjuster.py index 68cb7a9..ecf0719 100644 --- a/ynabtransactionadjuster/adjuster.py +++ b/ynabtransactionadjuster/adjuster.py @@ -1,5 +1,6 @@ import inspect from abc import abstractmethod, ABCMeta +from dataclasses import dataclass from typing import List, Callable from ynabtransactionadjuster.exceptions import SignatureError @@ -12,6 +13,7 @@ from ynabtransactionadjuster.serializer import Serializer +@dataclass class Adjuster(metaclass=ABCMeta): """Abstract class which modifies transactions according to concrete implementation. You need to create your own child class and implement the `filter()`and `adjust()` method in it according to your needs. It has attributes @@ -22,12 +24,10 @@ class Adjuster(metaclass=ABCMeta): :ivar transactions: Transactions from YNAB Account :ivar credentials: Credentials for YNAB API """ - def __init__(self, categories: CategoryRepo, payees: PayeeRepo, transactions: List[Transaction], - credentials: Credentials) -> None: - self.categories = categories - self.payees = payees - self.transactions = transactions - self.credentials = credentials + credentials: Credentials + categories: CategoryRepo + payees: PayeeRepo + transactions: List[Transaction] @classmethod def from_credentials(cls, credentials: Credentials): From b7fb9e9ef265fd901725a97007a4d955ff89441e Mon Sep 17 00:00:00 2001 From: dnbasta Date: Mon, 22 Apr 2024 07:13:16 +0200 Subject: [PATCH 26/39] updated docs --- README.md | 5 ++--- docs/basic_usage.md | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2e15876..385f894 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,8 @@ A detailed documentation is available at https://ynab-transaction-adjuster.readt ### Create an Adjuster Create a child class of `Adjuster`. This class needs to implement a `filter()` and an `adjust()` method which contain the intended logic. The `filter()` method receives a list of `Transaction` objects which can be filtered before -adjustement. The `adjust()` method receives a singular `Transaction` and a `Modifier`. The latter is -prefilled with values from the original transaction. Its attributes can be modified, and it needs to be returned at -the end of the function. +adjustement. The `adjust()` method receives a single `Transaction` and a `Modifier`.The latter is prefilled with values +from the original transaction and can be altered. The modifier needs to be returned at the end of the function. Please check the [detailed usage](https://ynab-transaction-adjuster.readthedocs.io/en/latest/detailed_usage/) section for explanations how to change different attributes. diff --git a/docs/basic_usage.md b/docs/basic_usage.md index a30f1af..721686d 100644 --- a/docs/basic_usage.md +++ b/docs/basic_usage.md @@ -4,9 +4,9 @@ Create a child class of [`Adjuster`][ynabtransactionadjuster.Adjuster]. This class needs to implement a `filter()` and an `adjust()` method which contain the intended logic. The `filter()` method receives a list of [`Transaction`][models.Transaction] objects which can be filtered before -adjustement. The `adjust()` method receives a singular [`Transaction`][models.Transaction] and a -[`Modifier`][models.Modifier]. The latter is prefilled with values from the original transaction. -Its attributes can be modified, and it needs to be returned at the end of the function. +adjustement. The `adjust()` method receives a single [`Transaction`][models.Transaction] and a +[`Modifier`][models.Modifier]. The latter is prefilled with values from the original transaction and can be altered. +The modifier needs to be returned at the end of the function. Please check the [detailed usage](detailed_usage.md) section for explanations how to change different attributes. ```py From c20f1f46d6f3d266fcc85d3aa72661978917f4ef Mon Sep 17 00:00:00 2001 From: dnbasta Date: Mon, 22 Apr 2024 07:29:08 +0200 Subject: [PATCH 27/39] made dry run return modifiedtransaction --- ynabtransactionadjuster/adjuster.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ynabtransactionadjuster/adjuster.py b/ynabtransactionadjuster/adjuster.py index ecf0719..423bbe7 100644 --- a/ynabtransactionadjuster/adjuster.py +++ b/ynabtransactionadjuster/adjuster.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from typing import List, Callable +from ynabtransactionadjuster.models import ModifiedTransaction from ynabtransactionadjuster.exceptions import SignatureError from ynabtransactionadjuster.models.credentials import Credentials from ynabtransactionadjuster.client import Client @@ -63,19 +64,19 @@ def adjust(self, transaction: Transaction, modifier: Modifier) -> Modifier: """ pass - def dry_run(self) -> List[dict]: + def dry_run(self) -> List[ModifiedTransaction]: """Tests the adjuster. It will fetch transactions from the YNAB account, filter & adjust them as per implementation of the two methods. This function doesn't update records in YNAB but returns the modified transactions so that they can be inspected. - :return: List of modified transactions in the format + :return: List of modified transactions :raises AdjustError: if there is any error during the adjust process :raises HTTPError: if there is any error with the YNAB API (e.g. wrong credentials) """ self.check_signature(self.filter) filtered_transactions = self.filter(self.transactions) s = Serializer(transactions=filtered_transactions, adjust_func=self.adjust, categories=self.categories) - modified_transactions = [{'original': mt.transaction, 'changes': mt.changed_attributes()} for mt in s.run()] + modified_transactions = s.run() return modified_transactions def run(self) -> int: From f19411add6ec0e43f3ea357bf210cb9b13be53aa Mon Sep 17 00:00:00 2001 From: dnbasta Date: Mon, 22 Apr 2024 07:37:18 +0200 Subject: [PATCH 28/39] created representation string --- ynabtransactionadjuster/models/modifiedtransaction.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ynabtransactionadjuster/models/modifiedtransaction.py b/ynabtransactionadjuster/models/modifiedtransaction.py index 8e70d33..0250a23 100644 --- a/ynabtransactionadjuster/models/modifiedtransaction.py +++ b/ynabtransactionadjuster/models/modifiedtransaction.py @@ -19,6 +19,9 @@ def is_changed(self) -> bool: return True return False + def __repr__(self) -> str: + return f"{self.__class__.__name__}(id={self.transaction.id}, modified_attributes={self.changed_attributes()}))" + def as_dict(self) -> dict: """Returns a dictionary representation of the transaction which is used for the update call to YNAB""" t_dict = dict(id=self.transaction.id, From 9e71eb059640f0b74a9fb80d16931d9869f2c3fc Mon Sep 17 00:00:00 2001 From: dnbasta Date: Mon, 22 Apr 2024 07:38:54 +0200 Subject: [PATCH 29/39] made id to visible string in repr --- ynabtransactionadjuster/models/modifiedtransaction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ynabtransactionadjuster/models/modifiedtransaction.py b/ynabtransactionadjuster/models/modifiedtransaction.py index 0250a23..8885f6a 100644 --- a/ynabtransactionadjuster/models/modifiedtransaction.py +++ b/ynabtransactionadjuster/models/modifiedtransaction.py @@ -20,7 +20,7 @@ def is_changed(self) -> bool: return False def __repr__(self) -> str: - return f"{self.__class__.__name__}(id={self.transaction.id}, modified_attributes={self.changed_attributes()}))" + return f"{self.__class__.__name__}(id='{self.transaction.id}', modified_attributes={self.changed_attributes()}))" def as_dict(self) -> dict: """Returns a dictionary representation of the transaction which is used for the update call to YNAB""" From dc2fa928d994380a6a7d30410e3c7bbf7cfd62d5 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Mon, 22 Apr 2024 08:08:28 +0200 Subject: [PATCH 30/39] implemented string representation and pretty_print option for dry run --- ynabtransactionadjuster/adjuster.py | 12 ++++++++---- .../models/modifiedtransaction.py | 6 +++--- ynabtransactionadjuster/models/transaction.py | 3 +++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/ynabtransactionadjuster/adjuster.py b/ynabtransactionadjuster/adjuster.py index 423bbe7..340c57c 100644 --- a/ynabtransactionadjuster/adjuster.py +++ b/ynabtransactionadjuster/adjuster.py @@ -64,19 +64,23 @@ def adjust(self, transaction: Transaction, modifier: Modifier) -> Modifier: """ pass - def dry_run(self) -> List[ModifiedTransaction]: + def dry_run(self, pretty_print: bool = False) -> List[ModifiedTransaction]: """Tests the adjuster. It will fetch transactions from the YNAB account, filter & adjust them as per implementation of the two methods. This function doesn't update records in YNAB but returns the modified transactions so that they can be inspected. + :param pretty_print: if set to True will print modified transactions as strings in console + :return: List of modified transactions :raises AdjustError: if there is any error during the adjust process :raises HTTPError: if there is any error with the YNAB API (e.g. wrong credentials) """ - self.check_signature(self.filter) + self._check_signature(self.filter) filtered_transactions = self.filter(self.transactions) s = Serializer(transactions=filtered_transactions, adjust_func=self.adjust, categories=self.categories) modified_transactions = s.run() + if pretty_print: + print('\n'.join(map(str, modified_transactions))) return modified_transactions def run(self) -> int: @@ -87,7 +91,7 @@ def run(self) -> int: :raises AdjustError: if there is any error during the adjust process :raises HTTPError: if there is any error with the YNAB API (e.g. wrong credentials) """ - self.check_signature(self.filter) + self._check_signature(self.filter) filtered_transactions = self.filter(self.transactions) s = Serializer(transactions=filtered_transactions, adjust_func=self.adjust, categories=self.categories) modified_transactions = s.run() @@ -98,7 +102,7 @@ def run(self) -> int: return 0 @staticmethod - def check_signature(func: Callable): + def _check_signature(func: Callable): args_dict = inspect.signature(func).parameters if len(args_dict) != 1: raise SignatureError(f"Function '{func.__name__}' needs to have exactly one parameter") diff --git a/ynabtransactionadjuster/models/modifiedtransaction.py b/ynabtransactionadjuster/models/modifiedtransaction.py index 8885f6a..f37b4c1 100644 --- a/ynabtransactionadjuster/models/modifiedtransaction.py +++ b/ynabtransactionadjuster/models/modifiedtransaction.py @@ -19,8 +19,8 @@ def is_changed(self) -> bool: return True return False - def __repr__(self) -> str: - return f"{self.__class__.__name__}(id='{self.transaction.id}', modified_attributes={self.changed_attributes()}))" + def __str__(self) -> str: + return f"{self.transaction} | {self.changed_attributes()}" def as_dict(self) -> dict: """Returns a dictionary representation of the transaction which is used for the update call to YNAB""" @@ -69,7 +69,7 @@ def _create_changed_dict(self, attribute: str) -> dict: changed=self.modifier.__getattribute__(attribute)) @model_validator(mode='after') - def check_values(self): + def _check_values(self): if len(self.modifier.subtransactions) > 1: if len(self.transaction.subtransactions) > 1: raise ValueError(f"Existing Subtransactions can not be updated") diff --git a/ynabtransactionadjuster/models/transaction.py b/ynabtransactionadjuster/models/transaction.py index c100f77..3074c28 100644 --- a/ynabtransactionadjuster/models/transaction.py +++ b/ynabtransactionadjuster/models/transaction.py @@ -68,3 +68,6 @@ def build_subtransaction(s_dict: dict) -> SubTransaction: def as_dict(self) -> dict: return asdict(self) + + def __str__(self) -> str: + return f"{self.transaction_date} | {self.payee.name} | {float(self.amount) / 1000:.2f} | {self.memo}" From 5674260940eecd32146c0cc4e8b6e9709976b25e Mon Sep 17 00:00:00 2001 From: dnbasta Date: Mon, 22 Apr 2024 08:15:14 +0200 Subject: [PATCH 31/39] updated documentation --- README.md | 3 ++- docs/basic_usage.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 385f894..a938cd6 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,8 @@ my_adjuster = MyAdjuster.from_credentials(credentials=my_credentials) ### Test Test the adjuster on records fetched via the `dry_run()` method. It executes the adjustments but doesn't write the results back to YNAB. Instead it returns a list of the changed transactions which can be inspected for the changed -properties. +properties. It takes an optional parameter `pretty_print` which, if set to `True`, prints modifications in an easy +readable string representation to the console. ```py mod_transactions = my_adjuster.dry_run() diff --git a/docs/basic_usage.md b/docs/basic_usage.md index 721686d..7862e85 100644 --- a/docs/basic_usage.md +++ b/docs/basic_usage.md @@ -40,7 +40,8 @@ my_adjuster = MyAdjuster.from_credentials(credentials=my_credentials) ### Test Test the adjuster on records fetched via the `dry_run()` method. It executes the adjustments but doesn't write the results back to YNAB. Instead it returns a list of the changed transactions which can be inspected for the changed -properties. +properties. It takes an optional parameter `pretty_print` which, if set to `True`, prints modifications in an easy +readable string representation to the console. ```py mod_transactions = my_adjuster.dry_run() From b450a7d39d0fafe7b48eb14d0bf403aa8a8a1ace Mon Sep 17 00:00:00 2001 From: dnbasta Date: Mon, 22 Apr 2024 11:58:14 +0200 Subject: [PATCH 32/39] implemented signature checker --- tests/test_serializer.py | 34 ++++++++++++ tests/test_signaturechecker.py | 57 +++++++++++++++++++++ ynabtransactionadjuster/adjuster.py | 13 +++-- ynabtransactionadjuster/serializer.py | 43 ++++++++++------ ynabtransactionadjuster/signaturechecker.py | 34 ++++++++++++ 5 files changed, 159 insertions(+), 22 deletions(-) create mode 100644 tests/test_serializer.py create mode 100644 tests/test_signaturechecker.py create mode 100644 ynabtransactionadjuster/signaturechecker.py diff --git a/tests/test_serializer.py b/tests/test_serializer.py new file mode 100644 index 0000000..514dc24 --- /dev/null +++ b/tests/test_serializer.py @@ -0,0 +1,34 @@ +from unittest.mock import PropertyMock, MagicMock + +import pytest + +from ynabtransactionadjuster import Modifier, Transaction +from ynabtransactionadjuster.exceptions import SignatureError +from ynabtransactionadjuster.serializer import Serializer + + +def test_find_field_names_position(): + def mock_func(x, y): + pass + s = Serializer(transactions=[MagicMock()], categories=MagicMock(), adjust_func=mock_func) + tf, mf = s.find_field_names() + assert tf == 'x' + assert mf == 'y' + + +def test_find_field_names_partial_annotation(): + def mock_func(x: Modifier, y): + pass + s = Serializer(transactions=[MagicMock()], adjust_func=mock_func, categories=MagicMock()) + tf, mf = s.find_field_names() + assert tf == 'y' + assert mf == 'x' + + +def test_find_field_names_annotation(): + def mock_func(x: Modifier, y: Transaction): + pass + s = Serializer(transactions=[MagicMock()], adjust_func=mock_func, categories=MagicMock()) + tf, mf = s.find_field_names() + assert tf == 'y' + assert mf == 'x' diff --git a/tests/test_signaturechecker.py b/tests/test_signaturechecker.py new file mode 100644 index 0000000..ab890c5 --- /dev/null +++ b/tests/test_signaturechecker.py @@ -0,0 +1,57 @@ +import pytest + +from ynabtransactionadjuster.exceptions import SignatureError +from ynabtransactionadjuster.signaturechecker import SignatureChecker + + +def mock_parent(self, x: int, y: str): + pass + + +def test_check_parameter_count_success(): + # Arrange + def mock_func(x, y): + pass + + # Act + SignatureChecker(func=mock_func, parent_func=mock_parent).check_parameter_count() + + +def test_check_parameter_count_fail(): + # Arrange + def mock_func(x): + pass + + # Act + with pytest.raises(SignatureError): + SignatureChecker(func=mock_func, parent_func=mock_parent).check_parameter_count() + + +def test_check_annotations_fail_on_type(): + # Arrange + def mock_func(x: dict): + pass + + # Act + with pytest.raises(SignatureError): + SignatureChecker(func=mock_func, parent_func=mock_parent).check_parameter_annotations() + + +def test_check_annotations_fail_on_type_count(): + # Arrange + def mock_func(x: int, y: int): + pass + + # Act + with pytest.raises(SignatureError): + SignatureChecker(func=mock_func, parent_func=mock_parent).check_parameter_annotations() + + +def test_check_annotation_type_success(): + + # Arrange + def mock_func(x: int): + pass + + # Act + SignatureChecker(func=mock_func, parent_func=mock_parent).check_parameter_annotations() diff --git a/ynabtransactionadjuster/adjuster.py b/ynabtransactionadjuster/adjuster.py index 340c57c..b09bbbc 100644 --- a/ynabtransactionadjuster/adjuster.py +++ b/ynabtransactionadjuster/adjuster.py @@ -12,6 +12,7 @@ from ynabtransactionadjuster.repos import CategoryRepo from ynabtransactionadjuster.repos import PayeeRepo from ynabtransactionadjuster.serializer import Serializer +from ynabtransactionadjuster.signaturechecker import SignatureChecker @dataclass @@ -75,7 +76,7 @@ def dry_run(self, pretty_print: bool = False) -> List[ModifiedTransaction]: :raises AdjustError: if there is any error during the adjust process :raises HTTPError: if there is any error with the YNAB API (e.g. wrong credentials) """ - self._check_signature(self.filter) + self.check_signatures() filtered_transactions = self.filter(self.transactions) s = Serializer(transactions=filtered_transactions, adjust_func=self.adjust, categories=self.categories) modified_transactions = s.run() @@ -91,7 +92,7 @@ def run(self) -> int: :raises AdjustError: if there is any error during the adjust process :raises HTTPError: if there is any error with the YNAB API (e.g. wrong credentials) """ - self._check_signature(self.filter) + self.check_signatures() filtered_transactions = self.filter(self.transactions) s = Serializer(transactions=filtered_transactions, adjust_func=self.adjust, categories=self.categories) modified_transactions = s.run() @@ -101,8 +102,6 @@ def run(self) -> int: return updated return 0 - @staticmethod - def _check_signature(func: Callable): - args_dict = inspect.signature(func).parameters - if len(args_dict) != 1: - raise SignatureError(f"Function '{func.__name__}' needs to have exactly one parameter") + def check_signatures(self): + SignatureChecker(func=self.filter, parent_func=Adjuster.filter).check() + SignatureChecker(func=self.adjust, parent_func=Adjuster.adjust).check() diff --git a/ynabtransactionadjuster/serializer.py b/ynabtransactionadjuster/serializer.py index adcf5c7..225baf3 100644 --- a/ynabtransactionadjuster/serializer.py +++ b/ynabtransactionadjuster/serializer.py @@ -1,28 +1,28 @@ import inspect -from typing import List, Callable, Optional +from typing import List, Optional, Callable -from ynabtransactionadjuster.exceptions import AdjustError, SignatureError -from ynabtransactionadjuster.models import Transaction, ModifiedTransaction, Modifier, Category from ynabtransactionadjuster.repos import CategoryRepo +from ynabtransactionadjuster.exceptions import AdjustError +from ynabtransactionadjuster.models import Transaction, ModifiedTransaction, Modifier, Category class Serializer: - def __init__(self, transactions: List[Transaction], adjust_func: Callable, categories: CategoryRepo): + def __init__(self, transactions: List[Transaction], categories: CategoryRepo, adjust_func: Callable): self._transactions = transactions self._adjust_func = adjust_func self._categories = categories def run(self) -> List[ModifiedTransaction]: - modified_transactions = [self.adjust_single(transaction=t, adjust_func=self._adjust_func) + modified_transactions = [self.adjust_single(transaction=t) for t in self._transactions] filtered_transactions = [t for t in modified_transactions if t.is_changed()] return filtered_transactions - def adjust_single(self, transaction: Transaction, adjust_func: Callable) -> ModifiedTransaction: + def adjust_single(self, transaction: Transaction) -> ModifiedTransaction: modifier = Modifier.from_transaction(transaction=transaction) - transaction_field, modifier_field = self.find_field_names(adjust_func) - modifier_return = adjust_func(**{transaction_field: transaction, modifier_field: modifier}) + transaction_field, modifier_field = self.find_field_names() + modifier_return = self._adjust_func(**{transaction_field: transaction, modifier_field: modifier}) try: self.validate_instance(modifier_return) self.validate_attributes(modifier_return) @@ -35,7 +35,7 @@ def adjust_single(self, transaction: Transaction, adjust_func: Callable) -> Modi def validate_category(self, category: Category): if category: - self._categories.fetch_by_id(category.id) + self._adjuster.categories.fetch_by_id(category.id) @staticmethod def validate_attributes(modifier: Modifier): @@ -46,16 +46,29 @@ def validate_instance(modifier: Optional[Modifier]): if not isinstance(modifier, Modifier): raise AdjustError(f"Adjust function doesn't return TransactionModifier object") - @staticmethod - def find_field_names(adjust_func: Callable) -> (str, str): - args_dict = inspect.signature(adjust_func).parameters - if len(args_dict) != 2: - raise SignatureError(f"function '{adjust_func.__name__}' needs to have exactly two parameters") + def find_field_names(self) -> (str, str): + args_dict = inspect.signature(self._adjust_func).parameters + + # Find transaction field by annotation try: transaction_field = next(k for k, v in args_dict.items() if v.annotation == Transaction) + except StopIteration: + transaction_field = None + + # Find modifier field by annotation + try: modifier_field = next(k for k, v in args_dict.items() if v.annotation == Modifier) except StopIteration: - field_iterator = iter(args_dict) + modifier_field = None + + if transaction_field and modifier_field: + pass + elif transaction_field and not modifier_field: + modifier_field = next(k for k, _ in iter(args_dict.items()) if k != transaction_field) + elif modifier_field and not transaction_field: + transaction_field = next(k for k, _ in iter(args_dict.items()) if k != modifier_field) + else: + field_iterator = iter(args_dict.keys()) transaction_field = next(field_iterator) modifier_field = next(field_iterator) return transaction_field, modifier_field diff --git a/ynabtransactionadjuster/signaturechecker.py b/ynabtransactionadjuster/signaturechecker.py new file mode 100644 index 0000000..8695796 --- /dev/null +++ b/ynabtransactionadjuster/signaturechecker.py @@ -0,0 +1,34 @@ +import inspect +from collections import Counter +from typing import Callable + +from ynabtransactionadjuster.exceptions import SignatureError + + +class SignatureChecker: + + def __init__(self, func: Callable, parent_func: Callable): + self.func_name = func.__name__ + self.parameters = list(inspect.signature(func).parameters.values()) + self.expected_parameters = [v for v in inspect.signature(parent_func).parameters.values() if v.name != 'self'] + + def check(self): + self.check_parameter_count() + self.check_parameter_annotations() + + def check_parameter_count(self): + if len(self.expected_parameters) != len(self.parameters): + raise SignatureError(SignatureError(f"Function '{self.func_name}' needs to have exactly " + f"{len(self.expected_parameters)} parameter(s)")) + + def check_parameter_annotations(self): + annotations = [p.annotation for p in self.parameters if p.annotation != inspect._empty] + expected_annotations = [p.annotation for p in self.expected_parameters if p.annotation != inspect._empty] + + counter = Counter(annotations) + expected_counter = Counter(expected_annotations) + if counter - expected_counter: + raise SignatureError(f"Function '{self.func_name}' with {annotations} does not have expected " + f"annotations {expected_annotations}") + + From 4fafd7a8c4fcc228e24b897a7f076aa1e4c8b074 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Mon, 22 Apr 2024 11:58:44 +0200 Subject: [PATCH 33/39] cleanup test imports --- tests/test_serializer.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_serializer.py b/tests/test_serializer.py index 514dc24..511b3b5 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -1,9 +1,7 @@ -from unittest.mock import PropertyMock, MagicMock +from unittest.mock import MagicMock -import pytest from ynabtransactionadjuster import Modifier, Transaction -from ynabtransactionadjuster.exceptions import SignatureError from ynabtransactionadjuster.serializer import Serializer From 022f7802ba8c31c6f8cd4d93e8035e4060c32dba Mon Sep 17 00:00:00 2001 From: dnbasta Date: Mon, 22 Apr 2024 12:00:15 +0200 Subject: [PATCH 34/39] added SignatureError to docstring --- ynabtransactionadjuster/adjuster.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ynabtransactionadjuster/adjuster.py b/ynabtransactionadjuster/adjuster.py index b09bbbc..fe8a01e 100644 --- a/ynabtransactionadjuster/adjuster.py +++ b/ynabtransactionadjuster/adjuster.py @@ -73,6 +73,7 @@ def dry_run(self, pretty_print: bool = False) -> List[ModifiedTransaction]: :param pretty_print: if set to True will print modified transactions as strings in console :return: List of modified transactions + :raises SignatureError: if signature of implemented adjuster functions is not compatible :raises AdjustError: if there is any error during the adjust process :raises HTTPError: if there is any error with the YNAB API (e.g. wrong credentials) """ @@ -89,6 +90,7 @@ def run(self) -> int: implementation of the two methods and push the updated transactions back to YNAB :return: count of adjusted transactions which have been updated in YNAB + :raises SignatureError: if signature of implemented adjuster functions is not compatible :raises AdjustError: if there is any error during the adjust process :raises HTTPError: if there is any error with the YNAB API (e.g. wrong credentials) """ From 8c7aa4c1a666dee32c582a5d56bc2364108d439a Mon Sep 17 00:00:00 2001 From: dnbasta Date: Mon, 22 Apr 2024 12:00:45 +0200 Subject: [PATCH 35/39] cleaned up imports --- ynabtransactionadjuster/adjuster.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ynabtransactionadjuster/adjuster.py b/ynabtransactionadjuster/adjuster.py index fe8a01e..60e75dc 100644 --- a/ynabtransactionadjuster/adjuster.py +++ b/ynabtransactionadjuster/adjuster.py @@ -1,10 +1,8 @@ -import inspect from abc import abstractmethod, ABCMeta from dataclasses import dataclass -from typing import List, Callable +from typing import List from ynabtransactionadjuster.models import ModifiedTransaction -from ynabtransactionadjuster.exceptions import SignatureError from ynabtransactionadjuster.models.credentials import Credentials from ynabtransactionadjuster.client import Client from ynabtransactionadjuster.models import Transaction From b99e0f9e299a2da1b7222a1bba58c7495da79834 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Tue, 23 Apr 2024 06:45:39 +0200 Subject: [PATCH 36/39] added monthly downloads badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index a938cd6..6025de6 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![GitHub Release](https://img.shields.io/github/release/dnbasta/ynab-transaction-adjuster?style=flat)]() [![Github Release](https://img.shields.io/maintenance/yes/2100)]() +[![Github Release](https://img.shields.io/pypi/dm/ynab-transaction-adjuster)]() [!["Buy Me A Coffee"](https://img.shields.io/badge/Buy_Me_A_Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/dnbasta) From 93a77eb1e938dafde611047a19e3af26c05377eb Mon Sep 17 00:00:00 2001 From: dnbasta Date: Tue, 23 Apr 2024 06:46:28 +0200 Subject: [PATCH 37/39] updated labels for badges --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6025de6..8b0c6fe 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # ynab-transaction-adjuster [![GitHub Release](https://img.shields.io/github/release/dnbasta/ynab-transaction-adjuster?style=flat)]() -[![Github Release](https://img.shields.io/maintenance/yes/2100)]() -[![Github Release](https://img.shields.io/pypi/dm/ynab-transaction-adjuster)]() +[![Maintained](https://img.shields.io/maintenance/yes/2100)]() +[![Monthly downloads](https://img.shields.io/pypi/dm/ynab-transaction-adjuster)]() [!["Buy Me A Coffee"](https://img.shields.io/badge/Buy_Me_A_Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/dnbasta) From d5f87586bc79648a019a1fcec0827e94fa7d7c86 Mon Sep 17 00:00:00 2001 From: dnbasta Date: Tue, 23 Apr 2024 07:26:17 +0200 Subject: [PATCH 38/39] bugfix and test update --- tests/test_adjuster.py | 5 +++-- ynabtransactionadjuster/serializer.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_adjuster.py b/tests/test_adjuster.py index 86254a2..09de89e 100644 --- a/tests/test_adjuster.py +++ b/tests/test_adjuster.py @@ -2,7 +2,7 @@ import pytest -from ynabtransactionadjuster import Adjuster +from ynabtransactionadjuster import Adjuster, ModifiedTransaction class MockYnabTransactionAdjuster(Adjuster): @@ -34,7 +34,8 @@ def test_dry_run(mock_category_repo, caplog, mock_original_transaction): # Assert assert len(r) == 1 - assert r[0]['changes']['memo']['changed'] == memo + assert isinstance(r[0], ModifiedTransaction) + assert r[0].modifier.memo == memo @patch('ynabtransactionadjuster.adjuster.Client.update_transactions') diff --git a/ynabtransactionadjuster/serializer.py b/ynabtransactionadjuster/serializer.py index 225baf3..42ddc05 100644 --- a/ynabtransactionadjuster/serializer.py +++ b/ynabtransactionadjuster/serializer.py @@ -35,7 +35,7 @@ def adjust_single(self, transaction: Transaction) -> ModifiedTransaction: def validate_category(self, category: Category): if category: - self._adjuster.categories.fetch_by_id(category.id) + self._categories.fetch_by_id(category.id) @staticmethod def validate_attributes(modifier: Modifier): From a9a830f690c64e55b5e0ee0d390b556312e35b5f Mon Sep 17 00:00:00 2001 From: dnbasta Date: Tue, 23 Apr 2024 07:26:32 +0200 Subject: [PATCH 39/39] updated dependencies --- poetry.lock | 200 ++++++++++++++++++++++++------------------------- pyproject.toml | 6 +- 2 files changed, 103 insertions(+), 103 deletions(-) diff --git a/poetry.lock b/poetry.lock index f78c092..f75595e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# 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" @@ -137,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] @@ -151,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]] @@ -184,13 +184,13 @@ files = [ [[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] @@ -199,18 +199,18 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pydantic" -version = "2.6.4" +version = "2.7.0" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.6.4-py3-none-any.whl", hash = "sha256:cc46fce86607580867bdc3361ad462bab9c222ef042d3da86f2fb333e1d916c5"}, - {file = "pydantic-2.6.4.tar.gz", hash = "sha256:b1704e0847db01817624a6b86766967f552dd9dbf3afba4004409f908dcc84e6"}, + {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.16.3" +pydantic-core = "2.18.1" typing-extensions = ">=4.6.1" [package.extras] @@ -218,90 +218,90 @@ email = ["email-validator (>=2.0.0)"] [[package]] name = "pydantic-core" -version = "2.16.3" -description = "" +version = "2.18.1" +description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.16.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:75b81e678d1c1ede0785c7f46690621e4c6e63ccd9192af1f0bd9d504bbb6bf4"}, - {file = "pydantic_core-2.16.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9c865a7ee6f93783bd5d781af5a4c43dadc37053a5b42f7d18dc019f8c9d2bd1"}, - {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:162e498303d2b1c036b957a1278fa0899d02b2842f1ff901b6395104c5554a45"}, - {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2f583bd01bbfbff4eaee0868e6fc607efdfcc2b03c1c766b06a707abbc856187"}, - {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b926dd38db1519ed3043a4de50214e0d600d404099c3392f098a7f9d75029ff8"}, - {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:716b542728d4c742353448765aa7cdaa519a7b82f9564130e2b3f6766018c9ec"}, - {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ad7f7ee1a13d9cb49d8198cd7d7e3aa93e425f371a68235f784e99741561f"}, - {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bd87f48924f360e5d1c5f770d6155ce0e7d83f7b4e10c2f9ec001c73cf475c99"}, - {file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0df446663464884297c793874573549229f9eca73b59360878f382a0fc085979"}, - {file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4df8a199d9f6afc5ae9a65f8f95ee52cae389a8c6b20163762bde0426275b7db"}, - {file = "pydantic_core-2.16.3-cp310-none-win32.whl", hash = "sha256:456855f57b413f077dff513a5a28ed838dbbb15082ba00f80750377eed23d132"}, - {file = "pydantic_core-2.16.3-cp310-none-win_amd64.whl", hash = "sha256:732da3243e1b8d3eab8c6ae23ae6a58548849d2e4a4e03a1924c8ddf71a387cb"}, - {file = "pydantic_core-2.16.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:519ae0312616026bf4cedc0fe459e982734f3ca82ee8c7246c19b650b60a5ee4"}, - {file = "pydantic_core-2.16.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b3992a322a5617ded0a9f23fd06dbc1e4bd7cf39bc4ccf344b10f80af58beacd"}, - {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d62da299c6ecb04df729e4b5c52dc0d53f4f8430b4492b93aa8de1f541c4aac"}, - {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2acca2be4bb2f2147ada8cac612f8a98fc09f41c89f87add7256ad27332c2fda"}, - {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b662180108c55dfbf1280d865b2d116633d436cfc0bba82323554873967b340"}, - {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7c6ed0dc9d8e65f24f5824291550139fe6f37fac03788d4580da0d33bc00c97"}, - {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1bb0827f56654b4437955555dc3aeeebeddc47c2d7ed575477f082622c49e"}, - {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e56f8186d6210ac7ece503193ec84104da7ceb98f68ce18c07282fcc2452e76f"}, - {file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:936e5db01dd49476fa8f4383c259b8b1303d5dd5fb34c97de194560698cc2c5e"}, - {file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:33809aebac276089b78db106ee692bdc9044710e26f24a9a2eaa35a0f9fa70ba"}, - {file = "pydantic_core-2.16.3-cp311-none-win32.whl", hash = "sha256:ded1c35f15c9dea16ead9bffcde9bb5c7c031bff076355dc58dcb1cb436c4721"}, - {file = "pydantic_core-2.16.3-cp311-none-win_amd64.whl", hash = "sha256:d89ca19cdd0dd5f31606a9329e309d4fcbb3df860960acec32630297d61820df"}, - {file = "pydantic_core-2.16.3-cp311-none-win_arm64.whl", hash = "sha256:6162f8d2dc27ba21027f261e4fa26f8bcb3cf9784b7f9499466a311ac284b5b9"}, - {file = "pydantic_core-2.16.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f56ae86b60ea987ae8bcd6654a887238fd53d1384f9b222ac457070b7ac4cff"}, - {file = "pydantic_core-2.16.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9bd22a2a639e26171068f8ebb5400ce2c1bc7d17959f60a3b753ae13c632975"}, - {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4204e773b4b408062960e65468d5346bdfe139247ee5f1ca2a378983e11388a2"}, - {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f651dd19363c632f4abe3480a7c87a9773be27cfe1341aef06e8759599454120"}, - {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aaf09e615a0bf98d406657e0008e4a8701b11481840be7d31755dc9f97c44053"}, - {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e47755d8152c1ab5b55928ab422a76e2e7b22b5ed8e90a7d584268dd49e9c6b"}, - {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:500960cb3a0543a724a81ba859da816e8cf01b0e6aaeedf2c3775d12ee49cade"}, - {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf6204fe865da605285c34cf1172879d0314ff267b1c35ff59de7154f35fdc2e"}, - {file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d33dd21f572545649f90c38c227cc8631268ba25c460b5569abebdd0ec5974ca"}, - {file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:49d5d58abd4b83fb8ce763be7794d09b2f50f10aa65c0f0c1696c677edeb7cbf"}, - {file = "pydantic_core-2.16.3-cp312-none-win32.whl", hash = "sha256:f53aace168a2a10582e570b7736cc5bef12cae9cf21775e3eafac597e8551fbe"}, - {file = "pydantic_core-2.16.3-cp312-none-win_amd64.whl", hash = "sha256:0d32576b1de5a30d9a97f300cc6a3f4694c428d956adbc7e6e2f9cad279e45ed"}, - {file = "pydantic_core-2.16.3-cp312-none-win_arm64.whl", hash = "sha256:ec08be75bb268473677edb83ba71e7e74b43c008e4a7b1907c6d57e940bf34b6"}, - {file = "pydantic_core-2.16.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:b1f6f5938d63c6139860f044e2538baeee6f0b251a1816e7adb6cbce106a1f01"}, - {file = "pydantic_core-2.16.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2a1ef6a36fdbf71538142ed604ad19b82f67b05749512e47f247a6ddd06afdc7"}, - {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:704d35ecc7e9c31d48926150afada60401c55efa3b46cd1ded5a01bdffaf1d48"}, - {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d937653a696465677ed583124b94a4b2d79f5e30b2c46115a68e482c6a591c8a"}, - {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9803edf8e29bd825f43481f19c37f50d2b01899448273b3a7758441b512acf8"}, - {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72282ad4892a9fb2da25defeac8c2e84352c108705c972db82ab121d15f14e6d"}, - {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f752826b5b8361193df55afcdf8ca6a57d0232653494ba473630a83ba50d8c9"}, - {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4384a8f68ddb31a0b0c3deae88765f5868a1b9148939c3f4121233314ad5532c"}, - {file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a4b2bf78342c40b3dc830880106f54328928ff03e357935ad26c7128bbd66ce8"}, - {file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:13dcc4802961b5f843a9385fc821a0b0135e8c07fc3d9949fd49627c1a5e6ae5"}, - {file = "pydantic_core-2.16.3-cp38-none-win32.whl", hash = "sha256:e3e70c94a0c3841e6aa831edab1619ad5c511199be94d0c11ba75fe06efe107a"}, - {file = "pydantic_core-2.16.3-cp38-none-win_amd64.whl", hash = "sha256:ecdf6bf5f578615f2e985a5e1f6572e23aa632c4bd1dc67f8f406d445ac115ed"}, - {file = "pydantic_core-2.16.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:bda1ee3e08252b8d41fa5537413ffdddd58fa73107171a126d3b9ff001b9b820"}, - {file = "pydantic_core-2.16.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:21b888c973e4f26b7a96491c0965a8a312e13be108022ee510248fe379a5fa23"}, - {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be0ec334369316fa73448cc8c982c01e5d2a81c95969d58b8f6e272884df0074"}, - {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5b6079cc452a7c53dd378c6f881ac528246b3ac9aae0f8eef98498a75657805"}, - {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ee8d5f878dccb6d499ba4d30d757111847b6849ae07acdd1205fffa1fc1253c"}, - {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7233d65d9d651242a68801159763d09e9ec96e8a158dbf118dc090cd77a104c9"}, - {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6119dc90483a5cb50a1306adb8d52c66e447da88ea44f323e0ae1a5fcb14256"}, - {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:578114bc803a4c1ff9946d977c221e4376620a46cf78da267d946397dc9514a8"}, - {file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d8f99b147ff3fcf6b3cc60cb0c39ea443884d5559a30b1481e92495f2310ff2b"}, - {file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4ac6b4ce1e7283d715c4b729d8f9dab9627586dafce81d9eaa009dd7f25dd972"}, - {file = "pydantic_core-2.16.3-cp39-none-win32.whl", hash = "sha256:e7774b570e61cb998490c5235740d475413a1f6de823169b4cf94e2fe9e9f6b2"}, - {file = "pydantic_core-2.16.3-cp39-none-win_amd64.whl", hash = "sha256:9091632a25b8b87b9a605ec0e61f241c456e9248bfdcf7abdf344fdb169c81cf"}, - {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:36fa178aacbc277bc6b62a2c3da95226520da4f4e9e206fdf076484363895d2c"}, - {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:dcca5d2bf65c6fb591fff92da03f94cd4f315972f97c21975398bd4bd046854a"}, - {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a72fb9963cba4cd5793854fd12f4cfee731e86df140f59ff52a49b3552db241"}, - {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60cc1a081f80a2105a59385b92d82278b15d80ebb3adb200542ae165cd7d183"}, - {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cbcc558401de90a746d02ef330c528f2e668c83350f045833543cd57ecead1ad"}, - {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:fee427241c2d9fb7192b658190f9f5fd6dfe41e02f3c1489d2ec1e6a5ab1e04a"}, - {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f4cb85f693044e0f71f394ff76c98ddc1bc0953e48c061725e540396d5c8a2e1"}, - {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b29eeb887aa931c2fcef5aa515d9d176d25006794610c264ddc114c053bf96fe"}, - {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a425479ee40ff021f8216c9d07a6a3b54b31c8267c6e17aa88b70d7ebd0e5e5b"}, - {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5c5cbc703168d1b7a838668998308018a2718c2130595e8e190220238addc96f"}, - {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b6add4c0b39a513d323d3b93bc173dac663c27b99860dd5bf491b240d26137"}, - {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f76ee558751746d6a38f89d60b6228fa174e5172d143886af0f85aa306fd89"}, - {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00ee1c97b5364b84cb0bd82e9bbf645d5e2871fb8c58059d158412fee2d33d8a"}, - {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:287073c66748f624be4cef893ef9174e3eb88fe0b8a78dc22e88eca4bc357ca6"}, - {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ed25e1835c00a332cb10c683cd39da96a719ab1dfc08427d476bce41b92531fc"}, - {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:86b3d0033580bd6bbe07590152007275bd7af95f98eaa5bd36f3da219dcd93da"}, - {file = "pydantic_core-2.16.3.tar.gz", hash = "sha256:1cac689f80a3abab2d3c0048b29eea5751114054f032a941a32de4c852c59cad"}, + {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] @@ -363,13 +363,13 @@ files = [ [[package]] name = "typing-extensions" -version = "4.10.0" +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.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, - {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, + {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]] @@ -391,5 +391,5 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" -python-versions = ">=3.8,<4.0" -content-hash = "dd11736810a516d64a54eb9652f4a60dfd71eff4fbaaf9e42a049d2ac50c7fe3" +python-versions = "^3.8" +content-hash = "694ce777f06fdf3cf75d57fba731c9c0dfa4a5d7a640f5972eda338e2380e180" diff --git a/pyproject.toml b/pyproject.toml index 633116b..231d6ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,9 +13,9 @@ classifiers = [ ] [tool.poetry.dependencies] -python = '>=3.8,<4.0' -requests = '>=2.28.0' -pydantic = "^2.6.4" +python = '^3.8' +requests = '^2.28.0' +pydantic = '^2.7.0' [tool.poetry.group.dev.dependencies] pytest = "^8.0.0"