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/
wheels/
*.egg-info
.pytest_cache
.ropeproject
# Virtual environments
.venv

View File

@@ -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",
]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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'

View File

@@ -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]

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" },
]
[[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" },
]