store cookies in xdg-data-dir, clean up typing, fix some tests
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,6 +5,8 @@ build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
.pytest_cache
|
||||
.ropeproject
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
18
src/main.py
18
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)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
81
uv.lock
generated
@@ -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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user