store cookies in xdg-data-dir, clean up typing, fix some tests

This commit is contained in:
2025-12-07 08:15:59 -05:00
parent 5af4b5c4eb
commit 163b4a525c
7 changed files with 122 additions and 157 deletions

2
.gitignore vendored
View File

@@ -5,6 +5,8 @@ build/
dist/ dist/
wheels/ wheels/
*.egg-info *.egg-info
.pytest_cache
.ropeproject
# Virtual environments # Virtual environments
.venv .venv

View File

@@ -12,6 +12,7 @@ dependencies = [
"openai>=2.3.0", "openai>=2.3.0",
"pydantic>=2.12.1", "pydantic>=2.12.1",
"requests>=2.30.0", "requests>=2.30.0",
"xdg-base-dirs>=6.0.2",
"ynab>=1.9.0", "ynab>=1.9.0",
] ]

View File

@@ -1,3 +1,4 @@
from dataclasses import dataclass
import datetime import datetime
import os import os
import re import re
@@ -6,19 +7,29 @@ from amazonorders.conf import AmazonOrdersConfig
from amazonorders.orders import AmazonOrders from amazonorders.orders import AmazonOrders
from amazonorders.transactions import AmazonTransactions from amazonorders.transactions import AmazonTransactions
from amazonorders.session import AmazonSession from amazonorders.session import AmazonSession
from amazonorders.entity.order import Order
from amazonorders.entity.transaction import Transaction 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 = [] accts = []
for k, v in env.items(): for k, v in env.items():
if k == 'AMAZON_EMAIL': if k == 'AMAZON_EMAIL':
pwd = env['AMAZON_PASSWORD'] 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): elif m := re.match(r'AMAZON_EMAIL_(\d+)', k):
idx = m.group(1) idx = m.group(1)
pwd = env[f'AMAZON_PASSWORD_{idx}'] pwd = env[f'AMAZON_PASSWORD_{idx}']
accts.append((v, pwd)) acct = AmazonAccount(email=v, password=pwd)
accts.append(acct)
return accts return accts
@@ -27,17 +38,15 @@ class Session:
orders: AmazonOrders orders: AmazonOrders
transactions: AmazonTransactions transactions: AmazonTransactions
def __init__(self, email, pwd): def __init__(self, acct: AmazonAccount):
self.email = email self.acct = acct
self.pwd = pwd self._cookie_jar_path = xdg_base_dirs.xdg_data_home() / f'catnab/cookies/{self.acct.email}.json'
def __enter__(self): def __enter__(self):
config = AmazonOrdersConfig(data={ config = AmazonOrdersConfig(data={'cookie_jar_path': self._cookie_jar_path})
'cookie_jar_path': f'.cookies/{self.email}.json',
})
self.amazon_session = AmazonSession( self.amazon_session = AmazonSession(
self.email, username=self.acct.email,
self.pwd, password=self.acct.password,
config=config config=config
) )
self.amazon_session.login() self.amazon_session.login()
@@ -50,13 +59,13 @@ class Session:
def __exit__(self, *exc_info): def __exit__(self, *exc_info):
pass pass
def list_transactions(self, days: int): def list_transactions(self, days: int) -> list[Transaction]:
return self.transactions.get_transactions(days) 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) 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() today = datetime.datetime.today()
since_date = today - datetime.timedelta(days=days) since_date = today - datetime.timedelta(days=days)
years = range(since_date.year, today.year + 1) years = range(since_date.year, today.year + 1)

View File

@@ -1,6 +1,5 @@
import collections import collections
from dataclasses import dataclass from dataclasses import dataclass
import datetime
import os import os
import traceback import traceback
@@ -12,6 +11,7 @@ from ynab.models.transaction_detail import TransactionDetail
import ynab import ynab
import amazon import amazon
from amazon import AmazonAccount
import ai import ai
import ynab_client import ynab_client
@@ -56,7 +56,7 @@ def tx_matches(amz_hist: list[Transaction], ynab_hist: list[TransactionDetail])
matched = [] matched = []
for amz_tx in filter(is_matchable_amz, amz_hist): 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) total_cents = round(amz_tx.grand_total * 1000)
found = None found = None
for ynab_tx in ynab_by_amt[total_cents]: for ynab_tx in ynab_by_amt[total_cents]:
@@ -166,9 +166,9 @@ class Context:
) )
def sync_account(ctx: Context, email: str, pwd: str): def sync_account(ctx: Context, acct: AmazonAccount):
print(f'Syncing transactions from Amazon account: {email}') print(f'Syncing transactions from Amazon account: {acct.email}')
with amazon.Session(email, pwd) as amz: with amazon.Session(acct) as amz:
print('Fetching Amazon transactions...') print('Fetching Amazon transactions...')
amz_hist = amz.list_transactions(ctx.sync_days) amz_hist = amz.list_transactions(ctx.sync_days)
print('Fetching YNAB transactions...') print('Fetching YNAB transactions...')
@@ -216,12 +216,10 @@ def main():
@main.command @main.command
@click.option('--days', type=int, default=30) @click.option('--days', type=int, default=30)
def sync(days): def sync(days: int):
since_moment = datetime.datetime.now() - datetime.timedelta(days=days)
since_date = since_moment.date()
ctx = Context(days) ctx = Context(days)
for email, pwd in amazon.get_accounts(): for acct in amazon.get_accounts():
sync_account(ctx, email, pwd) sync_account(ctx, acct)

View File

@@ -9,9 +9,8 @@ def test_get_accounts():
res = amazon.get_accounts(env) res = amazon.get_accounts(env)
assert len(res) == 1 assert len(res) == 1
email, pwd = res[0] assert res[0].email == 'test@example.com'
assert email == 'test@example.com' assert res[0].password == 'password123'
assert pwd == 'password123'
def test_get_accounts_numbered(): def test_get_accounts_numbered():
@@ -22,9 +21,8 @@ def test_get_accounts_numbered():
res = amazon.get_accounts(env) res = amazon.get_accounts(env)
assert len(res) == 1 assert len(res) == 1
email, pwd = res[0] assert res[0].email == 'test@example.com'
assert email == 'test@example.com' assert res[0].password == 'password123'
assert pwd == 'password123'
def test_get_accounts_numbered_multi(): def test_get_accounts_numbered_multi():
@@ -38,13 +36,11 @@ def test_get_accounts_numbered_multi():
assert len(res) == 2 assert len(res) == 2
email1, pwd1 = res[0] assert res[0].email == 'test@example.com'
assert email1 == 'test@example.com' assert res[0].password == 'password123'
assert pwd1 == 'password123'
email2, pwd2 = res[1] assert res[1].email == 'test2@example.com'
assert email2 == 'test2@example.com' assert res[1].password == 'password456'
assert pwd2 == 'password456'
def test_get_accounts_both(): def test_get_accounts_both():
@@ -57,10 +53,9 @@ def test_get_accounts_both():
res = amazon.get_accounts(env) res = amazon.get_accounts(env)
assert len(res) == 2 assert len(res) == 2
email1, pwd1 = res[0]
assert email1 == 'test@example.com'
assert pwd1 == 'password123'
email2, pwd2 = res[1] assert res[0].email == 'test@example.com'
assert email2 == 'test2@example.com' assert res[0].password == 'password123'
assert pwd2 == 'password456'
assert res[1].email == 'test2@example.com'
assert res[1].password == 'password456'

View File

@@ -1,75 +1,16 @@
import datetime from unittest.mock import MagicMock
import json
import pickle
from types import SimpleNamespace
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 import pytest
from ai import ItemClassification
import main import main
from main import TxCategory
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)
def test_get_tx_categories(monkeypatch): def test_get_tx_categories(monkeypatch):
order = SimpleNamespace( order = MagicMock(
items=[ items=[
SimpleNamespace( MagicMock(
title='An example item', title='An example item',
price=17.49 price=17.49
) )
@@ -77,7 +18,8 @@ def test_get_tx_categories(monkeypatch):
) )
item_details={ item_details={
'An example item': SimpleNamespace( 'An example item': ItemClassification(
name='Example item',
category='Example category', category='Example category',
description='example', description='example',
) )
@@ -95,13 +37,13 @@ def test_get_tx_categories(monkeypatch):
def test_get_categories_multi_items(monkeypatch): def test_get_categories_multi_items(monkeypatch):
order = SimpleNamespace( order = MagicMock(
items=[ items=[
SimpleNamespace( MagicMock(
title='An example item', title='An example item',
price=17.49 price=17.49
), ),
SimpleNamespace( MagicMock(
title='Another example', title='Another example',
price=12.34, price=12.34,
) )
@@ -109,11 +51,13 @@ def test_get_categories_multi_items(monkeypatch):
) )
item_details={ item_details={
'An example item': SimpleNamespace( 'An example item': ItemClassification(
name='Example item 1',
category='Example category', category='Example category',
description='example', description='example',
), ),
'Another example': SimpleNamespace( 'Another example': ItemClassification(
name='Example item 2',
category='Example category', category='Example category',
description='second example', description='second example',
) )
@@ -131,13 +75,13 @@ def test_get_categories_multi_items(monkeypatch):
def test_get_categories_multi_categories(monkeypatch): def test_get_categories_multi_categories(monkeypatch):
order = SimpleNamespace( order = MagicMock(
items=[ items=[
SimpleNamespace( MagicMock(
title='An example item', title='An example item',
price=17.49 price=17.49
), ),
SimpleNamespace( MagicMock(
title='Another example', title='Another example',
price=12.34, price=12.34,
) )
@@ -145,11 +89,13 @@ def test_get_categories_multi_categories(monkeypatch):
) )
item_details={ item_details={
'An example item': SimpleNamespace( 'An example item': ItemClassification(
name='Example item 1',
category='Example category', category='Example category',
description='example', description='example',
), ),
'Another example': SimpleNamespace( 'Another example': ItemClassification(
name='Example item 2',
category='Other category', category='Other category',
description='second example', description='second example',
) )
@@ -172,12 +118,12 @@ def test_get_categories_multi_categories(monkeypatch):
def test_build_tx_update_multi(): def test_build_tx_update_multi():
tx_categories = [ tx_categories = [
SimpleNamespace( TxCategory(
category_name='Example category', category_name='Example category',
description='example', description='example',
ratio=0.68, ratio=0.68,
), ),
SimpleNamespace( TxCategory(
category_name='Other category', category_name='Other category',
description='second example', description='second example',
ratio=0.32, ratio=0.32,
@@ -189,10 +135,13 @@ def test_build_tx_update_multi():
'Other category': '456', 'Other category': '456',
} }
# ynab uses tenths of a cent as its unit tx = MagicMock(
tx_total = 23760 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 assert len(res.subtransactions) == 2
sub0 = res.subtransactions[0] sub0 = res.subtransactions[0]

81
uv.lock generated
View File

@@ -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" }, { 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]] [[package]]
name = "certifi" name = "certifi"
version = "2025.10.5" 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" }, { 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]] [[package]]
name = "ynab" name = "ynab"
version = "1.9.0" version = "1.9.0"
@@ -825,38 +871,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/9a/3e/36599ae876db3e1d3
wheels = [ 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" }, { 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" },
]