ynab-cli 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ynab_cli/__init__.py +0 -0
- ynab_cli/adapters/__init__.py +0 -0
- ynab_cli/adapters/rich/__init__.py +0 -0
- ynab_cli/adapters/rich/io.py +38 -0
- ynab_cli/adapters/textual/__init__.py +0 -0
- ynab_cli/adapters/textual/io.py +62 -0
- ynab_cli/adapters/ynab/__init__.py +8 -0
- ynab_cli/adapters/ynab/api/__init__.py +1 -0
- ynab_cli/adapters/ynab/api/accounts/__init__.py +1 -0
- ynab_cli/adapters/ynab/api/accounts/create_account.py +186 -0
- ynab_cli/adapters/ynab/api/accounts/get_account_by_id.py +177 -0
- ynab_cli/adapters/ynab/api/accounts/get_accounts.py +184 -0
- ynab_cli/adapters/ynab/api/budgets/__init__.py +1 -0
- ynab_cli/adapters/ynab/api/budgets/get_budget_by_id.py +188 -0
- ynab_cli/adapters/ynab/api/budgets/get_budget_settings_by_id.py +163 -0
- ynab_cli/adapters/ynab/api/budgets/get_budgets.py +171 -0
- ynab_cli/adapters/ynab/api/categories/__init__.py +1 -0
- ynab_cli/adapters/ynab/api/categories/get_categories.py +188 -0
- ynab_cli/adapters/ynab/api/categories/get_category_by_id.py +180 -0
- ynab_cli/adapters/ynab/api/categories/get_month_category_by_id.py +194 -0
- ynab_cli/adapters/ynab/api/categories/update_category.py +199 -0
- ynab_cli/adapters/ynab/api/categories/update_month_category.py +213 -0
- ynab_cli/adapters/ynab/api/months/__init__.py +1 -0
- ynab_cli/adapters/ynab/api/months/get_budget_month.py +177 -0
- ynab_cli/adapters/ynab/api/months/get_budget_months.py +184 -0
- ynab_cli/adapters/ynab/api/payee_locations/__init__.py +1 -0
- ynab_cli/adapters/ynab/api/payee_locations/get_payee_location_by_id.py +176 -0
- ynab_cli/adapters/ynab/api/payee_locations/get_payee_locations.py +163 -0
- ynab_cli/adapters/ynab/api/payee_locations/get_payee_locations_by_payee.py +176 -0
- ynab_cli/adapters/ynab/api/payees/__init__.py +1 -0
- ynab_cli/adapters/ynab/api/payees/get_payee_by_id.py +176 -0
- ynab_cli/adapters/ynab/api/payees/get_payees.py +184 -0
- ynab_cli/adapters/ynab/api/payees/update_payee.py +199 -0
- ynab_cli/adapters/ynab/api/scheduled_transactions/__init__.py +1 -0
- ynab_cli/adapters/ynab/api/scheduled_transactions/create_scheduled_transaction.py +186 -0
- ynab_cli/adapters/ynab/api/scheduled_transactions/delete_scheduled_transaction.py +176 -0
- ynab_cli/adapters/ynab/api/scheduled_transactions/get_scheduled_transaction_by_id.py +176 -0
- ynab_cli/adapters/ynab/api/scheduled_transactions/get_scheduled_transactions.py +184 -0
- ynab_cli/adapters/ynab/api/scheduled_transactions/update_scheduled_transaction.py +199 -0
- ynab_cli/adapters/ynab/api/transactions/__init__.py +1 -0
- ynab_cli/adapters/ynab/api/transactions/create_transaction.py +202 -0
- ynab_cli/adapters/ynab/api/transactions/delete_transaction.py +176 -0
- ynab_cli/adapters/ynab/api/transactions/get_transaction_by_id.py +176 -0
- ynab_cli/adapters/ynab/api/transactions/get_transactions.py +227 -0
- ynab_cli/adapters/ynab/api/transactions/get_transactions_by_account.py +236 -0
- ynab_cli/adapters/ynab/api/transactions/get_transactions_by_category.py +236 -0
- ynab_cli/adapters/ynab/api/transactions/get_transactions_by_month.py +236 -0
- ynab_cli/adapters/ynab/api/transactions/get_transactions_by_payee.py +236 -0
- ynab_cli/adapters/ynab/api/transactions/import_transactions.py +183 -0
- ynab_cli/adapters/ynab/api/transactions/update_transaction.py +199 -0
- ynab_cli/adapters/ynab/api/transactions/update_transactions.py +177 -0
- ynab_cli/adapters/ynab/api/user/__init__.py +1 -0
- ynab_cli/adapters/ynab/api/user/get_user.py +130 -0
- ynab_cli/adapters/ynab/client.py +268 -0
- ynab_cli/adapters/ynab/errors.py +16 -0
- ynab_cli/adapters/ynab/models/__init__.py +229 -0
- ynab_cli/adapters/ynab/models/account.py +345 -0
- ynab_cli/adapters/ynab/models/account_response.py +65 -0
- ynab_cli/adapters/ynab/models/account_response_data.py +65 -0
- ynab_cli/adapters/ynab/models/account_type.py +23 -0
- ynab_cli/adapters/ynab/models/accounts_response.py +65 -0
- ynab_cli/adapters/ynab/models/accounts_response_data.py +81 -0
- ynab_cli/adapters/ynab/models/budget_detail.py +405 -0
- ynab_cli/adapters/ynab/models/budget_detail_response.py +65 -0
- ynab_cli/adapters/ynab/models/budget_detail_response_data.py +73 -0
- ynab_cli/adapters/ynab/models/budget_settings.py +115 -0
- ynab_cli/adapters/ynab/models/budget_settings_response.py +65 -0
- ynab_cli/adapters/ynab/models/budget_settings_response_data.py +65 -0
- ynab_cli/adapters/ynab/models/budget_summary.py +216 -0
- ynab_cli/adapters/ynab/models/budget_summary_response.py +65 -0
- ynab_cli/adapters/ynab/models/budget_summary_response_data.py +91 -0
- ynab_cli/adapters/ynab/models/bulk_response.py +65 -0
- ynab_cli/adapters/ynab/models/bulk_response_data.py +65 -0
- ynab_cli/adapters/ynab/models/bulk_response_data_bulk.py +68 -0
- ynab_cli/adapters/ynab/models/bulk_transactions.py +75 -0
- ynab_cli/adapters/ynab/models/categories_response.py +65 -0
- ynab_cli/adapters/ynab/models/categories_response_data.py +81 -0
- ynab_cli/adapters/ynab/models/category.py +519 -0
- ynab_cli/adapters/ynab/models/category_goal_type_type_1.py +15 -0
- ynab_cli/adapters/ynab/models/category_goal_type_type_2_type_1.py +15 -0
- ynab_cli/adapters/ynab/models/category_goal_type_type_3_type_1.py +15 -0
- ynab_cli/adapters/ynab/models/category_group.py +85 -0
- ynab_cli/adapters/ynab/models/category_group_with_categories.py +108 -0
- ynab_cli/adapters/ynab/models/category_response.py +65 -0
- ynab_cli/adapters/ynab/models/category_response_data.py +65 -0
- ynab_cli/adapters/ynab/models/currency_format_type_0.py +117 -0
- ynab_cli/adapters/ynab/models/date_format_type_0.py +61 -0
- ynab_cli/adapters/ynab/models/error_detail.py +75 -0
- ynab_cli/adapters/ynab/models/error_response.py +65 -0
- ynab_cli/adapters/ynab/models/existing_transaction.py +322 -0
- ynab_cli/adapters/ynab/models/get_transactions_by_account_type.py +12 -0
- ynab_cli/adapters/ynab/models/get_transactions_by_category_type.py +12 -0
- ynab_cli/adapters/ynab/models/get_transactions_by_month_type.py +12 -0
- ynab_cli/adapters/ynab/models/get_transactions_by_payee_type.py +12 -0
- ynab_cli/adapters/ynab/models/get_transactions_type.py +12 -0
- ynab_cli/adapters/ynab/models/hybrid_transaction.py +567 -0
- ynab_cli/adapters/ynab/models/hybrid_transaction_type.py +12 -0
- ynab_cli/adapters/ynab/models/hybrid_transactions_response.py +65 -0
- ynab_cli/adapters/ynab/models/hybrid_transactions_response_data.py +84 -0
- ynab_cli/adapters/ynab/models/loan_account_periodic_value_type_0.py +44 -0
- ynab_cli/adapters/ynab/models/month_detail.py +168 -0
- ynab_cli/adapters/ynab/models/month_detail_response.py +65 -0
- ynab_cli/adapters/ynab/models/month_detail_response_data.py +65 -0
- ynab_cli/adapters/ynab/models/month_summaries_response.py +65 -0
- ynab_cli/adapters/ynab/models/month_summaries_response_data.py +81 -0
- ynab_cli/adapters/ynab/models/month_summary.py +145 -0
- ynab_cli/adapters/ynab/models/new_transaction.py +352 -0
- ynab_cli/adapters/ynab/models/patch_category_wrapper.py +65 -0
- ynab_cli/adapters/ynab/models/patch_month_category_wrapper.py +65 -0
- ynab_cli/adapters/ynab/models/patch_payee_wrapper.py +65 -0
- ynab_cli/adapters/ynab/models/patch_transactions_wrapper.py +73 -0
- ynab_cli/adapters/ynab/models/payee.py +100 -0
- ynab_cli/adapters/ynab/models/payee_location.py +93 -0
- ynab_cli/adapters/ynab/models/payee_location_response.py +65 -0
- ynab_cli/adapters/ynab/models/payee_location_response_data.py +65 -0
- ynab_cli/adapters/ynab/models/payee_locations_response.py +65 -0
- ynab_cli/adapters/ynab/models/payee_locations_response_data.py +73 -0
- ynab_cli/adapters/ynab/models/payee_response.py +65 -0
- ynab_cli/adapters/ynab/models/payee_response_data.py +65 -0
- ynab_cli/adapters/ynab/models/payees_response.py +65 -0
- ynab_cli/adapters/ynab/models/payees_response_data.py +81 -0
- ynab_cli/adapters/ynab/models/post_account_wrapper.py +65 -0
- ynab_cli/adapters/ynab/models/post_scheduled_transaction_wrapper.py +65 -0
- ynab_cli/adapters/ynab/models/post_transactions_wrapper.py +91 -0
- ynab_cli/adapters/ynab/models/put_scheduled_transaction_wrapper.py +65 -0
- ynab_cli/adapters/ynab/models/put_transaction_wrapper.py +65 -0
- ynab_cli/adapters/ynab/models/save_account.py +77 -0
- ynab_cli/adapters/ynab/models/save_category.py +129 -0
- ynab_cli/adapters/ynab/models/save_category_response.py +65 -0
- ynab_cli/adapters/ynab/models/save_category_response_data.py +73 -0
- ynab_cli/adapters/ynab/models/save_month_category.py +59 -0
- ynab_cli/adapters/ynab/models/save_payee.py +59 -0
- ynab_cli/adapters/ynab/models/save_payee_response.py +65 -0
- ynab_cli/adapters/ynab/models/save_payee_response_data.py +73 -0
- ynab_cli/adapters/ynab/models/save_scheduled_transaction.py +269 -0
- ynab_cli/adapters/ynab/models/save_sub_transaction.py +166 -0
- ynab_cli/adapters/ynab/models/save_transaction_with_id_or_import_id.py +366 -0
- ynab_cli/adapters/ynab/models/save_transaction_with_optional_fields.py +322 -0
- ynab_cli/adapters/ynab/models/save_transactions_response.py +65 -0
- ynab_cli/adapters/ynab/models/save_transactions_response_data.py +123 -0
- ynab_cli/adapters/ynab/models/scheduled_sub_transaction.py +238 -0
- ynab_cli/adapters/ynab/models/scheduled_transaction_detail.py +381 -0
- ynab_cli/adapters/ynab/models/scheduled_transaction_frequency.py +23 -0
- ynab_cli/adapters/ynab/models/scheduled_transaction_response.py +65 -0
- ynab_cli/adapters/ynab/models/scheduled_transaction_response_data.py +65 -0
- ynab_cli/adapters/ynab/models/scheduled_transaction_summary.py +310 -0
- ynab_cli/adapters/ynab/models/scheduled_transaction_summary_frequency.py +23 -0
- ynab_cli/adapters/ynab/models/scheduled_transactions_response.py +65 -0
- ynab_cli/adapters/ynab/models/scheduled_transactions_response_data.py +81 -0
- ynab_cli/adapters/ynab/models/sub_transaction.py +259 -0
- ynab_cli/adapters/ynab/models/transaction_cleared_status.py +13 -0
- ynab_cli/adapters/ynab/models/transaction_detail.py +570 -0
- ynab_cli/adapters/ynab/models/transaction_flag_color_type_1.py +16 -0
- ynab_cli/adapters/ynab/models/transaction_flag_color_type_2_type_1.py +16 -0
- ynab_cli/adapters/ynab/models/transaction_flag_color_type_3_type_1.py +16 -0
- ynab_cli/adapters/ynab/models/transaction_response.py +65 -0
- ynab_cli/adapters/ynab/models/transaction_response_data.py +65 -0
- ynab_cli/adapters/ynab/models/transaction_summary.py +499 -0
- ynab_cli/adapters/ynab/models/transaction_summary_debt_transaction_type_type_1.py +18 -0
- ynab_cli/adapters/ynab/models/transaction_summary_debt_transaction_type_type_2_type_1.py +18 -0
- ynab_cli/adapters/ynab/models/transaction_summary_debt_transaction_type_type_3_type_1.py +18 -0
- ynab_cli/adapters/ynab/models/transactions_import_response.py +65 -0
- ynab_cli/adapters/ynab/models/transactions_import_response_data.py +59 -0
- ynab_cli/adapters/ynab/models/transactions_response.py +65 -0
- ynab_cli/adapters/ynab/models/transactions_response_data.py +81 -0
- ynab_cli/adapters/ynab/models/user.py +60 -0
- ynab_cli/adapters/ynab/models/user_response.py +65 -0
- ynab_cli/adapters/ynab/models/user_response_data.py +65 -0
- ynab_cli/adapters/ynab/types.py +46 -0
- ynab_cli/adapters/ynab/util.py +78 -0
- ynab_cli/domain/__init__.py +0 -0
- ynab_cli/domain/constants.py +3 -0
- ynab_cli/domain/models/__init__.py +0 -0
- ynab_cli/domain/models/rules.py +37 -0
- ynab_cli/domain/ports/__init__.py +5 -0
- ynab_cli/domain/ports/io.py +63 -0
- ynab_cli/domain/settings.py +16 -0
- ynab_cli/domain/use_cases/__init__.py +0 -0
- ynab_cli/domain/use_cases/categories.py +105 -0
- ynab_cli/domain/use_cases/payees.py +232 -0
- ynab_cli/domain/use_cases/transactions.py +93 -0
- ynab_cli/host/__init__.py +14 -0
- ynab_cli/host/cli.py +37 -0
- ynab_cli/host/click/__init__.py +0 -0
- ynab_cli/host/click/cli.py +44 -0
- ynab_cli/host/click/commands/__init__.py +0 -0
- ynab_cli/host/click/commands/categories.py +92 -0
- ynab_cli/host/click/commands/payees.py +157 -0
- ynab_cli/host/click/commands/rich/__init__.py +0 -0
- ynab_cli/host/click/commands/rich/progress_table.py +37 -0
- ynab_cli/host/click/commands/transactions.py +76 -0
- ynab_cli/host/constants.py +3 -0
- ynab_cli/host/textual/__init__.py +0 -0
- ynab_cli/host/textual/app.py +98 -0
- ynab_cli/host/textual/app.tcss +47 -0
- ynab_cli/host/textual/cli.py +42 -0
- ynab_cli/host/textual/widgets/__init__.py +58 -0
- ynab_cli/host/textual/widgets/categories.py +90 -0
- ynab_cli/host/textual/widgets/payees.py +154 -0
- ynab_cli/host/textual/widgets/transactions.py +89 -0
- ynab_cli/py.typed +0 -0
- ynab_cli-0.3.0.dist-info/METADATA +102 -0
- ynab_cli-0.3.0.dist-info/RECORD +206 -0
- ynab_cli-0.3.0.dist-info/WHEEL +4 -0
- ynab_cli-0.3.0.dist-info/entry_points.txt +2 -0
- ynab_cli-0.3.0.dist-info/licenses/LICENSE.md +21 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator
|
|
2
|
+
from typing import TypedDict
|
|
3
|
+
|
|
4
|
+
from ynab_cli.adapters import ynab
|
|
5
|
+
from ynab_cli.adapters.ynab import models, util
|
|
6
|
+
from ynab_cli.adapters.ynab.api.categories import get_categories
|
|
7
|
+
from ynab_cli.adapters.ynab.api.transactions import get_transactions_by_category
|
|
8
|
+
from ynab_cli.domain import ports
|
|
9
|
+
from ynab_cli.domain.constants import YNAB_API_URL
|
|
10
|
+
from ynab_cli.domain.settings import Settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _should_skip_category_or_group(category_or_group: models.Category | models.CategoryGroupWithCategories) -> bool:
|
|
14
|
+
if category_or_group.deleted:
|
|
15
|
+
return True
|
|
16
|
+
return False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ListUnusedParams(TypedDict):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def list_unused(settings: Settings, io: ports.IO, params: ListUnusedParams) -> AsyncIterator[models.Category]:
|
|
24
|
+
async with ynab.AuthenticatedClient(base_url=YNAB_API_URL, token=settings.ynab.access_token) as client:
|
|
25
|
+
try:
|
|
26
|
+
category_groups = (
|
|
27
|
+
await util.get_asyncio_detailed(
|
|
28
|
+
io, get_categories.asyncio_detailed, settings.ynab.budget_id, client=client
|
|
29
|
+
)
|
|
30
|
+
).data.category_groups
|
|
31
|
+
category_groups.sort(key=lambda cg: cg.name)
|
|
32
|
+
|
|
33
|
+
progress_total = len(category_groups)
|
|
34
|
+
for category_group in category_groups:
|
|
35
|
+
await io.progress.update(total=progress_total, advance=1)
|
|
36
|
+
|
|
37
|
+
if _should_skip_category_or_group(category_or_group=category_group):
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
progress_total += len(category_group.categories)
|
|
41
|
+
category_group.categories.sort(key=lambda c: c.name)
|
|
42
|
+
for category in category_group.categories:
|
|
43
|
+
await io.progress.update(total=progress_total, advance=1)
|
|
44
|
+
|
|
45
|
+
if _should_skip_category_or_group(category_or_group=category):
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
transactions = (
|
|
49
|
+
await util.get_asyncio_detailed(
|
|
50
|
+
io,
|
|
51
|
+
get_transactions_by_category.asyncio_detailed,
|
|
52
|
+
settings.ynab.budget_id,
|
|
53
|
+
str(category.id),
|
|
54
|
+
client=client,
|
|
55
|
+
)
|
|
56
|
+
).data.transactions
|
|
57
|
+
num_transactions = len(transactions)
|
|
58
|
+
|
|
59
|
+
# List unused category if no transactions
|
|
60
|
+
if not num_transactions:
|
|
61
|
+
yield category
|
|
62
|
+
|
|
63
|
+
except util.ApiError as e:
|
|
64
|
+
if e.status_code == 429:
|
|
65
|
+
await io.print("API rate limit exceeded. Try again later, or get a new access token.")
|
|
66
|
+
else:
|
|
67
|
+
await io.print(f"Exception when calling YNAB: {e}\n")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ListAllParams(TypedDict):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def list_all(settings: Settings, io: ports.IO, params: ListAllParams) -> AsyncIterator[models.Category]:
|
|
75
|
+
async with ynab.AuthenticatedClient(base_url=YNAB_API_URL, token=settings.ynab.access_token) as client:
|
|
76
|
+
try:
|
|
77
|
+
category_groups = (
|
|
78
|
+
await util.get_asyncio_detailed(
|
|
79
|
+
io, get_categories.asyncio_detailed, settings.ynab.budget_id, client=client
|
|
80
|
+
)
|
|
81
|
+
).data.category_groups
|
|
82
|
+
category_groups.sort(key=lambda cg: cg.name)
|
|
83
|
+
|
|
84
|
+
progress_total = len(category_groups)
|
|
85
|
+
for category_group in category_groups:
|
|
86
|
+
await io.progress.update(total=progress_total, advance=1)
|
|
87
|
+
|
|
88
|
+
if _should_skip_category_or_group(category_or_group=category_group):
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
progress_total += len(category_group.categories)
|
|
92
|
+
category_group.categories.sort(key=lambda c: c.name)
|
|
93
|
+
for category in category_group.categories:
|
|
94
|
+
await io.progress.update(total=progress_total, advance=1)
|
|
95
|
+
|
|
96
|
+
if _should_skip_category_or_group(category_or_group=category):
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
yield category
|
|
100
|
+
|
|
101
|
+
except util.ApiError as e:
|
|
102
|
+
if e.status_code == 429:
|
|
103
|
+
await io.print("API rate limit exceeded. Try again later, or get a new access token.")
|
|
104
|
+
else:
|
|
105
|
+
await io.print(f"Exception when calling YNAB: {e}\n")
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import AsyncIterator
|
|
3
|
+
from typing import TypedDict
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from rapidfuzz import fuzz
|
|
7
|
+
|
|
8
|
+
from ynab_cli.adapters import ynab
|
|
9
|
+
from ynab_cli.adapters.ynab import models, util
|
|
10
|
+
from ynab_cli.adapters.ynab.api.payees import get_payees, update_payee
|
|
11
|
+
from ynab_cli.adapters.ynab.api.transactions import get_transactions_by_payee
|
|
12
|
+
from ynab_cli.domain import ports
|
|
13
|
+
from ynab_cli.domain.constants import UNUSED_PREFIX, YNAB_API_URL
|
|
14
|
+
from ynab_cli.domain.settings import Settings
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _should_skip_payee(payee: models.Payee) -> bool:
|
|
18
|
+
if (
|
|
19
|
+
payee.deleted
|
|
20
|
+
or (payee.transfer_account_id and isinstance(payee.transfer_account_id, str))
|
|
21
|
+
or payee.name.startswith("Transfer : ")
|
|
22
|
+
or payee.name.startswith(UNUSED_PREFIX)
|
|
23
|
+
or payee.name in ["Starting Balance"]
|
|
24
|
+
):
|
|
25
|
+
return True
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _normalize_name(name: str) -> str:
|
|
30
|
+
normalized_name = name.title().strip()
|
|
31
|
+
# Normalize possessive names
|
|
32
|
+
normalized_name = normalized_name.replace("'S", "'s")
|
|
33
|
+
# Normalize multiple whitespaces
|
|
34
|
+
normalized_name = re.sub(r"\s+", " ", normalized_name)
|
|
35
|
+
# Normalize domain names
|
|
36
|
+
m = re.search(r".+\.([a-zA-Z]{2,3})$", normalized_name)
|
|
37
|
+
if m:
|
|
38
|
+
normalized_name = normalized_name.replace(m.group(1), m.group(1).lower())
|
|
39
|
+
|
|
40
|
+
return normalized_name
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NormalizeNamesParams(TypedDict):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def normalize_names(
|
|
48
|
+
settings: Settings, io: ports.IO, params: NormalizeNamesParams, *, client: ynab.AuthenticatedClient | None = None
|
|
49
|
+
) -> AsyncIterator[tuple[models.Payee, str]]:
|
|
50
|
+
if client is None:
|
|
51
|
+
client = ynab.AuthenticatedClient(base_url=YNAB_API_URL, token=settings.ynab.access_token)
|
|
52
|
+
|
|
53
|
+
async with client:
|
|
54
|
+
try:
|
|
55
|
+
payees = (
|
|
56
|
+
await util.get_asyncio_detailed(io, get_payees.asyncio_detailed, settings.ynab.budget_id, client=client)
|
|
57
|
+
).data.payees
|
|
58
|
+
payees.sort(key=lambda p: p.name)
|
|
59
|
+
|
|
60
|
+
progress_total = len(payees)
|
|
61
|
+
for payee in payees:
|
|
62
|
+
await io.progress.update(total=progress_total, advance=1)
|
|
63
|
+
|
|
64
|
+
if _should_skip_payee(payee=payee):
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
normalized_name = _normalize_name(payee.name)
|
|
68
|
+
if normalized_name != payee.name:
|
|
69
|
+
yield (payee, normalized_name)
|
|
70
|
+
|
|
71
|
+
if not settings.dry_run:
|
|
72
|
+
await util.run_asyncio_detailed(
|
|
73
|
+
io,
|
|
74
|
+
update_payee.asyncio_detailed,
|
|
75
|
+
settings.ynab.budget_id,
|
|
76
|
+
str(payee.id),
|
|
77
|
+
client=client,
|
|
78
|
+
body=models.PatchPayeeWrapper(payee=models.SavePayee(name=normalized_name)),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
except util.ApiError as e:
|
|
82
|
+
if e.status_code == 429:
|
|
83
|
+
await io.print("API rate limit exceeded. Try again later, or get a new access token.")
|
|
84
|
+
else:
|
|
85
|
+
await io.print(f"Exception when calling YNAB: {e}\n")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ListDuplicatesParams(TypedDict):
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def list_duplicates(
|
|
93
|
+
settings: Settings, io: ports.IO, params: ListDuplicatesParams, *, client: ynab.AuthenticatedClient | None = None
|
|
94
|
+
) -> AsyncIterator[tuple[models.Payee, models.Payee]]:
|
|
95
|
+
if client is None:
|
|
96
|
+
client = ynab.AuthenticatedClient(base_url=YNAB_API_URL, token=settings.ynab.access_token)
|
|
97
|
+
|
|
98
|
+
async with client:
|
|
99
|
+
try:
|
|
100
|
+
possible_duplicates: dict[tuple[UUID, str], list[tuple[UUID, str]]] = {}
|
|
101
|
+
|
|
102
|
+
payees = (
|
|
103
|
+
await util.get_asyncio_detailed(io, get_payees.asyncio_detailed, settings.ynab.budget_id, client=client)
|
|
104
|
+
).data.payees
|
|
105
|
+
payees.sort(key=lambda p: p.name)
|
|
106
|
+
|
|
107
|
+
progress_total = len(payees)
|
|
108
|
+
for idx, payee in enumerate(payees):
|
|
109
|
+
await io.progress.update(total=progress_total, advance=1)
|
|
110
|
+
|
|
111
|
+
if _should_skip_payee(payee=payee):
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
filtered_payees = list(payees)
|
|
115
|
+
del filtered_payees[idx]
|
|
116
|
+
|
|
117
|
+
for filtered_payee in filtered_payees:
|
|
118
|
+
if _should_skip_payee(payee=filtered_payee):
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
normalized_payee_name = _normalize_name(payee.name).lower()
|
|
122
|
+
normalized_filtered_payee_name = _normalize_name(filtered_payee.name).lower()
|
|
123
|
+
if fuzz.ratio(normalized_payee_name, normalized_filtered_payee_name) > 70:
|
|
124
|
+
# Check to see if we already tracked this possible duplicate in the other direction
|
|
125
|
+
existing_possible_duplicates = possible_duplicates.get((filtered_payee.id, filtered_payee.name))
|
|
126
|
+
if existing_possible_duplicates:
|
|
127
|
+
if (payee.id, payee.name) in existing_possible_duplicates:
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
possible_duplicates[(payee.id, payee.name)] = possible_duplicates.get(
|
|
131
|
+
(payee.id, payee.name), []
|
|
132
|
+
)
|
|
133
|
+
possible_duplicates[(payee.id, payee.name)].append((filtered_payee.id, filtered_payee.name))
|
|
134
|
+
|
|
135
|
+
yield (payee, filtered_payee)
|
|
136
|
+
|
|
137
|
+
except util.ApiError as e:
|
|
138
|
+
if e.status_code == 429:
|
|
139
|
+
await io.print("API rate limit exceeded. Try again later, or get a new access token.")
|
|
140
|
+
else:
|
|
141
|
+
await io.print(f"Exception when calling YNAB: {e}\n")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ListUnusedParams(TypedDict):
|
|
145
|
+
prefix_unused: bool
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def list_unused(
|
|
149
|
+
settings: Settings, io: ports.IO, params: ListUnusedParams, *, client: ynab.AuthenticatedClient | None = None
|
|
150
|
+
) -> AsyncIterator[models.Payee]:
|
|
151
|
+
if client is None:
|
|
152
|
+
client = ynab.AuthenticatedClient(base_url=YNAB_API_URL, token=settings.ynab.access_token)
|
|
153
|
+
|
|
154
|
+
async with client:
|
|
155
|
+
try:
|
|
156
|
+
payees = (
|
|
157
|
+
await util.get_asyncio_detailed(io, get_payees.asyncio_detailed, settings.ynab.budget_id, client=client)
|
|
158
|
+
).data.payees
|
|
159
|
+
payees.sort(key=lambda p: p.name)
|
|
160
|
+
|
|
161
|
+
progress_total = len(payees)
|
|
162
|
+
for payee in payees:
|
|
163
|
+
await io.progress.update(total=progress_total, advance=1)
|
|
164
|
+
|
|
165
|
+
if _should_skip_payee(payee=payee):
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
transactions = (
|
|
169
|
+
await util.get_asyncio_detailed(
|
|
170
|
+
io,
|
|
171
|
+
get_transactions_by_payee.asyncio_detailed,
|
|
172
|
+
settings.ynab.budget_id,
|
|
173
|
+
str(payee.id),
|
|
174
|
+
client=client,
|
|
175
|
+
)
|
|
176
|
+
).data.transactions
|
|
177
|
+
num_transactions = len(transactions)
|
|
178
|
+
|
|
179
|
+
# List unused payee if no transactions
|
|
180
|
+
if not num_transactions:
|
|
181
|
+
yield payee
|
|
182
|
+
|
|
183
|
+
# If prefix_unused is True, rename the payee
|
|
184
|
+
if not settings.dry_run and params.get("prefix_unused", False):
|
|
185
|
+
new_name = f"{UNUSED_PREFIX} {payee.name}"
|
|
186
|
+
await util.run_asyncio_detailed(
|
|
187
|
+
io,
|
|
188
|
+
update_payee.asyncio_detailed,
|
|
189
|
+
settings.ynab.budget_id,
|
|
190
|
+
str(payee.id),
|
|
191
|
+
client=client,
|
|
192
|
+
body=models.PatchPayeeWrapper(payee=models.SavePayee(name=new_name)),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
except util.ApiError as e:
|
|
196
|
+
if e.status_code == 429:
|
|
197
|
+
await io.print("API rate limit exceeded. Try again later, or get a new access token.")
|
|
198
|
+
else:
|
|
199
|
+
await io.print(f"Exception when calling YNAB: {e}\n")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class ListAllParams(TypedDict):
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def list_all(
|
|
207
|
+
settings: Settings, io: ports.IO, params: ListAllParams, *, client: ynab.AuthenticatedClient | None = None
|
|
208
|
+
) -> AsyncIterator[models.Payee]:
|
|
209
|
+
if client is None:
|
|
210
|
+
client = ynab.AuthenticatedClient(base_url=YNAB_API_URL, token=settings.ynab.access_token)
|
|
211
|
+
|
|
212
|
+
async with client:
|
|
213
|
+
try:
|
|
214
|
+
payees = (
|
|
215
|
+
await util.get_asyncio_detailed(io, get_payees.asyncio_detailed, settings.ynab.budget_id, client=client)
|
|
216
|
+
).data.payees
|
|
217
|
+
payees.sort(key=lambda p: p.name)
|
|
218
|
+
|
|
219
|
+
progress_total = len(payees)
|
|
220
|
+
for payee in payees:
|
|
221
|
+
await io.progress.update(total=progress_total, advance=1)
|
|
222
|
+
|
|
223
|
+
if _should_skip_payee(payee=payee):
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
yield payee
|
|
227
|
+
|
|
228
|
+
except util.ApiError as e:
|
|
229
|
+
if e.status_code == 429:
|
|
230
|
+
await io.print("API rate limit exceeded. Try again later, or get a new access token.")
|
|
231
|
+
else:
|
|
232
|
+
await io.print(f"Exception when calling YNAB: {e}\n")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator
|
|
2
|
+
from typing import TypedDict
|
|
3
|
+
|
|
4
|
+
import rule_engine
|
|
5
|
+
|
|
6
|
+
from ynab_cli.adapters import ynab
|
|
7
|
+
from ynab_cli.adapters.ynab import models, util
|
|
8
|
+
from ynab_cli.adapters.ynab.api.transactions import get_transactions, update_transactions
|
|
9
|
+
from ynab_cli.domain import ports
|
|
10
|
+
from ynab_cli.domain.constants import YNAB_API_URL
|
|
11
|
+
from ynab_cli.domain.models import rules
|
|
12
|
+
from ynab_cli.domain.settings import Settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _should_skip_transaction(transaction: models.TransactionDetail) -> bool:
|
|
16
|
+
if transaction.deleted or transaction.category_name in ["Split"]:
|
|
17
|
+
return True
|
|
18
|
+
return False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_save_transaction(
|
|
22
|
+
transaction_detail: models.TransactionDetail,
|
|
23
|
+
transaction_rules: rules.TransactionRules,
|
|
24
|
+
) -> models.SaveTransactionWithIdOrImportId | None:
|
|
25
|
+
transaction_detail_dict = transaction_detail.to_dict()
|
|
26
|
+
|
|
27
|
+
context = rule_engine.Context(default_value=None)
|
|
28
|
+
for rule in transaction_rules.transaction_rules:
|
|
29
|
+
if any(rule_engine.Rule(rule_str, context=context).matches(transaction_detail_dict) for rule_str in rule.rules):
|
|
30
|
+
if rule.patch:
|
|
31
|
+
save_transaction_dict = rule.patch.to_dict()
|
|
32
|
+
save_transaction_dict["id"] = transaction_detail.id
|
|
33
|
+
save_transaction_dict["import_id"] = transaction_detail.import_id
|
|
34
|
+
|
|
35
|
+
save_transaction = models.SaveTransactionWithIdOrImportId.from_dict(save_transaction_dict)
|
|
36
|
+
|
|
37
|
+
return save_transaction
|
|
38
|
+
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ApplyRulesParams(TypedDict):
|
|
43
|
+
transaction_rules: rules.TransactionRules
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def apply_rules(
|
|
47
|
+
settings: Settings, io: ports.IO, params: ApplyRulesParams
|
|
48
|
+
) -> AsyncIterator[tuple[models.TransactionDetail, models.SaveTransactionWithIdOrImportId]]:
|
|
49
|
+
async with ynab.AuthenticatedClient(base_url=YNAB_API_URL, token=settings.ynab.access_token) as client:
|
|
50
|
+
try:
|
|
51
|
+
transactions = (
|
|
52
|
+
await util.get_asyncio_detailed(
|
|
53
|
+
io,
|
|
54
|
+
get_transactions.asyncio_detailed,
|
|
55
|
+
settings.ynab.budget_id,
|
|
56
|
+
client=client,
|
|
57
|
+
type_=models.GetTransactionsType.UNAPPROVED,
|
|
58
|
+
)
|
|
59
|
+
).data.transactions
|
|
60
|
+
transactions.sort(key=lambda t: t.date)
|
|
61
|
+
|
|
62
|
+
save_transactions = []
|
|
63
|
+
|
|
64
|
+
progress_total = len(transactions)
|
|
65
|
+
for transaction in transactions:
|
|
66
|
+
await io.progress.update(total=progress_total, advance=1)
|
|
67
|
+
|
|
68
|
+
if _should_skip_transaction(transaction=transaction):
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
save_transaction = _get_save_transaction(
|
|
72
|
+
transaction_detail=transaction, transaction_rules=params["transaction_rules"]
|
|
73
|
+
)
|
|
74
|
+
if save_transaction:
|
|
75
|
+
yield (transaction, save_transaction)
|
|
76
|
+
|
|
77
|
+
if not settings.dry_run:
|
|
78
|
+
save_transactions.append(save_transaction)
|
|
79
|
+
|
|
80
|
+
if save_transactions:
|
|
81
|
+
await util.run_asyncio_detailed(
|
|
82
|
+
io,
|
|
83
|
+
update_transactions.asyncio_detailed,
|
|
84
|
+
settings.ynab.budget_id,
|
|
85
|
+
client=client,
|
|
86
|
+
body=models.PatchTransactionsWrapper(transactions=save_transactions),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
except util.ApiError as e:
|
|
90
|
+
if e.status_code == 429:
|
|
91
|
+
await io.print("API rate limit exceeded. Try again later, or get a new access token.")
|
|
92
|
+
else:
|
|
93
|
+
await io.print(f"Exception when calling YNAB: {e}\n")
|
ynab_cli/host/cli.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from click_default_group import DefaultGroup
|
|
3
|
+
|
|
4
|
+
from ynab_cli.domain.settings import Settings
|
|
5
|
+
from ynab_cli.host.click.cli import run
|
|
6
|
+
from ynab_cli.host.constants import CONTEXT_KEY_SETTINGS, ENV_PREFIX
|
|
7
|
+
from ynab_cli.host.textual.cli import tui
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group(cls=DefaultGroup, default="tui", default_if_no_args=True)
|
|
11
|
+
@click.option("--dry-run", is_flag=True, default=False, help="Run without making any changes.")
|
|
12
|
+
@click.option("--debug", is_flag=True, default=False, help="Enable debug mode for more verbose output.")
|
|
13
|
+
@click.pass_context
|
|
14
|
+
def cli(
|
|
15
|
+
ctx: click.Context,
|
|
16
|
+
dry_run: bool,
|
|
17
|
+
debug: bool,
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Main entrypoint."""
|
|
20
|
+
|
|
21
|
+
ctx.ensure_object(dict)
|
|
22
|
+
ctx.obj[CONTEXT_KEY_SETTINGS] = Settings(dry_run=dry_run, debug=debug)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
cli.add_command(run)
|
|
26
|
+
cli.add_command(tui)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main() -> None:
|
|
30
|
+
cli(
|
|
31
|
+
auto_envvar_prefix=ENV_PREFIX,
|
|
32
|
+
obj={}, # click context object
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from rich.logging import RichHandler
|
|
5
|
+
|
|
6
|
+
from ynab_cli.domain.settings import Settings, YnabSettings
|
|
7
|
+
from ynab_cli.host.click.commands import categories, payees, transactions
|
|
8
|
+
from ynab_cli.host.constants import CONTEXT_KEY_SETTINGS, ENV_PREFIX
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
@click.option(
|
|
13
|
+
"--access-token",
|
|
14
|
+
prompt=True,
|
|
15
|
+
hide_input=True,
|
|
16
|
+
envvar=f"{ENV_PREFIX}_ACCESS_TOKEN",
|
|
17
|
+
show_envvar=True,
|
|
18
|
+
help="YNAB API access token.",
|
|
19
|
+
)
|
|
20
|
+
@click.option("--budget-id", prompt=True, envvar=f"{ENV_PREFIX}_BUDGET_ID", show_envvar=True, help="YNAB budget ID.")
|
|
21
|
+
@click.pass_context
|
|
22
|
+
def run(
|
|
23
|
+
ctx: click.Context,
|
|
24
|
+
access_token: str,
|
|
25
|
+
budget_id: str,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Main entrypoint for YNAB CLI commands."""
|
|
28
|
+
|
|
29
|
+
ctx.ensure_object(dict)
|
|
30
|
+
settings: Settings = ctx.obj.get(CONTEXT_KEY_SETTINGS, Settings())
|
|
31
|
+
settings.ynab = YnabSettings(access_token=access_token, budget_id=budget_id)
|
|
32
|
+
ctx.obj[CONTEXT_KEY_SETTINGS] = settings
|
|
33
|
+
|
|
34
|
+
logging.basicConfig(
|
|
35
|
+
level="DEBUG" if settings.debug else "WARNING", # NOTSET, DEBUG, INFO, WARNING, ERROR, CRITICAL
|
|
36
|
+
format="%(message)s",
|
|
37
|
+
datefmt="[%X]",
|
|
38
|
+
handlers=[RichHandler()],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
run.add_command(payees.payees)
|
|
43
|
+
run.add_command(categories.categories)
|
|
44
|
+
run.add_command(transactions.transactions)
|
|
File without changes
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
|
|
6
|
+
from ynab_cli.adapters.rich import io
|
|
7
|
+
from ynab_cli.domain.settings import Settings
|
|
8
|
+
from ynab_cli.domain.use_cases import categories as use_cases
|
|
9
|
+
from ynab_cli.host.click.commands.rich.progress_table import ProgressTable
|
|
10
|
+
from ynab_cli.host.constants import CONTEXT_KEY_SETTINGS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def _list_unused(settings: Settings) -> None:
|
|
14
|
+
params: use_cases.ListUnusedParams = {}
|
|
15
|
+
|
|
16
|
+
table = Table(title="Unused Categories")
|
|
17
|
+
table.add_column("Category Group ID")
|
|
18
|
+
table.add_column("Category Group Name")
|
|
19
|
+
table.add_column("Category ID")
|
|
20
|
+
table.add_column("Category Name")
|
|
21
|
+
|
|
22
|
+
console = None
|
|
23
|
+
with ProgressTable(table) as progress:
|
|
24
|
+
console = progress.console
|
|
25
|
+
|
|
26
|
+
task_id = progress.add_task("Loading unused categories...")
|
|
27
|
+
async for category in use_cases.list_unused(settings, io.RichIO((progress, task_id)), params):
|
|
28
|
+
table.add_row(
|
|
29
|
+
str(category.category_group_id),
|
|
30
|
+
str(category.category_group_name),
|
|
31
|
+
str(category.id),
|
|
32
|
+
str(category.name),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if console:
|
|
36
|
+
console.print(table)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _list_all(settings: Settings) -> None:
|
|
40
|
+
params: use_cases.ListAllParams = {}
|
|
41
|
+
|
|
42
|
+
table = Table(title="All Categories")
|
|
43
|
+
table.add_column("Category Group ID")
|
|
44
|
+
table.add_column("Category Group Name")
|
|
45
|
+
table.add_column("Category ID")
|
|
46
|
+
table.add_column("Category Name")
|
|
47
|
+
|
|
48
|
+
console = None
|
|
49
|
+
with ProgressTable(table) as progress:
|
|
50
|
+
console = progress.console
|
|
51
|
+
|
|
52
|
+
task_id = progress.add_task("Loading all categories...")
|
|
53
|
+
async for category in use_cases.list_all(settings, io.RichIO((progress, task_id)), params):
|
|
54
|
+
table.add_row(
|
|
55
|
+
str(category.category_group_id),
|
|
56
|
+
str(category.category_group_name),
|
|
57
|
+
str(category.id),
|
|
58
|
+
str(category.name),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if console:
|
|
62
|
+
console.print(table)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@click.command()
|
|
66
|
+
@click.pass_context
|
|
67
|
+
def list_unused(ctx: click.Context) -> None:
|
|
68
|
+
"""List unused categories in the YNAB budget."""
|
|
69
|
+
|
|
70
|
+
ctx.ensure_object(dict)
|
|
71
|
+
asyncio.run(_list_unused(ctx.obj.get(CONTEXT_KEY_SETTINGS, Settings())))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@click.command()
|
|
75
|
+
@click.pass_context
|
|
76
|
+
def list_all(ctx: click.Context) -> None:
|
|
77
|
+
"""List all categories in the YNAB budget."""
|
|
78
|
+
|
|
79
|
+
ctx.ensure_object(dict)
|
|
80
|
+
asyncio.run(_list_all(ctx.obj.get(CONTEXT_KEY_SETTINGS, Settings())))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@click.group()
|
|
84
|
+
@click.pass_context
|
|
85
|
+
def categories(ctx: click.Context) -> None:
|
|
86
|
+
"""Manage YNAB categories in the YNAB budget."""
|
|
87
|
+
|
|
88
|
+
ctx.ensure_object(dict)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
categories.add_command(list_unused)
|
|
92
|
+
categories.add_command(list_all)
|