ynab-api-cli 0.1.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 (225) hide show
  1. ynab_api_cli/__init__.py +3 -0
  2. ynab_api_cli/__main__.py +266 -0
  3. ynab_api_cli/cli.py +171 -0
  4. ynab_api_cli/compat.py +120 -0
  5. ynab_api_cli/config.py +110 -0
  6. ynab_api_cli/contract.py +197 -0
  7. ynab_api_cli/contract_testing.py +141 -0
  8. ynab_api_cli/development.py +269 -0
  9. ynab_api_cli/distribution.py +123 -0
  10. ynab_api_cli/exceptions.py +7 -0
  11. ynab_api_cli/generated/__init__.py +3 -0
  12. ynab_api_cli/generated/api/__init__.py +3 -0
  13. ynab_api_cli/generated/api/accounts/__init__.py +3 -0
  14. ynab_api_cli/generated/api/accounts/create_account.py +54 -0
  15. ynab_api_cli/generated/api/accounts/get_account_by_id.py +53 -0
  16. ynab_api_cli/generated/api/accounts/get_accounts.py +57 -0
  17. ynab_api_cli/generated/api/categories/__init__.py +3 -0
  18. ynab_api_cli/generated/api/categories/create_category.py +54 -0
  19. ynab_api_cli/generated/api/categories/create_category_group.py +58 -0
  20. ynab_api_cli/generated/api/categories/get_categories.py +57 -0
  21. ynab_api_cli/generated/api/categories/get_category_by_id.py +52 -0
  22. ynab_api_cli/generated/api/categories/get_month_category_by_id.py +58 -0
  23. ynab_api_cli/generated/api/categories/update_category.py +57 -0
  24. ynab_api_cli/generated/api/categories/update_category_group.py +63 -0
  25. ynab_api_cli/generated/api/categories/update_month_category.py +65 -0
  26. ynab_api_cli/generated/api/money_movements/__init__.py +3 -0
  27. ynab_api_cli/generated/api/money_movements/get_money_movement_groups.py +51 -0
  28. ynab_api_cli/generated/api/money_movements/get_money_movement_groups_by_month.py +57 -0
  29. ynab_api_cli/generated/api/money_movements/get_money_movements.py +51 -0
  30. ynab_api_cli/generated/api/money_movements/get_money_movements_by_month.py +57 -0
  31. ynab_api_cli/generated/api/months/__init__.py +3 -0
  32. ynab_api_cli/generated/api/months/get_plan_month.py +55 -0
  33. ynab_api_cli/generated/api/months/get_plan_months.py +59 -0
  34. ynab_api_cli/generated/api/payee_locations/__init__.py +3 -0
  35. ynab_api_cli/generated/api/payee_locations/get_payee_location_by_id.py +54 -0
  36. ynab_api_cli/generated/api/payee_locations/get_payee_locations.py +51 -0
  37. ynab_api_cli/generated/api/payee_locations/get_payee_locations_by_payee.py +54 -0
  38. ynab_api_cli/generated/api/payees/__init__.py +3 -0
  39. ynab_api_cli/generated/api/payees/create_payee.py +54 -0
  40. ynab_api_cli/generated/api/payees/get_payee_by_id.py +52 -0
  41. ynab_api_cli/generated/api/payees/get_payees.py +57 -0
  42. ynab_api_cli/generated/api/payees/update_payee.py +57 -0
  43. ynab_api_cli/generated/api/plans/__init__.py +3 -0
  44. ynab_api_cli/generated/api/plans/get_plan_by_id.py +57 -0
  45. ynab_api_cli/generated/api/plans/get_plan_settings_by_id.py +49 -0
  46. ynab_api_cli/generated/api/plans/get_plans.py +51 -0
  47. ynab_api_cli/generated/api/scheduled_transactions/__init__.py +3 -0
  48. ynab_api_cli/generated/api/scheduled_transactions/create_scheduled_transaction.py +58 -0
  49. ynab_api_cli/generated/api/scheduled_transactions/delete_scheduled_transaction.py +56 -0
  50. ynab_api_cli/generated/api/scheduled_transactions/get_scheduled_transaction_by_id.py +56 -0
  51. ynab_api_cli/generated/api/scheduled_transactions/get_scheduled_transactions.py +59 -0
  52. ynab_api_cli/generated/api/scheduled_transactions/update_scheduled_transaction.py +63 -0
  53. ynab_api_cli/generated/api/transactions/__init__.py +3 -0
  54. ynab_api_cli/generated/api/transactions/create_transaction.py +58 -0
  55. ynab_api_cli/generated/api/transactions/delete_transaction.py +54 -0
  56. ynab_api_cli/generated/api/transactions/get_transaction_by_id.py +54 -0
  57. ynab_api_cli/generated/api/transactions/get_transactions.py +80 -0
  58. ynab_api_cli/generated/api/transactions/get_transactions_by_account.py +87 -0
  59. ynab_api_cli/generated/api/transactions/get_transactions_by_category.py +89 -0
  60. ynab_api_cli/generated/api/transactions/get_transactions_by_month.py +87 -0
  61. ynab_api_cli/generated/api/transactions/get_transactions_by_payee.py +89 -0
  62. ynab_api_cli/generated/api/transactions/import_transactions.py +54 -0
  63. ynab_api_cli/generated/api/transactions/update_transaction.py +59 -0
  64. ynab_api_cli/generated/api/transactions/update_transactions.py +58 -0
  65. ynab_api_cli/generated/api/user/__init__.py +3 -0
  66. ynab_api_cli/generated/api/user/get_user.py +42 -0
  67. ynab_api_cli/generated/bindings.py +141 -0
  68. ynab_api_cli/generated/models/__init__.py +416 -0
  69. ynab_api_cli/generated/models/account.py +71 -0
  70. ynab_api_cli/generated/models/account_base.py +57 -0
  71. ynab_api_cli/generated/models/account_response.py +19 -0
  72. ynab_api_cli/generated/models/account_response_data.py +19 -0
  73. ynab_api_cli/generated/models/account_type.py +23 -0
  74. ynab_api_cli/generated/models/accounts_response.py +21 -0
  75. ynab_api_cli/generated/models/accounts_response_data.py +20 -0
  76. ynab_api_cli/generated/models/bulk_response.py +19 -0
  77. ynab_api_cli/generated/models/bulk_response_data.py +21 -0
  78. ynab_api_cli/generated/models/bulk_response_data_bulk.py +15 -0
  79. ynab_api_cli/generated/models/bulk_transactions.py +21 -0
  80. ynab_api_cli/generated/models/categories_response.py +21 -0
  81. ynab_api_cli/generated/models/categories_response_data.py +22 -0
  82. ynab_api_cli/generated/models/category.py +104 -0
  83. ynab_api_cli/generated/models/category_base.py +74 -0
  84. ynab_api_cli/generated/models/category_group.py +20 -0
  85. ynab_api_cli/generated/models/category_group_with_categories.py +25 -0
  86. ynab_api_cli/generated/models/category_response.py +21 -0
  87. ynab_api_cli/generated/models/category_response_data.py +19 -0
  88. ynab_api_cli/generated/models/currency_format_type_0.py +21 -0
  89. ynab_api_cli/generated/models/date_format_type_0.py +14 -0
  90. ynab_api_cli/generated/models/debt_1.py +18 -0
  91. ynab_api_cli/generated/models/debt_2.py +18 -0
  92. ynab_api_cli/generated/models/debt_3.py +18 -0
  93. ynab_api_cli/generated/models/error_detail.py +16 -0
  94. ynab_api_cli/generated/models/error_response.py +19 -0
  95. ynab_api_cli/generated/models/existing_category.py +34 -0
  96. ynab_api_cli/generated/models/existing_transaction.py +43 -0
  97. ynab_api_cli/generated/models/flag_1.py +17 -0
  98. ynab_api_cli/generated/models/flag_2.py +17 -0
  99. ynab_api_cli/generated/models/flag_3.py +17 -0
  100. ynab_api_cli/generated/models/get_transactions_by_account_type.py +12 -0
  101. ynab_api_cli/generated/models/get_transactions_by_category_type.py +12 -0
  102. ynab_api_cli/generated/models/get_transactions_by_month_type.py +12 -0
  103. ynab_api_cli/generated/models/get_transactions_by_payee_type.py +12 -0
  104. ynab_api_cli/generated/models/get_transactions_type.py +12 -0
  105. ynab_api_cli/generated/models/goal_1.py +15 -0
  106. ynab_api_cli/generated/models/goal_2.py +15 -0
  107. ynab_api_cli/generated/models/goal_3.py +15 -0
  108. ynab_api_cli/generated/models/hybrid_transaction.py +70 -0
  109. ynab_api_cli/generated/models/hybrid_transaction_type.py +12 -0
  110. ynab_api_cli/generated/models/hybrid_transactions_response.py +21 -0
  111. ynab_api_cli/generated/models/hybrid_transactions_response_data.py +22 -0
  112. ynab_api_cli/generated/models/loan_account_periodic_value_type_0.py +33 -0
  113. ynab_api_cli/generated/models/money_movement.py +35 -0
  114. ynab_api_cli/generated/models/money_movement_base.py +33 -0
  115. ynab_api_cli/generated/models/money_movement_group.py +25 -0
  116. ynab_api_cli/generated/models/money_movement_groups_response.py +21 -0
  117. ynab_api_cli/generated/models/money_movement_groups_response_data.py +22 -0
  118. ynab_api_cli/generated/models/money_movements_response.py +21 -0
  119. ynab_api_cli/generated/models/money_movements_response_data.py +20 -0
  120. ynab_api_cli/generated/models/month_detail.py +42 -0
  121. ynab_api_cli/generated/models/month_detail_base.py +30 -0
  122. ynab_api_cli/generated/models/month_detail_response.py +21 -0
  123. ynab_api_cli/generated/models/month_detail_response_data.py +19 -0
  124. ynab_api_cli/generated/models/month_summaries_response.py +21 -0
  125. ynab_api_cli/generated/models/month_summaries_response_data.py +20 -0
  126. ynab_api_cli/generated/models/month_summary.py +37 -0
  127. ynab_api_cli/generated/models/month_summary_base.py +25 -0
  128. ynab_api_cli/generated/models/new_category.py +34 -0
  129. ynab_api_cli/generated/models/new_transaction.py +44 -0
  130. ynab_api_cli/generated/models/patch_category_group_wrapper.py +19 -0
  131. ynab_api_cli/generated/models/patch_category_wrapper.py +19 -0
  132. ynab_api_cli/generated/models/patch_month_category_wrapper.py +19 -0
  133. ynab_api_cli/generated/models/patch_payee_wrapper.py +19 -0
  134. ynab_api_cli/generated/models/patch_transactions_wrapper.py +21 -0
  135. ynab_api_cli/generated/models/payee.py +23 -0
  136. ynab_api_cli/generated/models/payee_location.py +20 -0
  137. ynab_api_cli/generated/models/payee_location_response.py +21 -0
  138. ynab_api_cli/generated/models/payee_location_response_data.py +19 -0
  139. ynab_api_cli/generated/models/payee_locations_response.py +21 -0
  140. ynab_api_cli/generated/models/payee_locations_response_data.py +19 -0
  141. ynab_api_cli/generated/models/payee_response.py +19 -0
  142. ynab_api_cli/generated/models/payee_response_data.py +19 -0
  143. ynab_api_cli/generated/models/payees_response.py +19 -0
  144. ynab_api_cli/generated/models/payees_response_data.py +20 -0
  145. ynab_api_cli/generated/models/plan_detail.py +74 -0
  146. ynab_api_cli/generated/models/plan_detail_response.py +21 -0
  147. ynab_api_cli/generated/models/plan_detail_response_data.py +20 -0
  148. ynab_api_cli/generated/models/plan_settings.py +21 -0
  149. ynab_api_cli/generated/models/plan_settings_response.py +21 -0
  150. ynab_api_cli/generated/models/plan_settings_response_data.py +19 -0
  151. ynab_api_cli/generated/models/plan_summary.py +38 -0
  152. ynab_api_cli/generated/models/plan_summary_response.py +21 -0
  153. ynab_api_cli/generated/models/plan_summary_response_data.py +24 -0
  154. ynab_api_cli/generated/models/post_account_wrapper.py +19 -0
  155. ynab_api_cli/generated/models/post_category_group_wrapper.py +19 -0
  156. ynab_api_cli/generated/models/post_category_wrapper.py +19 -0
  157. ynab_api_cli/generated/models/post_payee.py +14 -0
  158. ynab_api_cli/generated/models/post_payee_wrapper.py +19 -0
  159. ynab_api_cli/generated/models/post_scheduled_transaction_wrapper.py +23 -0
  160. ynab_api_cli/generated/models/post_transactions_wrapper.py +24 -0
  161. ynab_api_cli/generated/models/put_scheduled_transaction_wrapper.py +23 -0
  162. ynab_api_cli/generated/models/put_transaction_wrapper.py +19 -0
  163. ynab_api_cli/generated/models/save_account.py +18 -0
  164. ynab_api_cli/generated/models/save_account_type.py +16 -0
  165. ynab_api_cli/generated/models/save_category.py +34 -0
  166. ynab_api_cli/generated/models/save_category_goal_frequency.py +13 -0
  167. ynab_api_cli/generated/models/save_category_group.py +14 -0
  168. ynab_api_cli/generated/models/save_category_group_response.py +21 -0
  169. ynab_api_cli/generated/models/save_category_group_response_data.py +20 -0
  170. ynab_api_cli/generated/models/save_category_response.py +21 -0
  171. ynab_api_cli/generated/models/save_category_response_data.py +20 -0
  172. ynab_api_cli/generated/models/save_month_category.py +14 -0
  173. ynab_api_cli/generated/models/save_payee.py +16 -0
  174. ynab_api_cli/generated/models/save_payee_response.py +21 -0
  175. ynab_api_cli/generated/models/save_payee_response_data.py +20 -0
  176. ynab_api_cli/generated/models/save_scheduled_transaction.py +37 -0
  177. ynab_api_cli/generated/models/save_sub_transaction.py +22 -0
  178. ynab_api_cli/generated/models/save_transaction_with_id_or_import_id.py +45 -0
  179. ynab_api_cli/generated/models/save_transaction_with_optional_fields.py +43 -0
  180. ynab_api_cli/generated/models/save_transactions_response.py +21 -0
  181. ynab_api_cli/generated/models/save_transactions_response_data.py +29 -0
  182. ynab_api_cli/generated/models/scheduled_sub_transaction.py +31 -0
  183. ynab_api_cli/generated/models/scheduled_sub_transaction_base.py +29 -0
  184. ynab_api_cli/generated/models/scheduled_transaction_detail.py +53 -0
  185. ynab_api_cli/generated/models/scheduled_transaction_frequency.py +23 -0
  186. ynab_api_cli/generated/models/scheduled_transaction_response.py +21 -0
  187. ynab_api_cli/generated/models/scheduled_transaction_response_data.py +23 -0
  188. ynab_api_cli/generated/models/scheduled_transaction_summary.py +43 -0
  189. ynab_api_cli/generated/models/scheduled_transaction_summary_base.py +41 -0
  190. ynab_api_cli/generated/models/scheduled_transaction_summary_base_frequency.py +23 -0
  191. ynab_api_cli/generated/models/scheduled_transactions_response.py +21 -0
  192. ynab_api_cli/generated/models/scheduled_transactions_response_data.py +24 -0
  193. ynab_api_cli/generated/models/sub_transaction.py +34 -0
  194. ynab_api_cli/generated/models/sub_transaction_base.py +32 -0
  195. ynab_api_cli/generated/models/transaction_cleared_status.py +13 -0
  196. ynab_api_cli/generated/models/transaction_detail.py +70 -0
  197. ynab_api_cli/generated/models/transaction_response.py +21 -0
  198. ynab_api_cli/generated/models/transaction_response_data.py +20 -0
  199. ynab_api_cli/generated/models/transaction_summary.py +62 -0
  200. ynab_api_cli/generated/models/transaction_summary_base.py +60 -0
  201. ynab_api_cli/generated/models/transactions_import_response.py +21 -0
  202. ynab_api_cli/generated/models/transactions_import_response_data.py +14 -0
  203. ynab_api_cli/generated/models/transactions_response.py +21 -0
  204. ynab_api_cli/generated/models/transactions_response_data.py +20 -0
  205. ynab_api_cli/generated/models/user.py +16 -0
  206. ynab_api_cli/generated/models/user_response.py +19 -0
  207. ynab_api_cli/generated/models/user_response_data.py +19 -0
  208. ynab_api_cli/live.py +449 -0
  209. ynab_api_cli/py.typed +0 -0
  210. ynab_api_cli/resources/__init__.py +3 -0
  211. ynab_api_cli/resources/catalog.json +178 -0
  212. ynab_api_cli/resources/openapi.yaml +4035 -0
  213. ynab_api_cli/resources/provenance.json +9 -0
  214. ynab_api_cli/resources/schema_features.json +913 -0
  215. ynab_api_cli/resources/skill/SKILL.md +21 -0
  216. ynab_api_cli/resources/skill/references/operations.md +36 -0
  217. ynab_api_cli/resources/skill/references/setup.md +32 -0
  218. ynab_api_cli/security.py +83 -0
  219. ynab_api_cli/transport.py +133 -0
  220. ynab_api_cli/types.py +110 -0
  221. ynab_api_cli/verification.py +167 -0
  222. ynab_api_cli-0.1.0.dist-info/METADATA +90 -0
  223. ynab_api_cli-0.1.0.dist-info/RECORD +225 -0
  224. ynab_api_cli-0.1.0.dist-info/WHEEL +4 -0
  225. ynab_api_cli-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,3 @@
1
+ """Contract-driven YNAB REST CLI and generated typed Python client."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,266 @@
1
+ """CLI orchestration with safe diagnostics and explicit mutation execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import json
7
+ import logging
8
+ import shutil
9
+ import sys
10
+ from collections.abc import Callable, Sequence
11
+ from importlib.resources import files
12
+ from pathlib import Path
13
+
14
+ from pydantic import ValidationError
15
+
16
+ from ynab_api_cli.cli import Command, CommandLine
17
+ from ynab_api_cli.compat import Compatibility
18
+ from ynab_api_cli.config import ConfigStore
19
+ from ynab_api_cli.contract import Contract
20
+ from ynab_api_cli.exceptions import AppError
21
+ from ynab_api_cli.generated.bindings import Bindings
22
+ from ynab_api_cli.transport import Transport
23
+ from ynab_api_cli.types import JsonCodec, JsonValue, RequestValues
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class Application:
29
+ """Coordinate connection setup and reviewed typed operation dispatch."""
30
+
31
+ def __init__(
32
+ self, store: ConfigStore | None = None, transport: Transport | None = None
33
+ ) -> None:
34
+ self.contract = Contract()
35
+ self.store = store
36
+ self.transport = transport
37
+
38
+ @classmethod
39
+ def main(cls, argv: Sequence[str] | None = None) -> None:
40
+ """Run the console entry point and return its documented exit status."""
41
+ sys.exit(cls().run(argv))
42
+
43
+ def run(self, argv: Sequence[str] | None = None) -> int:
44
+ """Produce JSON output and omit traceback chains containing secrets."""
45
+ try:
46
+ command = CommandLine(self.contract).parse(argv)
47
+ logging.basicConfig(
48
+ level=command.log_level, format="%(levelname)s: %(message)s"
49
+ )
50
+ output = self.dispatch(command)
51
+ sys.stdout.write(
52
+ json.dumps(output, indent=2, ensure_ascii=False, allow_nan=False) + "\n"
53
+ )
54
+ if isinstance(output, dict) and output.get("compatible") is False:
55
+ return 1
56
+ except (AppError, OSError) as err:
57
+ logger.error( # noqa: TRY400 - never expose credential-bearing tracebacks.
58
+ "%s",
59
+ str(err)
60
+ if isinstance(err, AppError)
61
+ else "Filesystem operation failed.",
62
+ )
63
+ return 1
64
+ except ValidationError:
65
+ logger.error("Generated model validation failed; check compatibility.") # noqa: TRY400 - omit request values.
66
+ return 1
67
+ except KeyboardInterrupt:
68
+ return 130
69
+ return 0
70
+
71
+ def dispatch(self, command: Command) -> JsonValue:
72
+ """Route non-network discovery and authenticated commands."""
73
+ routes: dict[str, Callable[[Command], JsonValue]] = {
74
+ "discover": self.discover,
75
+ "inspect": self.inspect,
76
+ "compat": self.compatibility,
77
+ "skill": self.skill,
78
+ }
79
+ if command.name in routes:
80
+ return routes[command.name](command)
81
+ store = self.store if self.store is not None else ConfigStore()
82
+ if command.name in {"configure", "select-plan"}:
83
+ return self.configure(command, store)
84
+ connection = store.load()
85
+ transport = (
86
+ self.transport
87
+ if self.transport is not None
88
+ else Transport(connection, self.contract)
89
+ )
90
+ if command.name == "doctor":
91
+ return self.doctor(command, transport, store)
92
+ return self.execute(command, transport)
93
+
94
+ def execute(self, command: Command, transport: Transport) -> JsonValue:
95
+ """Validate and preview or submit one reviewed operation."""
96
+ connection = transport.connection
97
+ operation = self.contract.get(command.operation or "")
98
+ request = self.request(command)
99
+ path = dict(request.path)
100
+ if (
101
+ "{plan_id}" in operation.path
102
+ and "plan_id" not in path
103
+ and connection.plan_id is not None
104
+ ):
105
+ path["plan_id"] = connection.plan_id
106
+ request = RequestValues(path, request.query, request.body)
107
+ self.contract.validate_request(operation, request)
108
+ if operation.write and not command.execute:
109
+ return {
110
+ "preview": True,
111
+ "operation": operation.identifier,
112
+ "method": operation.method,
113
+ "path": operation.path,
114
+ "request": request.json(),
115
+ }
116
+ response = Bindings.operations[operation.alias](transport, request)
117
+ if command.metadata:
118
+ return {
119
+ "status": response.status,
120
+ "headers": JsonCodec.encode(response.headers),
121
+ "contract_version": self.contract.version,
122
+ "body": response.raw,
123
+ }
124
+ return response.raw
125
+
126
+ def discover(self, command: Command) -> JsonValue:
127
+ """List the reviewed contract groups and matching operations."""
128
+ groups = {op.group for op in self.contract.operations.values()}
129
+ if command.group is not None and command.group not in groups:
130
+ raise AppError("Unknown resource group.")
131
+ return JsonCodec.encode(
132
+ {
133
+ "contract_version": self.contract.version,
134
+ "groups": sorted(groups),
135
+ "operations": [
136
+ op.identifier
137
+ for op in self.contract.operations.values()
138
+ if op.identifier in self.contract.catalog
139
+ and (command.group is None or op.group == command.group)
140
+ ],
141
+ }
142
+ )
143
+
144
+ def inspect(self, command: Command) -> JsonValue:
145
+ """Return the operation and only its reachable schemas."""
146
+ operation = self.contract.get(command.operation or "")
147
+ return {
148
+ "operation": operation.identifier,
149
+ "method": operation.method,
150
+ "path": operation.path,
151
+ "write": operation.write,
152
+ "definition": operation.definition,
153
+ "components": self.reachable(operation.definition),
154
+ }
155
+
156
+ def compatibility(self, _command: Command) -> JsonValue:
157
+ """Check the public upstream definition without credentials."""
158
+ checker = Compatibility(self.contract)
159
+ return checker.compare(checker.fetch())
160
+
161
+ @staticmethod
162
+ def configure(command: Command, store: ConfigStore) -> JsonValue:
163
+ """Perform only the explicitly selected local configuration action."""
164
+ if command.name == "select-plan":
165
+ store.select_plan(command.plan or "")
166
+ return {"plan_id": command.plan}
167
+ if not sys.stdin.isatty():
168
+ raise AppError("Run configure in an interactive terminal for hidden input.")
169
+ store.configure(
170
+ getpass.getpass("YNAB personal access token (hidden): "),
171
+ replace=command.replace,
172
+ )
173
+ return {"configured": True, "directory": str(store.root)}
174
+
175
+ def doctor(
176
+ self, command: Command, transport: Transport, store: ConfigStore
177
+ ) -> dict[str, JsonValue]:
178
+ """Validate API access separately from offline configuration checks."""
179
+ if not command.offline:
180
+ Bindings.operations["get_user"](transport, RequestValues())
181
+ if transport.connection.plan_id is not None:
182
+ Bindings.operations["get_plan_by_id"](
183
+ transport, RequestValues({"plan_id": transport.connection.plan_id})
184
+ )
185
+ output: dict[str, JsonValue] = {
186
+ "ok": True,
187
+ "api_checked": not command.offline,
188
+ "directory": str(store.root),
189
+ "plan_id": transport.connection.plan_id,
190
+ "contract_version": self.contract.version,
191
+ }
192
+ if command.upstream:
193
+ checker = Compatibility(self.contract)
194
+ report = checker.compare(checker.fetch())
195
+ output["upstream"] = report
196
+ output["compatible"] = report["compatible"]
197
+ return output
198
+
199
+ def request(self, command: Command) -> RequestValues:
200
+ """Read the uniform envelope or a resource command's body."""
201
+ if command.source is None:
202
+ return command.arguments
203
+ text = command.source
204
+ if text.startswith("@"):
205
+ text = Path(text[1:]).read_text(encoding="utf-8")
206
+ elif text == "-":
207
+ text = sys.stdin.read()
208
+ try:
209
+ raw = JsonCodec.loads(text)
210
+ except ValueError as err:
211
+ raise AppError("Invalid request JSON.") from err
212
+ if command.name != "call":
213
+ return RequestValues(command.arguments.path, command.arguments.query, raw)
214
+ envelope = self.contract.object(raw)
215
+ if set(envelope) - {"path", "query", "body"}:
216
+ raise AppError("Request accepts only path, query, and body fields.")
217
+ base = RequestValues(
218
+ dict(self.contract.object(envelope.get("path", {}))),
219
+ dict(self.contract.object(envelope.get("query", {}))),
220
+ )
221
+ return (
222
+ RequestValues(base.path, base.query, envelope["body"])
223
+ if "body" in envelope
224
+ else base
225
+ )
226
+
227
+ def reachable(self, definition: JsonValue) -> dict[str, JsonValue]:
228
+ """Disclose only schemas reachable from the inspected operation."""
229
+ selected: dict[str, JsonValue] = {}
230
+ pending = [definition]
231
+ schemas = self.contract.object(
232
+ self.contract.object(self.contract.document["components"])["schemas"]
233
+ )
234
+ while pending:
235
+ value = pending.pop()
236
+ if isinstance(value, dict):
237
+ reference = value.get("$ref")
238
+ if isinstance(reference, str) and reference.startswith(
239
+ "#/components/schemas/"
240
+ ):
241
+ name = reference.rsplit("/", 1)[-1]
242
+ if name not in selected:
243
+ selected[name] = schemas[name]
244
+ pending.append(schemas[name])
245
+ pending.extend(value.values())
246
+ elif isinstance(value, list):
247
+ pending.extend(value)
248
+ return selected
249
+
250
+ @staticmethod
251
+ def skill(command: Command) -> JsonValue:
252
+ """Display or export the skill supplied by the installed distribution."""
253
+ resource = files("ynab_api_cli.resources").joinpath("skill")
254
+ if command.operation == "show":
255
+ return {"skill": resource.joinpath("SKILL.md").read_text(encoding="utf-8")}
256
+ if command.source is None:
257
+ raise AppError("skill export requires a destination directory.")
258
+ destination = Path(command.source)
259
+ if destination.exists():
260
+ raise AppError("Skill export destination already exists.")
261
+ shutil.copytree(str(resource), destination)
262
+ return {"exported": str(destination)}
263
+
264
+
265
+ if __name__ == "__main__":
266
+ Application.main()
ynab_api_cli/cli.py ADDED
@@ -0,0 +1,171 @@
1
+ """Contract-derived argument parsing into immutable command settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass
8
+
9
+ from ynab_api_cli.contract import Contract
10
+ from ynab_api_cli.types import RequestValues
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class Command:
15
+ """Typed command input separated from argparse's dynamic namespace."""
16
+
17
+ name: str
18
+ operation: str | None
19
+ group: str | None
20
+ plan: str | None
21
+ source: str | None
22
+ arguments: RequestValues
23
+ execute: bool
24
+ metadata: bool
25
+ offline: bool
26
+ upstream: bool
27
+ replace: bool
28
+ log_level: str
29
+
30
+
31
+ class CommandLine:
32
+ """Expose every reviewed endpoint with useful resource-specific flags."""
33
+
34
+ def __init__(self, contract: Contract) -> None:
35
+ self.contract = contract
36
+
37
+ def parser(self) -> argparse.ArgumentParser:
38
+ """Build connection, discovery, uniform, and resource subcommands."""
39
+ parser = argparse.ArgumentParser(
40
+ prog="ynab-api-cli", description="Complete reviewed YNAB REST API CLI"
41
+ )
42
+ parser.add_argument(
43
+ "--log-level", choices=["ERROR", "WARNING", "INFO"], default="WARNING"
44
+ )
45
+ sub = parser.add_subparsers(dest="command", required=True)
46
+ configure = sub.add_parser(
47
+ "configure", help="Prompt locally for protected credentials"
48
+ )
49
+ configure.add_argument("--replace", action="store_true")
50
+ doctor = sub.add_parser(
51
+ "doctor", help="Validate local settings and read-only API access"
52
+ )
53
+ doctor.add_argument("--offline", action="store_true")
54
+ doctor.add_argument("--upstream", action="store_true")
55
+ select = sub.add_parser("select-plan")
56
+ select.add_argument("plan")
57
+ discover = sub.add_parser("discover")
58
+ discover.add_argument("group", nargs="?")
59
+ describe = sub.add_parser("inspect")
60
+ describe.add_argument("operation")
61
+ call = sub.add_parser("call")
62
+ call.add_argument("operation")
63
+ call.add_argument(
64
+ "--request",
65
+ dest="source",
66
+ default="{}",
67
+ help="JSON envelope, @file, or - for stdin",
68
+ )
69
+ self.execution_flags(call)
70
+ compat = sub.add_parser("compat")
71
+ compat.add_argument("action", choices=["check"])
72
+ skill = sub.add_parser(
73
+ "skill", help="Inspect or export the bundled supplementary skill"
74
+ )
75
+ skill.add_argument("action", choices=["show", "export"])
76
+ skill.add_argument("destination", nargs="?")
77
+ groups = {
78
+ group: sub.add_parser(group).add_subparsers(dest="operation", required=True)
79
+ for group in sorted(
80
+ {
81
+ op.group
82
+ for op in self.contract.operations.values()
83
+ if op.identifier in self.contract.catalog
84
+ }
85
+ )
86
+ }
87
+ for operation in self.contract.operations.values():
88
+ if operation.identifier not in self.contract.catalog:
89
+ continue
90
+ endpoint = groups[operation.group].add_parser(
91
+ operation.alias.replace("_", "-"),
92
+ help=str(operation.definition.get("summary", "")),
93
+ )
94
+ endpoint.set_defaults(operation=operation.identifier)
95
+ for parameter in self.contract.parameters(operation):
96
+ endpoint.add_argument(
97
+ "--" + str(parameter["name"]).replace("_", "-"),
98
+ dest="argument_" + str(parameter["name"]),
99
+ required=bool(parameter.get("required"))
100
+ and parameter["name"] != "plan_id",
101
+ help=str(parameter.get("description", "")),
102
+ )
103
+ if operation.definition.get("requestBody") is not None:
104
+ endpoint.add_argument(
105
+ "--body",
106
+ dest="source",
107
+ required=True,
108
+ help="JSON object, @file, or - for stdin",
109
+ )
110
+ self.execution_flags(endpoint)
111
+ return parser
112
+
113
+ @staticmethod
114
+ def execution_flags(parser: argparse.ArgumentParser) -> None:
115
+ """Add mutation guards and optional HTTP metadata."""
116
+ parser.add_argument(
117
+ "--execute", action="store_true", help="Submit an authorized mutation"
118
+ )
119
+ parser.add_argument("--metadata", action="store_true")
120
+
121
+ def parse(self, argv: Sequence[str] | None = None) -> Command:
122
+ """Convert the namespace and endpoint flags into immutable settings."""
123
+ parser = self.parser()
124
+ args = parser.parse_args(argv)
125
+ name = str(args.command)
126
+ operation = getattr(args, "operation", None)
127
+ path: dict[str, object] = {}
128
+ query: dict[str, object] = {}
129
+ if operation is not None and name not in {"inspect", "call"}:
130
+ for parameter in self.contract.parameters(self.contract.get(operation)):
131
+ raw = getattr(args, "argument_" + str(parameter["name"]), None)
132
+ if raw is not None:
133
+ schema = self.contract.object(parameter["schema"])
134
+ value = self.convert(
135
+ str(raw), str(schema.get("type", "string")), parser
136
+ )
137
+ (path if parameter["in"] == "path" else query)[
138
+ str(parameter["name"])
139
+ ] = value
140
+ source = getattr(args, "source", None)
141
+ if name == "skill":
142
+ operation = str(args.action)
143
+ source = getattr(args, "destination", None)
144
+ return Command(
145
+ name,
146
+ operation,
147
+ getattr(args, "group", None),
148
+ getattr(args, "plan", None),
149
+ source,
150
+ RequestValues(path, query),
151
+ bool(getattr(args, "execute", False)),
152
+ bool(getattr(args, "metadata", False)),
153
+ bool(getattr(args, "offline", False)),
154
+ bool(getattr(args, "upstream", False)),
155
+ bool(getattr(args, "replace", False)),
156
+ str(args.log_level),
157
+ )
158
+
159
+ @staticmethod
160
+ def convert(text: str, kind: str, parser: argparse.ArgumentParser) -> object:
161
+ """Decode CLI primitive types without treating false as truthy."""
162
+ if kind == "boolean":
163
+ if text not in {"true", "false"}:
164
+ parser.error("Boolean values must be true or false.")
165
+ return text == "true"
166
+ if kind == "integer":
167
+ try:
168
+ return int(text)
169
+ except ValueError:
170
+ parser.error("Integer argument required.")
171
+ return text
ynab_api_cli/compat.py ADDED
@@ -0,0 +1,120 @@
1
+ """Compare bundled and upstream contracts without modifying installed files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+
8
+ import httpx
9
+ import yaml
10
+
11
+ from ynab_api_cli.contract import Contract
12
+ from ynab_api_cli.exceptions import AppError
13
+ from ynab_api_cli.types import JsonCodec, JsonValue
14
+
15
+
16
+ class Compatibility:
17
+ """Detect additions, removals, and structural contract changes."""
18
+
19
+ URL = "https://api.ynab.com/papi/open_api_spec.yaml"
20
+ DOCUMENTATION_KEYS = frozenset(
21
+ {"description", "summary", "example", "examples", "externalDocs"}
22
+ )
23
+
24
+ def __init__(self, contract: Contract | None = None) -> None:
25
+ self.contract = contract if contract is not None else Contract()
26
+
27
+ @classmethod
28
+ def structural(cls, value: JsonValue) -> JsonValue:
29
+ """Strip documentation while retaining API validation and wire semantics."""
30
+ if isinstance(value, dict):
31
+ return {
32
+ key: cls.structural(item)
33
+ for key, item in value.items()
34
+ if key not in cls.DOCUMENTATION_KEYS
35
+ }
36
+ if isinstance(value, list):
37
+ return [cls.structural(item) for item in value]
38
+ return value
39
+
40
+ def compare(self, latest: dict[str, JsonValue]) -> dict[str, JsonValue]:
41
+ """Report semantic paths and operation changes, including optional additions."""
42
+ upstream = Contract(latest)
43
+ baseline = self.contract.operations
44
+ added = sorted(set(upstream.operations) - set(baseline))
45
+ removed = sorted(set(baseline) - set(upstream.operations))
46
+ changed = [
47
+ name
48
+ for name in sorted(set(baseline) & set(upstream.operations))
49
+ if self.structural(baseline[name].definition)
50
+ != self.structural(upstream.operations[name].definition)
51
+ or baseline[name].path != upstream.operations[name].path
52
+ or baseline[name].method != upstream.operations[name].method
53
+ ]
54
+ schema_changes = self.differences(
55
+ self.structural(self.contract.document["components"]),
56
+ self.structural(latest["components"]),
57
+ "/components",
58
+ )
59
+ server_changes = self.contract.document.get("servers") != latest.get("servers")
60
+ compatible = not (
61
+ added or removed or changed or schema_changes or server_changes
62
+ )
63
+ affected = sorted(
64
+ set(changed)
65
+ | set(removed)
66
+ | (set(baseline) if schema_changes or server_changes else set())
67
+ )
68
+ output: dict[str, JsonValue] = {
69
+ "compatible": compatible,
70
+ "baseline_version": self.contract.version,
71
+ "upstream_version": upstream.version,
72
+ "added": JsonCodec.encode(added),
73
+ "removed": JsonCodec.encode(removed),
74
+ "changed": JsonCodec.encode(changed),
75
+ "schema_changes": JsonCodec.encode(schema_changes),
76
+ "server_changed": server_changes,
77
+ "affected_commands": JsonCodec.encode(affected),
78
+ "missing_tests": JsonCodec.encode(added),
79
+ "documentation_only": compatible and self.contract.document != latest,
80
+ }
81
+ return output
82
+
83
+ @classmethod
84
+ def differences(cls, old: JsonValue, new: JsonValue, path: str) -> list[str]:
85
+ """Find changed schema paths without relying on version strings."""
86
+ if isinstance(old, dict) and isinstance(new, dict):
87
+ result: list[str] = []
88
+ for key in sorted(set(old) | set(new)):
89
+ pointer = path + "/" + key.replace("~", "~0").replace("/", "~1")
90
+ result.extend(
91
+ [pointer]
92
+ if key not in old or key not in new
93
+ else cls.differences(old[key], new[key], pointer)
94
+ )
95
+ return result
96
+ return [] if old == new else [path]
97
+
98
+ def fetch(self, client: httpx.Client | None = None) -> dict[str, JsonValue]:
99
+ """Fetch the official YAML using bounded credential-free HTTPS."""
100
+ try:
101
+ if client is None:
102
+ with httpx.Client(
103
+ verify=True, follow_redirects=False, timeout=30, trust_env=False
104
+ ) as session:
105
+ response = session.get(self.URL)
106
+ else:
107
+ response = client.get(self.URL, timeout=30, follow_redirects=False)
108
+ response.raise_for_status()
109
+ return Contract.object(JsonCodec.encode(yaml.safe_load(response.text)))
110
+ except (httpx.HTTPError, yaml.YAMLError, ValueError) as err:
111
+ raise AppError(
112
+ "Cannot fetch or parse the public upstream contract."
113
+ ) from err
114
+
115
+ @staticmethod
116
+ def digest(value: dict[str, JsonValue]) -> str:
117
+ """Hash a canonical contract document for provenance checks."""
118
+ return hashlib.sha256(
119
+ json.dumps(value, sort_keys=True).encode("utf-8")
120
+ ).hexdigest()
ynab_api_cli/config.py ADDED
@@ -0,0 +1,110 @@
1
+ """Atomic connection configuration in XDG-style directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+ from uuid import UUID
10
+
11
+ from ynab_api_cli.contract import Contract
12
+ from ynab_api_cli.exceptions import AppError
13
+ from ynab_api_cli.security import FileSecurity
14
+ from ynab_api_cli.transport import Connection
15
+ from ynab_api_cli.types import JsonCodec, JsonValue
16
+
17
+
18
+ class ConfigStore:
19
+ """Keep credentials separate from non-secret default settings."""
20
+
21
+ def __init__(
22
+ self, root: Path | None = None, security: FileSecurity | None = None
23
+ ) -> None:
24
+ base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
25
+ if root is None and not base.is_absolute():
26
+ raise AppError("XDG_CONFIG_HOME must be absolute.")
27
+ self.root = root if root is not None else base / "ynab-api-cli"
28
+ self.security = security if security is not None else FileSecurity()
29
+
30
+ @staticmethod
31
+ def read(path: Path) -> dict[str, JsonValue]:
32
+ """Read a JSON object without reporting credential file contents."""
33
+ try:
34
+ return Contract.object(JsonCodec.loads(path.read_text(encoding="utf-8")))
35
+ except (OSError, ValueError) as err:
36
+ raise AppError(f"Cannot read JSON configuration: {path.name}.") from err
37
+
38
+ def write(
39
+ self,
40
+ path: Path,
41
+ values: dict[str, JsonValue],
42
+ *,
43
+ secret: bool = False,
44
+ exclusive: bool = False,
45
+ ) -> None:
46
+ """Replace a JSON file atomically after securing its temporary file."""
47
+ self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
48
+ descriptor, temporary = tempfile.mkstemp(dir=self.root, prefix=".ynab-api-cli-")
49
+ temporary_path = Path(temporary)
50
+ try:
51
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
52
+ stream.write(json.dumps(values, indent=2) + "\n")
53
+ stream.flush()
54
+ os.fsync(stream.fileno())
55
+ if secret:
56
+ self.security.protect(temporary_path)
57
+ if exclusive:
58
+ os.link(temporary_path, path)
59
+ else:
60
+ temporary_path.replace(path)
61
+ except OSError as err:
62
+ raise AppError("Cannot write configuration atomically.") from err
63
+ finally:
64
+ temporary_path.unlink(missing_ok=True)
65
+
66
+ def configure(self, token: str, *, replace: bool = False) -> None:
67
+ """Create or explicitly replace protected credentials."""
68
+ if not token.strip() or any(character.isspace() for character in token.strip()):
69
+ raise AppError("Token must be nonempty and contain no whitespace.")
70
+ Connection(token.strip())
71
+ path = self.root / "auth.json"
72
+ if path.exists() and not replace:
73
+ raise AppError(
74
+ "Credentials already exist; use configure --replace to rotate."
75
+ )
76
+ previous = self.read(path) if path.exists() else {}
77
+ previous["access_token"] = token.strip()
78
+ self.write(path, previous, secret=True, exclusive=not replace)
79
+
80
+ def load(self) -> Connection:
81
+ """Validate permissions, credentials, and optional default plan."""
82
+ path = self.root / "auth.json"
83
+ if not path.is_file():
84
+ raise AppError("Missing auth.json; run configure in a local terminal.")
85
+ self.security.validate(path)
86
+ token = self.read(path).get("access_token")
87
+ if (
88
+ not isinstance(token, str)
89
+ or not token.strip()
90
+ or any(character.isspace() for character in token)
91
+ ):
92
+ raise AppError(
93
+ "auth.json requires a nonempty access_token without whitespace."
94
+ )
95
+ config = self.root / "config.json"
96
+ plan = self.read(config).get("plan_id") if config.exists() else None
97
+ if plan is not None and (not isinstance(plan, str) or not plan):
98
+ raise AppError("config.json plan_id must be a nonempty string.")
99
+ return Connection(token, plan)
100
+
101
+ def select_plan(self, plan: str) -> None:
102
+ """Store an explicit UUID without overwriting unrelated settings."""
103
+ try:
104
+ UUID(plan)
105
+ except ValueError as err:
106
+ raise AppError("Select an explicit plan UUID.") from err
107
+ path = self.root / "config.json"
108
+ values = self.read(path) if path.exists() else {}
109
+ values["plan_id"] = plan
110
+ self.write(path, values)