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,157 @@
|
|
|
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 payees 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 _normalize_names(settings: Settings) -> None:
|
|
14
|
+
params: use_cases.NormalizeNamesParams = {}
|
|
15
|
+
|
|
16
|
+
table = Table(title="Normalized Payees")
|
|
17
|
+
table.add_column("Payee Id")
|
|
18
|
+
table.add_column("Payee Name")
|
|
19
|
+
table.add_column("Normalized Name")
|
|
20
|
+
|
|
21
|
+
console = None
|
|
22
|
+
with ProgressTable(table) as progress:
|
|
23
|
+
console = progress.console
|
|
24
|
+
|
|
25
|
+
task_id = progress.add_task("Normalizing payee names...")
|
|
26
|
+
async for payee, normalized_name in use_cases.normalize_names(settings, io.RichIO((progress, task_id)), params):
|
|
27
|
+
table.add_row(
|
|
28
|
+
str(payee.id),
|
|
29
|
+
payee.name,
|
|
30
|
+
normalized_name,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if console:
|
|
34
|
+
console.print(table)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _list_duplicates(settings: Settings) -> None:
|
|
38
|
+
params: use_cases.ListDuplicatesParams = {}
|
|
39
|
+
|
|
40
|
+
table = Table(title="Duplicate Payees")
|
|
41
|
+
table.add_column("Payee Id")
|
|
42
|
+
table.add_column("Payee Name")
|
|
43
|
+
table.add_column("Duplicate Payee Id")
|
|
44
|
+
table.add_column("Duplicate Payee Name")
|
|
45
|
+
|
|
46
|
+
console = None
|
|
47
|
+
with ProgressTable(table) as progress:
|
|
48
|
+
console = progress.console
|
|
49
|
+
|
|
50
|
+
task_id = progress.add_task("Loading duplicate payees...")
|
|
51
|
+
async for payee, duplicate_payee in use_cases.list_duplicates(settings, io.RichIO((progress, task_id)), params):
|
|
52
|
+
table.add_row(
|
|
53
|
+
str(payee.id),
|
|
54
|
+
payee.name,
|
|
55
|
+
str(duplicate_payee.id),
|
|
56
|
+
duplicate_payee.name,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if console:
|
|
60
|
+
console.print(table)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _list_unused(settings: Settings, prefix_unused: bool) -> None:
|
|
64
|
+
params: use_cases.ListUnusedParams = {
|
|
65
|
+
"prefix_unused": prefix_unused,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
table = Table(title="Unused Payees")
|
|
69
|
+
table.add_column("Payee Id")
|
|
70
|
+
table.add_column("Payee Name")
|
|
71
|
+
|
|
72
|
+
console = None
|
|
73
|
+
with ProgressTable(table) as progress:
|
|
74
|
+
console = progress.console
|
|
75
|
+
|
|
76
|
+
task_id = progress.add_task("Loading unused payees...")
|
|
77
|
+
async for payee in use_cases.list_unused(settings, io.RichIO((progress, task_id)), params):
|
|
78
|
+
table.add_row(
|
|
79
|
+
str(payee.id),
|
|
80
|
+
payee.name,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if console:
|
|
84
|
+
console.print(table)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def _list_all(settings: Settings) -> None:
|
|
88
|
+
params: use_cases.ListAllParams = {}
|
|
89
|
+
|
|
90
|
+
table = Table(title="All Payees")
|
|
91
|
+
table.add_column("Payee Id")
|
|
92
|
+
table.add_column("Payee Name")
|
|
93
|
+
|
|
94
|
+
console = None
|
|
95
|
+
with ProgressTable(table) as progress:
|
|
96
|
+
console = progress.console
|
|
97
|
+
|
|
98
|
+
task_id = progress.add_task("Loading all payees...")
|
|
99
|
+
async for payee in use_cases.list_all(settings, io.RichIO((progress, task_id)), params):
|
|
100
|
+
table.add_row(
|
|
101
|
+
str(payee.id),
|
|
102
|
+
payee.name,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if console:
|
|
106
|
+
console.print(table)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@click.command()
|
|
110
|
+
@click.pass_context
|
|
111
|
+
def normalize_names(ctx: click.Context) -> None:
|
|
112
|
+
"""Normalize payee names in the YNAB budget."""
|
|
113
|
+
|
|
114
|
+
ctx.ensure_object(dict)
|
|
115
|
+
asyncio.run(_normalize_names(ctx.obj.get(CONTEXT_KEY_SETTINGS, Settings())))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@click.command()
|
|
119
|
+
@click.pass_context
|
|
120
|
+
def list_duplicates(ctx: click.Context) -> None:
|
|
121
|
+
"""List duplicate payees in the YNAB budget."""
|
|
122
|
+
|
|
123
|
+
ctx.ensure_object(dict)
|
|
124
|
+
asyncio.run(_list_duplicates(ctx.obj.get(CONTEXT_KEY_SETTINGS, Settings())))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@click.command()
|
|
128
|
+
@click.option("--prefix-unused", is_flag=True, default=False, help="Add a prefix to the unused payee names.")
|
|
129
|
+
@click.pass_context
|
|
130
|
+
def list_unused(ctx: click.Context, prefix_unused: bool) -> None:
|
|
131
|
+
"""List unused payees in the YNAB budget."""
|
|
132
|
+
|
|
133
|
+
ctx.ensure_object(dict)
|
|
134
|
+
asyncio.run(_list_unused(ctx.obj.get(CONTEXT_KEY_SETTINGS, Settings()), prefix_unused))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@click.command()
|
|
138
|
+
@click.pass_context
|
|
139
|
+
def list_all(ctx: click.Context) -> None:
|
|
140
|
+
"""List all payees in the YNAB budget."""
|
|
141
|
+
|
|
142
|
+
ctx.ensure_object(dict)
|
|
143
|
+
asyncio.run(_list_all(ctx.obj.get(CONTEXT_KEY_SETTINGS, Settings())))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@click.group()
|
|
147
|
+
@click.pass_context
|
|
148
|
+
def payees(ctx: click.Context) -> None:
|
|
149
|
+
"""Manage payees in the YNAB budget."""
|
|
150
|
+
|
|
151
|
+
ctx.ensure_object(dict)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
payees.add_command(normalize_names)
|
|
155
|
+
payees.add_command(list_duplicates)
|
|
156
|
+
payees.add_command(list_unused)
|
|
157
|
+
payees.add_command(list_all)
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from rich import console as rich_console
|
|
4
|
+
from rich import progress as rich_progress
|
|
5
|
+
from rich import table as rich_table
|
|
6
|
+
from typing_extensions import override
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProgressTable(rich_progress.Progress):
|
|
10
|
+
@override
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
table: rich_table.Table,
|
|
14
|
+
*columns: str | rich_progress.ProgressColumn,
|
|
15
|
+
auto_refresh: bool = True,
|
|
16
|
+
transient: bool = True,
|
|
17
|
+
**kwargs: Any,
|
|
18
|
+
) -> None:
|
|
19
|
+
self._render_table = False
|
|
20
|
+
self._table = table
|
|
21
|
+
super().__init__(*columns, auto_refresh=auto_refresh, transient=transient, **kwargs)
|
|
22
|
+
|
|
23
|
+
@override
|
|
24
|
+
def get_renderable(self) -> rich_console.RenderableType:
|
|
25
|
+
if self._render_table and self.live.is_started:
|
|
26
|
+
return rich_console.Group(*self.get_renderables(), self._table)
|
|
27
|
+
return super().get_renderable()
|
|
28
|
+
|
|
29
|
+
@override
|
|
30
|
+
def start(self) -> None:
|
|
31
|
+
self._render_table = True
|
|
32
|
+
return super().start()
|
|
33
|
+
|
|
34
|
+
@override
|
|
35
|
+
def stop(self) -> None:
|
|
36
|
+
self._render_table = False
|
|
37
|
+
return super().stop()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from typing import IO, Any
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from ynab_cli.adapters.rich import io
|
|
9
|
+
from ynab_cli.domain.models import rules
|
|
10
|
+
from ynab_cli.domain.settings import Settings
|
|
11
|
+
from ynab_cli.domain.use_cases import transactions as use_cases
|
|
12
|
+
from ynab_cli.host.click.commands.rich.progress_table import ProgressTable
|
|
13
|
+
from ynab_cli.host.constants import CONTEXT_KEY_SETTINGS
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def _apply_rules(settings: Settings, transaction_rules: rules.TransactionRules) -> None:
|
|
17
|
+
params: use_cases.ApplyRulesParams = {
|
|
18
|
+
"transaction_rules": transaction_rules,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
table = Table(title="Updated Transactions")
|
|
22
|
+
table.add_column("Transaction Id")
|
|
23
|
+
table.add_column("Transaction Date")
|
|
24
|
+
table.add_column("Transaction Payee")
|
|
25
|
+
table.add_column("Transaction Category")
|
|
26
|
+
table.add_column("Transaction Memo")
|
|
27
|
+
table.add_column("Transaction Amount")
|
|
28
|
+
table.add_column("Transaction Changes")
|
|
29
|
+
|
|
30
|
+
console = None
|
|
31
|
+
with ProgressTable(table) as progress:
|
|
32
|
+
console = progress.console
|
|
33
|
+
|
|
34
|
+
task_id = progress.add_task("Updating transactions...")
|
|
35
|
+
async for transaction, save_transaction in use_cases.apply_rules(
|
|
36
|
+
settings, io.RichIO((progress, task_id)), params
|
|
37
|
+
):
|
|
38
|
+
table.add_row(
|
|
39
|
+
transaction.id,
|
|
40
|
+
transaction.date.isoformat(),
|
|
41
|
+
str(transaction.payee_name),
|
|
42
|
+
str(transaction.category_name),
|
|
43
|
+
str(transaction.memo),
|
|
44
|
+
str(transaction.amount),
|
|
45
|
+
str(save_transaction.to_dict()),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if console:
|
|
49
|
+
console.print(table)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@click.command()
|
|
53
|
+
@click.argument("rules-file", type=click.File())
|
|
54
|
+
@click.pass_context
|
|
55
|
+
def apply_rules(ctx: click.Context, rules_file: IO[Any]) -> None:
|
|
56
|
+
"""Apply transaction rules from a JSON RULES_FILE to transactions in the YNAB budget.
|
|
57
|
+
|
|
58
|
+
RULES_FILE should be a JSON file containing transaction rules.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
ctx.ensure_object(dict)
|
|
62
|
+
|
|
63
|
+
transaction_rules = rules.TransactionRules.from_dict(json.load(rules_file))
|
|
64
|
+
|
|
65
|
+
asyncio.run(_apply_rules(ctx.obj.get(CONTEXT_KEY_SETTINGS, Settings()), transaction_rules))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@click.group()
|
|
69
|
+
@click.pass_context
|
|
70
|
+
def transactions(ctx: click.Context) -> None:
|
|
71
|
+
"""Manage transactions in the YNAB budget."""
|
|
72
|
+
|
|
73
|
+
ctx.ensure_object(dict)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
transactions.add_command(apply_rules)
|
|
File without changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from typing import Any, ClassVar
|
|
2
|
+
|
|
3
|
+
from textual.app import App as _App
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.binding import Binding, BindingType
|
|
6
|
+
from textual.containers import Horizontal, Vertical
|
|
7
|
+
from textual.reactive import reactive
|
|
8
|
+
from textual.widgets import Checkbox, Footer, Header, Input, Label, TabbedContent, TabPane
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
|
|
11
|
+
from ynab_cli.domain.settings import Settings
|
|
12
|
+
from ynab_cli.host.textual.widgets import RunnableWidget
|
|
13
|
+
from ynab_cli.host.textual.widgets.categories import CategoriesTabs
|
|
14
|
+
from ynab_cli.host.textual.widgets.payees import PayeesTabs
|
|
15
|
+
from ynab_cli.host.textual.widgets.transactions import TransactionsTabs
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CommandTabs(RunnableWidget):
|
|
19
|
+
@override
|
|
20
|
+
def compose(self) -> ComposeResult:
|
|
21
|
+
with TabbedContent():
|
|
22
|
+
with TabPane("Transactions", id="transactions"):
|
|
23
|
+
yield TransactionsTabs().data_bind(settings=CommandTabs.settings)
|
|
24
|
+
with TabPane("Categories", id="categories"):
|
|
25
|
+
yield CategoriesTabs().data_bind(settings=CommandTabs.settings)
|
|
26
|
+
with TabPane("Payees", id="payees"):
|
|
27
|
+
yield PayeesTabs().data_bind(settings=CommandTabs.settings)
|
|
28
|
+
|
|
29
|
+
@override
|
|
30
|
+
async def run_command(self) -> None:
|
|
31
|
+
tabbed_content = self.query_one(TabbedContent)
|
|
32
|
+
if tabbed_content.active_pane:
|
|
33
|
+
if tabbed_content.active_pane.id == "transactions":
|
|
34
|
+
await self.query_one(TransactionsTabs).run_command()
|
|
35
|
+
elif tabbed_content.active_pane.id == "categories":
|
|
36
|
+
await self.query_one(CategoriesTabs).run_command()
|
|
37
|
+
elif tabbed_content.active_pane.id == "payees":
|
|
38
|
+
await self.query_one(PayeesTabs).run_command()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class App(_App[None]):
|
|
42
|
+
TITLE = "YNAB CLI"
|
|
43
|
+
CSS_PATH: ClassVar[str] = "app.tcss"
|
|
44
|
+
|
|
45
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
46
|
+
Binding("ctrl+c", "quit", "Quit", show=True, priority=True),
|
|
47
|
+
Binding("r", "run", "Run", show=True, priority=False),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
settings: reactive[Settings] = reactive(Settings())
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
settings: Settings,
|
|
55
|
+
**kwargs: Any,
|
|
56
|
+
) -> None:
|
|
57
|
+
super().__init__(**kwargs)
|
|
58
|
+
self.settings = settings
|
|
59
|
+
|
|
60
|
+
@override
|
|
61
|
+
def compose(self) -> ComposeResult:
|
|
62
|
+
yield Header(id="header")
|
|
63
|
+
with Vertical(id="settings"):
|
|
64
|
+
with Horizontal():
|
|
65
|
+
with Vertical():
|
|
66
|
+
yield Label("Access Token")
|
|
67
|
+
yield Input(self.settings.ynab.access_token, placeholder="Access Token", id="access_token")
|
|
68
|
+
with Vertical():
|
|
69
|
+
yield Label("Budget Id")
|
|
70
|
+
yield Input(self.settings.ynab.budget_id, placeholder="Budget Id", id="budget_id")
|
|
71
|
+
with Horizontal():
|
|
72
|
+
yield Checkbox("Debug", self.settings.debug, id="debug")
|
|
73
|
+
yield Checkbox("Dry Run", self.settings.dry_run, id="dry_run")
|
|
74
|
+
|
|
75
|
+
with Vertical(id="main"):
|
|
76
|
+
yield CommandTabs().data_bind(settings=App.settings)
|
|
77
|
+
yield Footer(id="footer")
|
|
78
|
+
|
|
79
|
+
def on_mount(self) -> None:
|
|
80
|
+
self.query_one(CommandTabs).query_one(TabbedContent).focus()
|
|
81
|
+
|
|
82
|
+
async def action_run(self) -> None:
|
|
83
|
+
command_tabs = self.query_one(CommandTabs)
|
|
84
|
+
await command_tabs.run_command()
|
|
85
|
+
|
|
86
|
+
async def on_input_changed(self, event: Input.Changed) -> None:
|
|
87
|
+
if event.input.id == "access_token":
|
|
88
|
+
self.settings.ynab.access_token = event.value
|
|
89
|
+
elif event.input.id == "budget_id":
|
|
90
|
+
self.settings.ynab.budget_id = event.value
|
|
91
|
+
self.mutate_reactive(App.settings)
|
|
92
|
+
|
|
93
|
+
async def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
|
94
|
+
if event.checkbox.id == "debug":
|
|
95
|
+
self.settings.debug = event.value
|
|
96
|
+
elif event.checkbox.id == "dry_run":
|
|
97
|
+
self.settings.dry_run = event.value
|
|
98
|
+
self.mutate_reactive(App.settings)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Screen {
|
|
2
|
+
Vertical#settings {
|
|
3
|
+
margin-top: 1;
|
|
4
|
+
height: auto;
|
|
5
|
+
border: solid $primary;
|
|
6
|
+
|
|
7
|
+
Horizontal {
|
|
8
|
+
height: auto;
|
|
9
|
+
|
|
10
|
+
Vertical {
|
|
11
|
+
height: auto;
|
|
12
|
+
|
|
13
|
+
Input {
|
|
14
|
+
border: solid $secondary;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
Vertical#main {
|
|
20
|
+
margin-top: 1;
|
|
21
|
+
margin-bottom: 1;
|
|
22
|
+
|
|
23
|
+
CommandTabs {
|
|
24
|
+
height: 1fr;
|
|
25
|
+
TransactionsTabs, CategoriesTabs, PayeesTabs {
|
|
26
|
+
padding-top: 1;
|
|
27
|
+
border: solid $primary;
|
|
28
|
+
height: 1fr;
|
|
29
|
+
|
|
30
|
+
DataTable {
|
|
31
|
+
border: solid $secondary;
|
|
32
|
+
height: 3fr;
|
|
33
|
+
}
|
|
34
|
+
Log {
|
|
35
|
+
border: solid $secondary;
|
|
36
|
+
height: 1fr;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
IOScreen {
|
|
44
|
+
Label {
|
|
45
|
+
margin: 1;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
from textual.logging import TextualHandler
|
|
6
|
+
|
|
7
|
+
from ynab_cli.domain.settings import Settings, YnabSettings
|
|
8
|
+
from ynab_cli.host.constants import CONTEXT_KEY_SETTINGS, ENV_PREFIX
|
|
9
|
+
from ynab_cli.host.textual.app import App
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def _tui(settings: Settings) -> None:
|
|
13
|
+
app = App(settings)
|
|
14
|
+
await app.run_async()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.command()
|
|
18
|
+
@click.option(
|
|
19
|
+
"--access-token", default="", envvar=f"{ENV_PREFIX}_ACCESS_TOKEN", show_envvar=True, help="YNAB API access token."
|
|
20
|
+
)
|
|
21
|
+
@click.option("--budget-id", default="", envvar=f"{ENV_PREFIX}_BUDGET_ID", show_envvar=True, help="YNAB budget ID.")
|
|
22
|
+
@click.pass_context
|
|
23
|
+
def tui(
|
|
24
|
+
ctx: click.Context,
|
|
25
|
+
access_token: str,
|
|
26
|
+
budget_id: str,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Run the Textual User Interface (TUI) for YNAB CLI."""
|
|
29
|
+
|
|
30
|
+
ctx.ensure_object(dict)
|
|
31
|
+
settings: Settings = ctx.obj.get(CONTEXT_KEY_SETTINGS, Settings())
|
|
32
|
+
settings.ynab = YnabSettings(access_token=access_token, budget_id=budget_id)
|
|
33
|
+
ctx.obj[CONTEXT_KEY_SETTINGS] = settings
|
|
34
|
+
|
|
35
|
+
logging.basicConfig(
|
|
36
|
+
level="DEBUG" if settings.debug else "WARNING", # NOTSET, DEBUG, INFO, WARNING, ERROR, CRITICAL
|
|
37
|
+
format="%(message)s",
|
|
38
|
+
datefmt="[%X]",
|
|
39
|
+
handlers=[TextualHandler()],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
asyncio.run(_tui(ctx.obj[CONTEXT_KEY_SETTINGS]))
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Generic, TypeVar
|
|
3
|
+
|
|
4
|
+
from textual import work
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Vertical
|
|
7
|
+
from textual.message import Message
|
|
8
|
+
from textual.reactive import var
|
|
9
|
+
from textual.widgets import DataTable, Log, ProgressBar, Static
|
|
10
|
+
from textual.worker import Worker, WorkerState
|
|
11
|
+
from typing_extensions import override
|
|
12
|
+
|
|
13
|
+
from ynab_cli.domain.settings import Settings
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
PARAMS = TypeVar("PARAMS")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RunnableWidget(Static):
|
|
22
|
+
settings: var[Settings] = var(Settings())
|
|
23
|
+
|
|
24
|
+
async def run_command(self) -> None:
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BaseCommand(RunnableWidget, Generic[PARAMS]):
|
|
29
|
+
class CommandCompleted(Message):
|
|
30
|
+
def __init__(self, worker_state: WorkerState) -> None:
|
|
31
|
+
super().__init__()
|
|
32
|
+
self.worker_state = worker_state
|
|
33
|
+
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
@override
|
|
37
|
+
def compose(self) -> ComposeResult:
|
|
38
|
+
with Vertical():
|
|
39
|
+
yield ProgressBar(total=0)
|
|
40
|
+
yield DataTable()
|
|
41
|
+
yield Log()
|
|
42
|
+
|
|
43
|
+
@work(exclusive=True)
|
|
44
|
+
async def _run_command_worker(self, use_case_params: PARAMS) -> None:
|
|
45
|
+
self.query_one(ProgressBar).update(total=None, progress=0)
|
|
46
|
+
self.query_one(DataTable).clear()
|
|
47
|
+
self.query_one(Log).write_line("Running command...")
|
|
48
|
+
await self._run_command(use_case_params)
|
|
49
|
+
|
|
50
|
+
async def _run_command(self, use_case_params: PARAMS) -> None:
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
|
|
53
|
+
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
54
|
+
log = self.query_one(Log)
|
|
55
|
+
log.write_line(f"Worker state changed: {event.state}")
|
|
56
|
+
|
|
57
|
+
if event.state not in (WorkerState.PENDING, WorkerState.RUNNING):
|
|
58
|
+
self.post_message(self.CommandCompleted(event.state))
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from textual.app import ComposeResult
|
|
2
|
+
from textual.widgets import DataTable, Log, ProgressBar, TabbedContent, TabPane
|
|
3
|
+
from typing_extensions import override
|
|
4
|
+
|
|
5
|
+
from ynab_cli.adapters.textual.io import TextualWorkerIO
|
|
6
|
+
from ynab_cli.domain.use_cases import categories as use_cases
|
|
7
|
+
from ynab_cli.host.textual.widgets import BaseCommand, RunnableWidget
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CategoriesTabs(RunnableWidget):
|
|
11
|
+
@override
|
|
12
|
+
def compose(self) -> ComposeResult:
|
|
13
|
+
with TabbedContent():
|
|
14
|
+
with TabPane("Unused", id="unused"):
|
|
15
|
+
yield CategoriesListUnusedCommand().data_bind(settings=CategoriesTabs.settings)
|
|
16
|
+
with TabPane("All", id="all"):
|
|
17
|
+
yield CategoriesListAllCommand().data_bind(settings=CategoriesTabs.settings)
|
|
18
|
+
|
|
19
|
+
@override
|
|
20
|
+
async def run_command(self) -> None:
|
|
21
|
+
tabbed_content = self.query_one(TabbedContent)
|
|
22
|
+
if tabbed_content.active_pane:
|
|
23
|
+
if tabbed_content.active_pane.id == "unused":
|
|
24
|
+
await self.query_one(CategoriesListUnusedCommand).run_command()
|
|
25
|
+
elif tabbed_content.active_pane.id == "all":
|
|
26
|
+
await self.query_one(CategoriesListAllCommand).run_command()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CategoriesListUnusedCommand(BaseCommand[use_cases.ListUnusedParams]):
|
|
30
|
+
def on_mount(self) -> None:
|
|
31
|
+
table = self.query_one(DataTable)
|
|
32
|
+
table.add_columns(
|
|
33
|
+
"Category Group Id",
|
|
34
|
+
"Category Group Name",
|
|
35
|
+
"Category Id",
|
|
36
|
+
"Category Name",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@override
|
|
40
|
+
async def run_command(self) -> None:
|
|
41
|
+
params: use_cases.ListUnusedParams = {}
|
|
42
|
+
self._run_command_worker(params)
|
|
43
|
+
|
|
44
|
+
@override
|
|
45
|
+
async def _run_command(self, use_case_params: use_cases.ListUnusedParams) -> None:
|
|
46
|
+
progress_bar = self.query_one(ProgressBar)
|
|
47
|
+
table = self.query_one(DataTable)
|
|
48
|
+
log = self.query_one(Log)
|
|
49
|
+
|
|
50
|
+
async for category in use_cases.list_unused(
|
|
51
|
+
self.settings, TextualWorkerIO(self.app, log, progress_bar), use_case_params
|
|
52
|
+
):
|
|
53
|
+
table.add_row(
|
|
54
|
+
category.category_group_id,
|
|
55
|
+
category.category_group_name,
|
|
56
|
+
category.id,
|
|
57
|
+
category.name,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CategoriesListAllCommand(BaseCommand[use_cases.ListAllParams]):
|
|
62
|
+
def on_mount(self) -> None:
|
|
63
|
+
table = self.query_one(DataTable)
|
|
64
|
+
table.add_columns(
|
|
65
|
+
"Category Group Id",
|
|
66
|
+
"Category Group Name",
|
|
67
|
+
"Category Id",
|
|
68
|
+
"Category Name",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@override
|
|
72
|
+
async def run_command(self) -> None:
|
|
73
|
+
params: use_cases.ListAllParams = {}
|
|
74
|
+
self._run_command_worker(params)
|
|
75
|
+
|
|
76
|
+
@override
|
|
77
|
+
async def _run_command(self, use_case_params: use_cases.ListAllParams) -> None:
|
|
78
|
+
progress_bar = self.query_one(ProgressBar)
|
|
79
|
+
table = self.query_one(DataTable)
|
|
80
|
+
log = self.query_one(Log)
|
|
81
|
+
|
|
82
|
+
async for category in use_cases.list_all(
|
|
83
|
+
self.settings, TextualWorkerIO(self.app, log, progress_bar), use_case_params
|
|
84
|
+
):
|
|
85
|
+
table.add_row(
|
|
86
|
+
category.category_group_id,
|
|
87
|
+
category.category_group_name,
|
|
88
|
+
category.id,
|
|
89
|
+
category.name,
|
|
90
|
+
)
|