diff --git a/.gitignore b/.gitignore index c137e85..8a4b765 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ build/ dist/ wheels/ *.egg-info +.pytest_cache +.ropeproject # Virtual environments .venv diff --git a/pyproject.toml b/pyproject.toml index 833f202..3ad1586 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "openai>=2.3.0", "pydantic>=2.12.1", "requests>=2.30.0", + "xdg-base-dirs>=6.0.2", "ynab>=1.9.0", ] diff --git a/src/amazon.py b/src/amazon.py index 63aad6f..e2fa930 100644 --- a/src/amazon.py +++ b/src/amazon.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass import datetime import os import re @@ -6,19 +7,29 @@ from amazonorders.conf import AmazonOrdersConfig from amazonorders.orders import AmazonOrders from amazonorders.transactions import AmazonTransactions from amazonorders.session import AmazonSession +from amazonorders.entity.order import Order from amazonorders.entity.transaction import Transaction +import xdg_base_dirs -def get_accounts(env: dict[str, str] = os.environ): +@dataclass +class AmazonAccount: + email: str + password: str + + +def get_accounts(env: dict[str, str] = os.environ) -> list[AmazonAccount]: accts = [] for k, v in env.items(): if k == 'AMAZON_EMAIL': pwd = env['AMAZON_PASSWORD'] - accts.append((v, pwd)) + acct = AmazonAccount(email=v, password=pwd) + accts.append(acct) elif m := re.match(r'AMAZON_EMAIL_(\d+)', k): idx = m.group(1) pwd = env[f'AMAZON_PASSWORD_{idx}'] - accts.append((v, pwd)) + acct = AmazonAccount(email=v, password=pwd) + accts.append(acct) return accts @@ -27,17 +38,15 @@ class Session: orders: AmazonOrders transactions: AmazonTransactions - def __init__(self, email, pwd): - self.email = email - self.pwd = pwd + def __init__(self, acct: AmazonAccount): + self.acct = acct + self._cookie_jar_path = xdg_base_dirs.xdg_data_home() / f'catnab/cookies/{self.acct.email}.json' def __enter__(self): - config = AmazonOrdersConfig(data={ - 'cookie_jar_path': f'.cookies/{self.email}.json', - }) + config = AmazonOrdersConfig(data={'cookie_jar_path': self._cookie_jar_path}) self.amazon_session = AmazonSession( - self.email, - self.pwd, + username=self.acct.email, + password=self.acct.password, config=config ) self.amazon_session.login() @@ -50,13 +59,13 @@ class Session: def __exit__(self, *exc_info): pass - def list_transactions(self, days: int): + def list_transactions(self, days: int) -> list[Transaction]: return self.transactions.get_transactions(days) - def get_order(self, order_number: str): + def get_order(self, order_number: str) -> Order: return self.orders.get_order(order_number) - def get_orders(self, days: int): + def get_orders(self, days: int) -> list[Order]: today = datetime.datetime.today() since_date = today - datetime.timedelta(days=days) years = range(since_date.year, today.year + 1) diff --git a/src/main.py b/src/main.py index ad9506d..48c678f 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,5 @@ import collections from dataclasses import dataclass -import datetime import os import traceback @@ -12,6 +11,7 @@ from ynab.models.transaction_detail import TransactionDetail import ynab import amazon +from amazon import AmazonAccount import ai import ynab_client @@ -56,7 +56,7 @@ def tx_matches(amz_hist: list[Transaction], ynab_hist: list[TransactionDetail]) matched = [] for amz_tx in filter(is_matchable_amz, amz_hist): - # ynab uses tenths of a percent for whatever reasonsince_date: datetime.date + # ynab uses tenths of a cent for whatever reason total_cents = round(amz_tx.grand_total * 1000) found = None for ynab_tx in ynab_by_amt[total_cents]: @@ -166,9 +166,9 @@ class Context: ) -def sync_account(ctx: Context, email: str, pwd: str): - print(f'Syncing transactions from Amazon account: {email}') - with amazon.Session(email, pwd) as amz: +def sync_account(ctx: Context, acct: AmazonAccount): + print(f'Syncing transactions from Amazon account: {acct.email}') + with amazon.Session(acct) as amz: print('Fetching Amazon transactions...') amz_hist = amz.list_transactions(ctx.sync_days) print('Fetching YNAB transactions...') @@ -216,12 +216,10 @@ def main(): @main.command @click.option('--days', type=int, default=30) -def sync(days): - since_moment = datetime.datetime.now() - datetime.timedelta(days=days) - since_date = since_moment.date() +def sync(days: int): ctx = Context(days) - for email, pwd in amazon.get_accounts(): - sync_account(ctx, email, pwd) + for acct in amazon.get_accounts(): + sync_account(ctx, acct) diff --git a/tests/test_amazon.py b/tests/test_amazon.py index be98595..fb3b9da 100644 --- a/tests/test_amazon.py +++ b/tests/test_amazon.py @@ -9,9 +9,8 @@ def test_get_accounts(): res = amazon.get_accounts(env) assert len(res) == 1 - email, pwd = res[0] - assert email == 'test@example.com' - assert pwd == 'password123' + assert res[0].email == 'test@example.com' + assert res[0].password == 'password123' def test_get_accounts_numbered(): @@ -22,9 +21,8 @@ def test_get_accounts_numbered(): res = amazon.get_accounts(env) assert len(res) == 1 - email, pwd = res[0] - assert email == 'test@example.com' - assert pwd == 'password123' + assert res[0].email == 'test@example.com' + assert res[0].password == 'password123' def test_get_accounts_numbered_multi(): @@ -38,13 +36,11 @@ def test_get_accounts_numbered_multi(): assert len(res) == 2 - email1, pwd1 = res[0] - assert email1 == 'test@example.com' - assert pwd1 == 'password123' + assert res[0].email == 'test@example.com' + assert res[0].password == 'password123' - email2, pwd2 = res[1] - assert email2 == 'test2@example.com' - assert pwd2 == 'password456' + assert res[1].email == 'test2@example.com' + assert res[1].password == 'password456' def test_get_accounts_both(): @@ -57,10 +53,9 @@ def test_get_accounts_both(): res = amazon.get_accounts(env) assert len(res) == 2 - email1, pwd1 = res[0] - assert email1 == 'test@example.com' - assert pwd1 == 'password123' - email2, pwd2 = res[1] - assert email2 == 'test2@example.com' - assert pwd2 == 'password456' + assert res[0].email == 'test@example.com' + assert res[0].password == 'password123' + + assert res[1].email == 'test2@example.com' + assert res[1].password == 'password456' diff --git a/tests/test_main.py b/tests/test_main.py index 68fffdd..6e82c5a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,75 +1,16 @@ -import datetime -import json -import pickle -from types import SimpleNamespace +from unittest.mock import MagicMock -from amazonorders.entity.order import Order -from amazonorders.entity.item import Item -from amazonorders.entity.shipment import Shipment -from amazonorders.entity.transaction import Transaction -from amazonorders.entity.recipient import Recipient import pytest +from ai import ItemClassification import main - - -KEYS = { - Item: [ - 'title', 'link', 'price', 'seller', 'condition', - 'return_eligible_date', 'image_link', 'quantity' - ], - Order: [ - 'full_details', 'index', 'shipments', 'items', 'order_number', 'order_details_link', - 'grand_total', 'order_placed_date', 'recipient', 'payment_method', 'payment_method_last_4', - 'subtotal', 'shipping_total', 'free_shipping', 'promotion_applied', 'coupon_savings', - 'reward_points', 'subscription_discount', 'total_before_tax', 'estimated_tax', - 'refund_total', 'multibuy_discount', 'amazon_discount', 'gift_card', 'gift_wrap', - ], - Transaction: [ - 'completed_date', 'payment_method', 'grand_total', 'is_refund', - 'order_number', 'order_details_link', 'seller', - ], - Shipment: [ - 'items', 'delivery_status', 'tracking_link', - ], - Recipient: [ - 'name', 'address', - ], -} - - -def to_dict(entity): - entity_keys = KEYS[type(entity)] - result = {} - for k, v in entity.__dict__.items(): - if k in entity_keys: - if type(v) in KEYS: - result[k] = to_dict(v) - elif type(v) == list and len(v) > 0 and type(v[0]) in KEYS: - result[k] = [to_dict(i) for i in v] - elif type(v) in (datetime.date, datetime.datetime): - result[k] = v.isoformat() - else: - result[k] = v - return result - - -# @pytest.fixture -def mock_amz_hist(): - with open('output/transactions_2025.pkl', 'rb') as f: - return pickle.load(f) - - -# @pytest.fixture -def mock_ynab_hist(): - with open('output/ynab_history.json') as f: - return json.load(f) +from main import TxCategory def test_get_tx_categories(monkeypatch): - order = SimpleNamespace( + order = MagicMock( items=[ - SimpleNamespace( + MagicMock( title='An example item', price=17.49 ) @@ -77,7 +18,8 @@ def test_get_tx_categories(monkeypatch): ) item_details={ - 'An example item': SimpleNamespace( + 'An example item': ItemClassification( + name='Example item', category='Example category', description='example', ) @@ -95,13 +37,13 @@ def test_get_tx_categories(monkeypatch): def test_get_categories_multi_items(monkeypatch): - order = SimpleNamespace( + order = MagicMock( items=[ - SimpleNamespace( + MagicMock( title='An example item', price=17.49 ), - SimpleNamespace( + MagicMock( title='Another example', price=12.34, ) @@ -109,11 +51,13 @@ def test_get_categories_multi_items(monkeypatch): ) item_details={ - 'An example item': SimpleNamespace( + 'An example item': ItemClassification( + name='Example item 1', category='Example category', description='example', ), - 'Another example': SimpleNamespace( + 'Another example': ItemClassification( + name='Example item 2', category='Example category', description='second example', ) @@ -131,13 +75,13 @@ def test_get_categories_multi_items(monkeypatch): def test_get_categories_multi_categories(monkeypatch): - order = SimpleNamespace( + order = MagicMock( items=[ - SimpleNamespace( + MagicMock( title='An example item', price=17.49 ), - SimpleNamespace( + MagicMock( title='Another example', price=12.34, ) @@ -145,11 +89,13 @@ def test_get_categories_multi_categories(monkeypatch): ) item_details={ - 'An example item': SimpleNamespace( + 'An example item': ItemClassification( + name='Example item 1', category='Example category', description='example', ), - 'Another example': SimpleNamespace( + 'Another example': ItemClassification( + name='Example item 2', category='Other category', description='second example', ) @@ -172,12 +118,12 @@ def test_get_categories_multi_categories(monkeypatch): def test_build_tx_update_multi(): tx_categories = [ - SimpleNamespace( + TxCategory( category_name='Example category', description='example', ratio=0.68, ), - SimpleNamespace( + TxCategory( category_name='Other category', description='second example', ratio=0.32, @@ -189,10 +135,13 @@ def test_build_tx_update_multi(): 'Other category': '456', } - # ynab uses tenths of a cent as its unit - tx_total = 23760 + tx = MagicMock( + id='958e13e6-7bfd-465e-956a-887f15c8c456', + # ynab uses tenths of a cent as its unit + amount=23760, + ) - res = main.build_tx_update(tx_categories, category_ids, tx_total) + res = main.build_tx_update(tx_categories, category_ids, tx) assert len(res.subtransactions) == 2 sub0 = res.subtransactions[0] diff --git a/uv.lock b/uv.lock index 88062d6..e92ff75 100644 --- a/uv.lock +++ b/uv.lock @@ -78,6 +78,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, ] +[[package]] +name = "catnab" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "amazon-orders" }, + { name = "click" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "xdg-base-dirs" }, + { name = "ynab" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ipdb" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "amazon-orders", specifier = ">=4.0.16" }, + { name = "click", specifier = ">=8.3.0" }, + { name = "openai", specifier = ">=2.3.0" }, + { name = "pydantic", specifier = ">=2.12.1" }, + { name = "requests", specifier = ">=2.30.0" }, + { name = "xdg-base-dirs", specifier = ">=6.0.2" }, + { name = "ynab", specifier = ">=1.9.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "ipdb", specifier = ">=0.13.13" }, + { name = "pytest", specifier = ">=8.4.2" }, +] + [[package]] name = "certifi" version = "2025.10.5" @@ -810,6 +847,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, ] +[[package]] +name = "xdg-base-dirs" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/d0/bbe05a15347538aaf9fa5b51ac3b97075dfb834931fcb77d81fbdb69e8f6/xdg_base_dirs-6.0.2.tar.gz", hash = "sha256:950504e14d27cf3c9cb37744680a43bf0ac42efefc4ef4acf98dc736cab2bced", size = 4085, upload-time = "2024-10-19T14:35:08.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/03/030b47fd46b60fc87af548e57ff59c2ca84b2a1dadbe721bb0ce33896b2e/xdg_base_dirs-6.0.2-py3-none-any.whl", hash = "sha256:3c01d1b758ed4ace150ac960ac0bd13ce4542b9e2cdf01312dcda5012cfebabe", size = 4747, upload-time = "2024-10-19T14:35:05.931Z" }, +] + [[package]] name = "ynab" version = "1.9.0" @@ -825,38 +871,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/9a/3e/36599ae876db3e1d3 wheels = [ { url = "https://files.pythonhosted.org/packages/b2/9c/0ccd11bcdf7522fcb2823fcd7ffbb48e3164d72caaf3f920c7b068347175/ynab-1.9.0-py3-none-any.whl", hash = "sha256:72ac0219605b4280149684ecd0fec3bd75d938772d65cdeea9b3e66a1b2f470d", size = 208674, upload-time = "2025-10-06T19:14:31.719Z" }, ] - -[[package]] -name = "ynab-amazon" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "amazon-orders" }, - { name = "click" }, - { name = "openai" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "ynab" }, -] - -[package.dev-dependencies] -dev = [ - { name = "ipdb" }, - { name = "pytest" }, -] - -[package.metadata] -requires-dist = [ - { name = "amazon-orders", specifier = ">=4.0.16" }, - { name = "click", specifier = ">=8.3.0" }, - { name = "openai", specifier = ">=2.3.0" }, - { name = "pydantic", specifier = ">=2.12.1" }, - { name = "requests", specifier = ">=2.30.0" }, - { name = "ynab", specifier = ">=1.9.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "ipdb", specifier = ">=0.13.13" }, - { name = "pytest", specifier = ">=8.4.2" }, -]