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,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")
@@ -0,0 +1,14 @@
1
+ import os
2
+
3
+ from dotenv import load_dotenv
4
+
5
+ cwd = os.getcwd()
6
+ dot_env_path = os.path.join(cwd, ".env")
7
+
8
+
9
+ def setup_env() -> None:
10
+ if os.path.exists(dot_env_path):
11
+ load_dotenv(dot_env_path)
12
+
13
+
14
+ setup_env()
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)