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.
Files changed (206) hide show
  1. ynab_cli/__init__.py +0 -0
  2. ynab_cli/adapters/__init__.py +0 -0
  3. ynab_cli/adapters/rich/__init__.py +0 -0
  4. ynab_cli/adapters/rich/io.py +38 -0
  5. ynab_cli/adapters/textual/__init__.py +0 -0
  6. ynab_cli/adapters/textual/io.py +62 -0
  7. ynab_cli/adapters/ynab/__init__.py +8 -0
  8. ynab_cli/adapters/ynab/api/__init__.py +1 -0
  9. ynab_cli/adapters/ynab/api/accounts/__init__.py +1 -0
  10. ynab_cli/adapters/ynab/api/accounts/create_account.py +186 -0
  11. ynab_cli/adapters/ynab/api/accounts/get_account_by_id.py +177 -0
  12. ynab_cli/adapters/ynab/api/accounts/get_accounts.py +184 -0
  13. ynab_cli/adapters/ynab/api/budgets/__init__.py +1 -0
  14. ynab_cli/adapters/ynab/api/budgets/get_budget_by_id.py +188 -0
  15. ynab_cli/adapters/ynab/api/budgets/get_budget_settings_by_id.py +163 -0
  16. ynab_cli/adapters/ynab/api/budgets/get_budgets.py +171 -0
  17. ynab_cli/adapters/ynab/api/categories/__init__.py +1 -0
  18. ynab_cli/adapters/ynab/api/categories/get_categories.py +188 -0
  19. ynab_cli/adapters/ynab/api/categories/get_category_by_id.py +180 -0
  20. ynab_cli/adapters/ynab/api/categories/get_month_category_by_id.py +194 -0
  21. ynab_cli/adapters/ynab/api/categories/update_category.py +199 -0
  22. ynab_cli/adapters/ynab/api/categories/update_month_category.py +213 -0
  23. ynab_cli/adapters/ynab/api/months/__init__.py +1 -0
  24. ynab_cli/adapters/ynab/api/months/get_budget_month.py +177 -0
  25. ynab_cli/adapters/ynab/api/months/get_budget_months.py +184 -0
  26. ynab_cli/adapters/ynab/api/payee_locations/__init__.py +1 -0
  27. ynab_cli/adapters/ynab/api/payee_locations/get_payee_location_by_id.py +176 -0
  28. ynab_cli/adapters/ynab/api/payee_locations/get_payee_locations.py +163 -0
  29. ynab_cli/adapters/ynab/api/payee_locations/get_payee_locations_by_payee.py +176 -0
  30. ynab_cli/adapters/ynab/api/payees/__init__.py +1 -0
  31. ynab_cli/adapters/ynab/api/payees/get_payee_by_id.py +176 -0
  32. ynab_cli/adapters/ynab/api/payees/get_payees.py +184 -0
  33. ynab_cli/adapters/ynab/api/payees/update_payee.py +199 -0
  34. ynab_cli/adapters/ynab/api/scheduled_transactions/__init__.py +1 -0
  35. ynab_cli/adapters/ynab/api/scheduled_transactions/create_scheduled_transaction.py +186 -0
  36. ynab_cli/adapters/ynab/api/scheduled_transactions/delete_scheduled_transaction.py +176 -0
  37. ynab_cli/adapters/ynab/api/scheduled_transactions/get_scheduled_transaction_by_id.py +176 -0
  38. ynab_cli/adapters/ynab/api/scheduled_transactions/get_scheduled_transactions.py +184 -0
  39. ynab_cli/adapters/ynab/api/scheduled_transactions/update_scheduled_transaction.py +199 -0
  40. ynab_cli/adapters/ynab/api/transactions/__init__.py +1 -0
  41. ynab_cli/adapters/ynab/api/transactions/create_transaction.py +202 -0
  42. ynab_cli/adapters/ynab/api/transactions/delete_transaction.py +176 -0
  43. ynab_cli/adapters/ynab/api/transactions/get_transaction_by_id.py +176 -0
  44. ynab_cli/adapters/ynab/api/transactions/get_transactions.py +227 -0
  45. ynab_cli/adapters/ynab/api/transactions/get_transactions_by_account.py +236 -0
  46. ynab_cli/adapters/ynab/api/transactions/get_transactions_by_category.py +236 -0
  47. ynab_cli/adapters/ynab/api/transactions/get_transactions_by_month.py +236 -0
  48. ynab_cli/adapters/ynab/api/transactions/get_transactions_by_payee.py +236 -0
  49. ynab_cli/adapters/ynab/api/transactions/import_transactions.py +183 -0
  50. ynab_cli/adapters/ynab/api/transactions/update_transaction.py +199 -0
  51. ynab_cli/adapters/ynab/api/transactions/update_transactions.py +177 -0
  52. ynab_cli/adapters/ynab/api/user/__init__.py +1 -0
  53. ynab_cli/adapters/ynab/api/user/get_user.py +130 -0
  54. ynab_cli/adapters/ynab/client.py +268 -0
  55. ynab_cli/adapters/ynab/errors.py +16 -0
  56. ynab_cli/adapters/ynab/models/__init__.py +229 -0
  57. ynab_cli/adapters/ynab/models/account.py +345 -0
  58. ynab_cli/adapters/ynab/models/account_response.py +65 -0
  59. ynab_cli/adapters/ynab/models/account_response_data.py +65 -0
  60. ynab_cli/adapters/ynab/models/account_type.py +23 -0
  61. ynab_cli/adapters/ynab/models/accounts_response.py +65 -0
  62. ynab_cli/adapters/ynab/models/accounts_response_data.py +81 -0
  63. ynab_cli/adapters/ynab/models/budget_detail.py +405 -0
  64. ynab_cli/adapters/ynab/models/budget_detail_response.py +65 -0
  65. ynab_cli/adapters/ynab/models/budget_detail_response_data.py +73 -0
  66. ynab_cli/adapters/ynab/models/budget_settings.py +115 -0
  67. ynab_cli/adapters/ynab/models/budget_settings_response.py +65 -0
  68. ynab_cli/adapters/ynab/models/budget_settings_response_data.py +65 -0
  69. ynab_cli/adapters/ynab/models/budget_summary.py +216 -0
  70. ynab_cli/adapters/ynab/models/budget_summary_response.py +65 -0
  71. ynab_cli/adapters/ynab/models/budget_summary_response_data.py +91 -0
  72. ynab_cli/adapters/ynab/models/bulk_response.py +65 -0
  73. ynab_cli/adapters/ynab/models/bulk_response_data.py +65 -0
  74. ynab_cli/adapters/ynab/models/bulk_response_data_bulk.py +68 -0
  75. ynab_cli/adapters/ynab/models/bulk_transactions.py +75 -0
  76. ynab_cli/adapters/ynab/models/categories_response.py +65 -0
  77. ynab_cli/adapters/ynab/models/categories_response_data.py +81 -0
  78. ynab_cli/adapters/ynab/models/category.py +519 -0
  79. ynab_cli/adapters/ynab/models/category_goal_type_type_1.py +15 -0
  80. ynab_cli/adapters/ynab/models/category_goal_type_type_2_type_1.py +15 -0
  81. ynab_cli/adapters/ynab/models/category_goal_type_type_3_type_1.py +15 -0
  82. ynab_cli/adapters/ynab/models/category_group.py +85 -0
  83. ynab_cli/adapters/ynab/models/category_group_with_categories.py +108 -0
  84. ynab_cli/adapters/ynab/models/category_response.py +65 -0
  85. ynab_cli/adapters/ynab/models/category_response_data.py +65 -0
  86. ynab_cli/adapters/ynab/models/currency_format_type_0.py +117 -0
  87. ynab_cli/adapters/ynab/models/date_format_type_0.py +61 -0
  88. ynab_cli/adapters/ynab/models/error_detail.py +75 -0
  89. ynab_cli/adapters/ynab/models/error_response.py +65 -0
  90. ynab_cli/adapters/ynab/models/existing_transaction.py +322 -0
  91. ynab_cli/adapters/ynab/models/get_transactions_by_account_type.py +12 -0
  92. ynab_cli/adapters/ynab/models/get_transactions_by_category_type.py +12 -0
  93. ynab_cli/adapters/ynab/models/get_transactions_by_month_type.py +12 -0
  94. ynab_cli/adapters/ynab/models/get_transactions_by_payee_type.py +12 -0
  95. ynab_cli/adapters/ynab/models/get_transactions_type.py +12 -0
  96. ynab_cli/adapters/ynab/models/hybrid_transaction.py +567 -0
  97. ynab_cli/adapters/ynab/models/hybrid_transaction_type.py +12 -0
  98. ynab_cli/adapters/ynab/models/hybrid_transactions_response.py +65 -0
  99. ynab_cli/adapters/ynab/models/hybrid_transactions_response_data.py +84 -0
  100. ynab_cli/adapters/ynab/models/loan_account_periodic_value_type_0.py +44 -0
  101. ynab_cli/adapters/ynab/models/month_detail.py +168 -0
  102. ynab_cli/adapters/ynab/models/month_detail_response.py +65 -0
  103. ynab_cli/adapters/ynab/models/month_detail_response_data.py +65 -0
  104. ynab_cli/adapters/ynab/models/month_summaries_response.py +65 -0
  105. ynab_cli/adapters/ynab/models/month_summaries_response_data.py +81 -0
  106. ynab_cli/adapters/ynab/models/month_summary.py +145 -0
  107. ynab_cli/adapters/ynab/models/new_transaction.py +352 -0
  108. ynab_cli/adapters/ynab/models/patch_category_wrapper.py +65 -0
  109. ynab_cli/adapters/ynab/models/patch_month_category_wrapper.py +65 -0
  110. ynab_cli/adapters/ynab/models/patch_payee_wrapper.py +65 -0
  111. ynab_cli/adapters/ynab/models/patch_transactions_wrapper.py +73 -0
  112. ynab_cli/adapters/ynab/models/payee.py +100 -0
  113. ynab_cli/adapters/ynab/models/payee_location.py +93 -0
  114. ynab_cli/adapters/ynab/models/payee_location_response.py +65 -0
  115. ynab_cli/adapters/ynab/models/payee_location_response_data.py +65 -0
  116. ynab_cli/adapters/ynab/models/payee_locations_response.py +65 -0
  117. ynab_cli/adapters/ynab/models/payee_locations_response_data.py +73 -0
  118. ynab_cli/adapters/ynab/models/payee_response.py +65 -0
  119. ynab_cli/adapters/ynab/models/payee_response_data.py +65 -0
  120. ynab_cli/adapters/ynab/models/payees_response.py +65 -0
  121. ynab_cli/adapters/ynab/models/payees_response_data.py +81 -0
  122. ynab_cli/adapters/ynab/models/post_account_wrapper.py +65 -0
  123. ynab_cli/adapters/ynab/models/post_scheduled_transaction_wrapper.py +65 -0
  124. ynab_cli/adapters/ynab/models/post_transactions_wrapper.py +91 -0
  125. ynab_cli/adapters/ynab/models/put_scheduled_transaction_wrapper.py +65 -0
  126. ynab_cli/adapters/ynab/models/put_transaction_wrapper.py +65 -0
  127. ynab_cli/adapters/ynab/models/save_account.py +77 -0
  128. ynab_cli/adapters/ynab/models/save_category.py +129 -0
  129. ynab_cli/adapters/ynab/models/save_category_response.py +65 -0
  130. ynab_cli/adapters/ynab/models/save_category_response_data.py +73 -0
  131. ynab_cli/adapters/ynab/models/save_month_category.py +59 -0
  132. ynab_cli/adapters/ynab/models/save_payee.py +59 -0
  133. ynab_cli/adapters/ynab/models/save_payee_response.py +65 -0
  134. ynab_cli/adapters/ynab/models/save_payee_response_data.py +73 -0
  135. ynab_cli/adapters/ynab/models/save_scheduled_transaction.py +269 -0
  136. ynab_cli/adapters/ynab/models/save_sub_transaction.py +166 -0
  137. ynab_cli/adapters/ynab/models/save_transaction_with_id_or_import_id.py +366 -0
  138. ynab_cli/adapters/ynab/models/save_transaction_with_optional_fields.py +322 -0
  139. ynab_cli/adapters/ynab/models/save_transactions_response.py +65 -0
  140. ynab_cli/adapters/ynab/models/save_transactions_response_data.py +123 -0
  141. ynab_cli/adapters/ynab/models/scheduled_sub_transaction.py +238 -0
  142. ynab_cli/adapters/ynab/models/scheduled_transaction_detail.py +381 -0
  143. ynab_cli/adapters/ynab/models/scheduled_transaction_frequency.py +23 -0
  144. ynab_cli/adapters/ynab/models/scheduled_transaction_response.py +65 -0
  145. ynab_cli/adapters/ynab/models/scheduled_transaction_response_data.py +65 -0
  146. ynab_cli/adapters/ynab/models/scheduled_transaction_summary.py +310 -0
  147. ynab_cli/adapters/ynab/models/scheduled_transaction_summary_frequency.py +23 -0
  148. ynab_cli/adapters/ynab/models/scheduled_transactions_response.py +65 -0
  149. ynab_cli/adapters/ynab/models/scheduled_transactions_response_data.py +81 -0
  150. ynab_cli/adapters/ynab/models/sub_transaction.py +259 -0
  151. ynab_cli/adapters/ynab/models/transaction_cleared_status.py +13 -0
  152. ynab_cli/adapters/ynab/models/transaction_detail.py +570 -0
  153. ynab_cli/adapters/ynab/models/transaction_flag_color_type_1.py +16 -0
  154. ynab_cli/adapters/ynab/models/transaction_flag_color_type_2_type_1.py +16 -0
  155. ynab_cli/adapters/ynab/models/transaction_flag_color_type_3_type_1.py +16 -0
  156. ynab_cli/adapters/ynab/models/transaction_response.py +65 -0
  157. ynab_cli/adapters/ynab/models/transaction_response_data.py +65 -0
  158. ynab_cli/adapters/ynab/models/transaction_summary.py +499 -0
  159. ynab_cli/adapters/ynab/models/transaction_summary_debt_transaction_type_type_1.py +18 -0
  160. ynab_cli/adapters/ynab/models/transaction_summary_debt_transaction_type_type_2_type_1.py +18 -0
  161. ynab_cli/adapters/ynab/models/transaction_summary_debt_transaction_type_type_3_type_1.py +18 -0
  162. ynab_cli/adapters/ynab/models/transactions_import_response.py +65 -0
  163. ynab_cli/adapters/ynab/models/transactions_import_response_data.py +59 -0
  164. ynab_cli/adapters/ynab/models/transactions_response.py +65 -0
  165. ynab_cli/adapters/ynab/models/transactions_response_data.py +81 -0
  166. ynab_cli/adapters/ynab/models/user.py +60 -0
  167. ynab_cli/adapters/ynab/models/user_response.py +65 -0
  168. ynab_cli/adapters/ynab/models/user_response_data.py +65 -0
  169. ynab_cli/adapters/ynab/types.py +46 -0
  170. ynab_cli/adapters/ynab/util.py +78 -0
  171. ynab_cli/domain/__init__.py +0 -0
  172. ynab_cli/domain/constants.py +3 -0
  173. ynab_cli/domain/models/__init__.py +0 -0
  174. ynab_cli/domain/models/rules.py +37 -0
  175. ynab_cli/domain/ports/__init__.py +5 -0
  176. ynab_cli/domain/ports/io.py +63 -0
  177. ynab_cli/domain/settings.py +16 -0
  178. ynab_cli/domain/use_cases/__init__.py +0 -0
  179. ynab_cli/domain/use_cases/categories.py +105 -0
  180. ynab_cli/domain/use_cases/payees.py +232 -0
  181. ynab_cli/domain/use_cases/transactions.py +93 -0
  182. ynab_cli/host/__init__.py +14 -0
  183. ynab_cli/host/cli.py +37 -0
  184. ynab_cli/host/click/__init__.py +0 -0
  185. ynab_cli/host/click/cli.py +44 -0
  186. ynab_cli/host/click/commands/__init__.py +0 -0
  187. ynab_cli/host/click/commands/categories.py +92 -0
  188. ynab_cli/host/click/commands/payees.py +157 -0
  189. ynab_cli/host/click/commands/rich/__init__.py +0 -0
  190. ynab_cli/host/click/commands/rich/progress_table.py +37 -0
  191. ynab_cli/host/click/commands/transactions.py +76 -0
  192. ynab_cli/host/constants.py +3 -0
  193. ynab_cli/host/textual/__init__.py +0 -0
  194. ynab_cli/host/textual/app.py +98 -0
  195. ynab_cli/host/textual/app.tcss +47 -0
  196. ynab_cli/host/textual/cli.py +42 -0
  197. ynab_cli/host/textual/widgets/__init__.py +58 -0
  198. ynab_cli/host/textual/widgets/categories.py +90 -0
  199. ynab_cli/host/textual/widgets/payees.py +154 -0
  200. ynab_cli/host/textual/widgets/transactions.py +89 -0
  201. ynab_cli/py.typed +0 -0
  202. ynab_cli-0.3.0.dist-info/METADATA +102 -0
  203. ynab_cli-0.3.0.dist-info/RECORD +206 -0
  204. ynab_cli-0.3.0.dist-info/WHEEL +4 -0
  205. ynab_cli-0.3.0.dist-info/entry_points.txt +2 -0
  206. 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)
@@ -0,0 +1,3 @@
1
+ ENV_PREFIX = "YNAB_CLI"
2
+
3
+ CONTEXT_KEY_SETTINGS = "SETTINGS"
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
+ )