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,21 @@
1
+ """Generated contract model; regenerate rather than editing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ if TYPE_CHECKING:
10
+ from ynab_api_cli.generated.models.transactions_import_response_data import (
11
+ TransactionsImportResponseData,
12
+ )
13
+
14
+
15
+ class TransactionsImportResponse(BaseModel):
16
+ """Typed TransactionsImportResponse contract representation."""
17
+
18
+ model_config = ConfigDict(
19
+ extra="allow", arbitrary_types_allowed=True, populate_by_name=True
20
+ )
21
+ data: TransactionsImportResponseData = Field(alias="data")
@@ -0,0 +1,14 @@
1
+ """Generated contract model; regenerate rather than editing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+
8
+ class TransactionsImportResponseData(BaseModel):
9
+ """Typed TransactionsImportResponseData contract representation."""
10
+
11
+ model_config = ConfigDict(
12
+ extra="allow", arbitrary_types_allowed=True, populate_by_name=True
13
+ )
14
+ transaction_ids: list[str] = Field(alias="transaction_ids")
@@ -0,0 +1,21 @@
1
+ """Generated contract model; regenerate rather than editing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ if TYPE_CHECKING:
10
+ from ynab_api_cli.generated.models.transactions_response_data import (
11
+ TransactionsResponseData,
12
+ )
13
+
14
+
15
+ class TransactionsResponse(BaseModel):
16
+ """Typed TransactionsResponse contract representation."""
17
+
18
+ model_config = ConfigDict(
19
+ extra="allow", arbitrary_types_allowed=True, populate_by_name=True
20
+ )
21
+ data: TransactionsResponseData = Field(alias="data")
@@ -0,0 +1,20 @@
1
+ """Generated contract model; regenerate rather than editing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ if TYPE_CHECKING:
10
+ from ynab_api_cli.generated.models.transaction_detail import TransactionDetail
11
+
12
+
13
+ class TransactionsResponseData(BaseModel):
14
+ """Typed TransactionsResponseData contract representation."""
15
+
16
+ model_config = ConfigDict(
17
+ extra="allow", arbitrary_types_allowed=True, populate_by_name=True
18
+ )
19
+ transactions: list[TransactionDetail] = Field(alias="transactions")
20
+ server_knowledge: int = Field(alias="server_knowledge")
@@ -0,0 +1,16 @@
1
+ """Generated contract model; regenerate rather than editing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from uuid import UUID
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+
10
+ class User(BaseModel):
11
+ """Typed User contract representation."""
12
+
13
+ model_config = ConfigDict(
14
+ extra="allow", arbitrary_types_allowed=True, populate_by_name=True
15
+ )
16
+ id: UUID = Field(alias="id")
@@ -0,0 +1,19 @@
1
+ """Generated contract model; regenerate rather than editing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ if TYPE_CHECKING:
10
+ from ynab_api_cli.generated.models.user_response_data import UserResponseData
11
+
12
+
13
+ class UserResponse(BaseModel):
14
+ """Typed UserResponse contract representation."""
15
+
16
+ model_config = ConfigDict(
17
+ extra="allow", arbitrary_types_allowed=True, populate_by_name=True
18
+ )
19
+ data: UserResponseData = Field(alias="data")
@@ -0,0 +1,19 @@
1
+ """Generated contract model; regenerate rather than editing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ if TYPE_CHECKING:
10
+ from ynab_api_cli.generated.models.user import User
11
+
12
+
13
+ class UserResponseData(BaseModel):
14
+ """Typed UserResponseData contract representation."""
15
+
16
+ model_config = ConfigDict(
17
+ extra="allow", arbitrary_types_allowed=True, populate_by_name=True
18
+ )
19
+ user: User = Field(alias="user")
ynab_api_cli/live.py ADDED
@@ -0,0 +1,449 @@
1
+ """Explicitly enabled live tests confined to a designated disposable plan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import logging
9
+ import sys
10
+ from dataclasses import dataclass
11
+ from datetime import UTC, datetime, timedelta
12
+ from pathlib import Path
13
+ from uuid import UUID, uuid4
14
+
15
+ from ynab_api_cli.config import ConfigStore
16
+ from ynab_api_cli.exceptions import AppError
17
+ from ynab_api_cli.generated.bindings import Bindings
18
+ from ynab_api_cli.security import FileSecurity
19
+ from ynab_api_cli.transport import Connection, Transport
20
+ from ynab_api_cli.types import UNSET, JsonCodec, JsonValue, RequestValues
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class LiveOptions:
27
+ """Explicit test-plan and credential selection with a separate write guard."""
28
+
29
+ plan_id: str
30
+ auth_file: Path
31
+ state_directory: Path
32
+ enable_writes: bool
33
+
34
+
35
+ class LiveRunner:
36
+ """Journal live mutations and reconcile fixtures through exact reads."""
37
+
38
+ PREFIX = "ynab-api-cli-test"
39
+
40
+ def __init__(self, options: LiveOptions, transport: Transport) -> None:
41
+ UUID(options.plan_id)
42
+ if options.plan_id != transport.connection.plan_id:
43
+ raise AppError("Live plan and transport plan must agree explicitly.")
44
+ self.options = options
45
+ self.transport = transport
46
+ self.contract = transport.contract
47
+ self.store = ConfigStore(options.state_directory)
48
+ self.state_file = options.state_directory / (options.plan_id + ".json")
49
+ self.state = (
50
+ self.store.read(self.state_file) if self.state_file.exists() else {}
51
+ )
52
+ self.completed: set[str] = set()
53
+ self.gaps: list[str] = []
54
+ self.ids: dict[str, str] = {
55
+ "plan_id": options.plan_id,
56
+ "month": datetime.now(UTC).date().replace(day=1).isoformat(),
57
+ }
58
+
59
+ @classmethod
60
+ def main(cls) -> None:
61
+ """Run live checks only with an explicit plan and protected auth file."""
62
+ parser = argparse.ArgumentParser(description="Opt-in isolated-plan live tests")
63
+ parser.add_argument("--plan-id", required=True)
64
+ parser.add_argument("--auth-file", type=Path, required=True)
65
+ parser.add_argument("--state-directory", type=Path, required=True)
66
+ parser.add_argument("--enable-writes", action="store_true")
67
+ args = parser.parse_args()
68
+ options = LiveOptions(
69
+ str(args.plan_id),
70
+ Path(args.auth_file),
71
+ Path(args.state_directory),
72
+ bool(args.enable_writes),
73
+ )
74
+ logging.basicConfig(level=logging.WARNING)
75
+ try:
76
+ FileSecurity().validate(options.auth_file)
77
+ token = cls.read_token(options.auth_file)
78
+ runner = cls(options, Transport(Connection(token, options.plan_id)))
79
+ sys.stdout.write(json.dumps(runner.run(), indent=2) + "\n")
80
+ except (AppError, OSError, ValueError):
81
+ logger.error("Live test failed; inspect its journal before retrying.") # noqa: TRY400 - credentials and record bodies must remain private.
82
+ raise SystemExit(1) from None
83
+
84
+ @staticmethod
85
+ def read_token(path: Path) -> str:
86
+ """Validate live credentials without using ordinary CLI defaults."""
87
+ token = ConfigStore.read(path).get("access_token")
88
+ if (
89
+ not isinstance(token, str)
90
+ or not token
91
+ or any(part.isspace() for part in token)
92
+ ):
93
+ raise AppError("Live auth file requires an access_token.")
94
+ return token
95
+
96
+ def invoke(
97
+ self,
98
+ operation: str,
99
+ *,
100
+ body: object = UNSET,
101
+ path: dict[str, object] | None = None,
102
+ ) -> dict[str, JsonValue]:
103
+ """Journal before submitting a mutation and retain unresolved intents."""
104
+ definition = self.contract.get(operation)
105
+ values: dict[str, object] = {
106
+ str(parameter["name"]): self.ids[str(parameter["name"])]
107
+ for parameter in self.contract.parameters(definition)
108
+ if parameter["in"] == "path" and str(parameter["name"]) in self.ids
109
+ }
110
+ if path is not None:
111
+ values.update(path)
112
+ if definition.write:
113
+ if not self.options.enable_writes:
114
+ raise AppError("Live writes require --enable-writes.")
115
+ if self.state.get("pending") is not None:
116
+ raise AppError(
117
+ "Unresolved live mutation; inspect state before retrying."
118
+ )
119
+ self.state["pending"] = {
120
+ "operation": operation,
121
+ "path": JsonCodec.encode(values),
122
+ "body": JsonCodec.encode(body) if body is not UNSET else None,
123
+ }
124
+ self.store.write(self.state_file, self.state, secret=True)
125
+ response = Bindings.operations[definition.alias](
126
+ self.transport, RequestValues(values, body=body)
127
+ )
128
+ data = self.contract.object(self.contract.object(response.raw)["data"])
129
+ self.completed.add(operation)
130
+ if definition.write:
131
+ self.state["last_mutation"] = self.state.pop("pending", None)
132
+ self.store.write(self.state_file, self.state, secret=True)
133
+ return data
134
+
135
+ def fixture(
136
+ self,
137
+ plural: str,
138
+ singular: str,
139
+ create: str,
140
+ body: dict[str, JsonValue],
141
+ name: str,
142
+ ) -> str:
143
+ """Reuse an exact named fixture or create and verify it once."""
144
+ records = self.invoke("get" + plural.title())[plural]
145
+ if not isinstance(records, list):
146
+ raise AppError("Expected fixture list.")
147
+ matches = [
148
+ self.contract.object(item)
149
+ for item in records
150
+ if self.contract.object(item).get("name") == name
151
+ ]
152
+ if len(matches) > 1:
153
+ raise AppError("Duplicate named live fixtures; reconcile manually.")
154
+ if matches:
155
+ return str(matches[0]["id"])
156
+ payload = dict(body)
157
+ payload["name"] = name
158
+ created = self.contract.object(
159
+ self.invoke(create, body={singular: payload})[singular]
160
+ )
161
+ identifier = str(created["id"])
162
+ verified = self.invoke(
163
+ "get" + singular.title() + "ById", path={singular + "_id": identifier}
164
+ )
165
+ if str(self.contract.object(verified[singular])["id"]) != identifier:
166
+ raise AppError("Fixture readback did not match the created ID.")
167
+ return identifier
168
+
169
+ def run(self) -> dict[str, JsonValue]:
170
+ """Verify plan identity, provision fixtures, and report exact live coverage."""
171
+ if self.state.get("pending") is not None:
172
+ raise AppError("Unresolved live mutation; reconcile its journal first.")
173
+ plan = self.contract.object(self.invoke("getPlanById")["plan"])
174
+ if not str(plan.get("name", "")).startswith("YNAB API CLI TEST"):
175
+ raise AppError("Live test plan name must begin YNAB API CLI TEST.")
176
+ if self.options.enable_writes:
177
+ self.provision()
178
+ self.mutations()
179
+ self.reads()
180
+ missing = sorted(set(self.contract.operations) - self.completed)
181
+ return {
182
+ "plan_id": self.options.plan_id,
183
+ "completed_operations": JsonCodec.encode(sorted(self.completed)),
184
+ "missing_operations": JsonCodec.encode(missing),
185
+ "gaps": JsonCodec.encode(self.gaps),
186
+ "retained_fixture_ids": JsonCodec.encode(self.ids),
187
+ "complete_live_coverage": not missing and not self.gaps,
188
+ "journal": str(self.state_file),
189
+ }
190
+
191
+ def provision(self) -> None:
192
+ """Provision reusable accounts, a category group, a category, and a payee."""
193
+ for label in ("source", "target"):
194
+ identifier = self.fixture(
195
+ "accounts",
196
+ "account",
197
+ "createAccount",
198
+ {"type": "checking", "balance": 0},
199
+ self.PREFIX + "-" + label,
200
+ )
201
+ self.ids[label + "_account_id"] = identifier
202
+ self.ids["account_id"] = self.ids["source_account_id"]
203
+ categories = self.invoke("getCategories")["category_groups"]
204
+ if not isinstance(categories, list):
205
+ raise AppError("Expected category groups.")
206
+ group = next(
207
+ (
208
+ self.contract.object(item)
209
+ for item in categories
210
+ if self.contract.object(item).get("name") == self.PREFIX
211
+ ),
212
+ None,
213
+ )
214
+ if group is None:
215
+ group = self.contract.object(
216
+ self.invoke(
217
+ "createCategoryGroup",
218
+ body={"category_group": {"name": self.PREFIX}},
219
+ )["category_group"]
220
+ )
221
+ self.ids["category_group_id"] = str(group["id"])
222
+ records = group.get("categories", [])
223
+ if not isinstance(records, list):
224
+ raise AppError("Expected category fixture list.")
225
+ category = next(
226
+ (
227
+ self.contract.object(item)
228
+ for item in records
229
+ if self.contract.object(item).get("name") == self.PREFIX
230
+ ),
231
+ None,
232
+ )
233
+ if category is None:
234
+ category = self.contract.object(
235
+ self.invoke(
236
+ "createCategory",
237
+ body={
238
+ "category": {
239
+ "name": self.PREFIX,
240
+ "category_group_id": self.ids["category_group_id"],
241
+ }
242
+ },
243
+ )["category"]
244
+ )
245
+ self.ids["category_id"] = str(category["id"])
246
+ self.ids["payee_id"] = self.fixture(
247
+ "payees", "payee", "createPayee", {}, self.PREFIX
248
+ )
249
+ self.state["fixtures"] = JsonCodec.encode(self.ids)
250
+ self.store.write(self.state_file, self.state, secret=True)
251
+
252
+ def mutations(self) -> None:
253
+ """Exercise supported mutations and verify affected records by ID."""
254
+ self.update_fixtures()
255
+ today = datetime.now(UTC).date().isoformat()
256
+ import_id = "cli:" + hashlib.sha256(str(uuid4()).encode()).hexdigest()[:28]
257
+ transaction: dict[str, JsonValue] = {
258
+ "account_id": self.ids["account_id"],
259
+ "category_id": self.ids["category_id"],
260
+ "date": today,
261
+ "amount": -1000,
262
+ "payee_id": self.ids["payee_id"],
263
+ "import_id": import_id,
264
+ "memo": self.PREFIX,
265
+ }
266
+ created = self.invoke("createTransaction", body={"transaction": transaction})
267
+ self.ids["transaction_id"] = str(
268
+ self.contract.object(created["transaction"])["id"]
269
+ )
270
+ self.verify("getTransactionById", "transaction", {"amount": -1000})
271
+ repeated = self.invoke(
272
+ "createTransaction", body={"transactions": [transaction]}
273
+ )
274
+ duplicates = repeated.get("duplicate_import_ids", [])
275
+ if not isinstance(duplicates, list) or import_id not in duplicates:
276
+ raise AppError("Duplicate import ID was not reported.")
277
+ self.invoke(
278
+ "updateTransaction",
279
+ body={"transaction": {"memo": self.PREFIX + "-updated"}},
280
+ )
281
+ self.verify(
282
+ "getTransactionById", "transaction", {"memo": self.PREFIX + "-updated"}
283
+ )
284
+ self.invoke(
285
+ "updateTransactions",
286
+ body={
287
+ "transactions": [{"id": self.ids["transaction_id"], "amount": -2000}]
288
+ },
289
+ )
290
+ self.verify("getTransactionById", "transaction", {"amount": -2000})
291
+ self.splits_and_transfer(today)
292
+ self.scheduled(today)
293
+ self.invoke("importTransactions")
294
+ self.gaps.append(
295
+ "Direct Import needs a linked test account; "
296
+ "manual accounts cannot verify bank imports."
297
+ )
298
+ self.invoke("deleteTransaction")
299
+ deleted = self.invoke("getTransactions")
300
+ self.assert_deleted(deleted, "transactions", self.ids["transaction_id"])
301
+
302
+ def update_fixtures(self) -> None:
303
+ """Exercise category, category-group, budgeting, and payee updates."""
304
+ self.invoke("updateCategory", body={"category": {"note": self.PREFIX}})
305
+ self.verify("getCategoryById", "category", {"note": self.PREFIX})
306
+ self.invoke(
307
+ "updateCategoryGroup", body={"category_group": {"name": self.PREFIX}}
308
+ )
309
+ self.invoke("updateMonthCategory", body={"category": {"budgeted": 1000}})
310
+ self.verify("getMonthCategoryById", "category", {"budgeted": 1000})
311
+ self.invoke("updatePayee", body={"payee": {"name": self.PREFIX}})
312
+ self.verify("getPayeeById", "payee", {"name": self.PREFIX})
313
+
314
+ def splits_and_transfer(self, today: str) -> None:
315
+ """Create, verify, and delete split and transfer records in fixture accounts."""
316
+ split = {
317
+ "account_id": self.ids["account_id"],
318
+ "date": today,
319
+ "amount": -1000,
320
+ "category_id": None,
321
+ "subtransactions": [
322
+ {"amount": -400, "category_id": self.ids["category_id"]},
323
+ {"amount": -600, "category_id": self.ids["category_id"]},
324
+ ],
325
+ }
326
+ created = self.invoke("createTransaction", body={"transactions": [split]})
327
+ records = created.get("transactions")
328
+ if not isinstance(records, list) or not records:
329
+ raise AppError("Split bulk creation did not return a record.")
330
+ identifier = str(self.contract.object(records[0])["id"])
331
+ self.verify(
332
+ "getTransactionById",
333
+ "transaction",
334
+ {"amount": -1000},
335
+ path={"transaction_id": identifier},
336
+ )
337
+ self.invoke("deleteTransaction", path={"transaction_id": identifier})
338
+ target = self.contract.object(
339
+ self.invoke(
340
+ "getAccountById", path={"account_id": self.ids["target_account_id"]}
341
+ )["account"]
342
+ )
343
+ payee = target.get("transfer_payee_id")
344
+ if not isinstance(payee, str):
345
+ self.gaps.append("Target account has no transfer payee.")
346
+ return
347
+ created = self.invoke(
348
+ "createTransaction",
349
+ body={
350
+ "transaction": {
351
+ "account_id": self.ids["account_id"],
352
+ "payee_id": payee,
353
+ "date": today,
354
+ "amount": -1000,
355
+ }
356
+ },
357
+ )
358
+ identifier = str(self.contract.object(created["transaction"])["id"])
359
+ self.verify(
360
+ "getTransactionById",
361
+ "transaction",
362
+ {"transfer_account_id": self.ids["target_account_id"]},
363
+ path={"transaction_id": identifier},
364
+ )
365
+ self.invoke("deleteTransaction", path={"transaction_id": identifier})
366
+
367
+ def scheduled(self, _today: str) -> None:
368
+ """Create, read, update, and remove a scheduled transaction fixture."""
369
+ date = (datetime.now(UTC).date() + timedelta(days=30)).isoformat()
370
+ payload: dict[str, JsonValue] = {
371
+ "account_id": self.ids["account_id"],
372
+ "date": date,
373
+ "amount": -1000,
374
+ "frequency": "monthly",
375
+ "memo": self.PREFIX,
376
+ }
377
+ created = self.invoke(
378
+ "createScheduledTransaction", body={"scheduled_transaction": payload}
379
+ )
380
+ self.ids["scheduled_transaction_id"] = str(
381
+ self.contract.object(created["scheduled_transaction"])["id"]
382
+ )
383
+ self.verify(
384
+ "getScheduledTransactionById", "scheduled_transaction", {"amount": -1000}
385
+ )
386
+ payload["memo"] = self.PREFIX + "-updated"
387
+ self.invoke(
388
+ "updateScheduledTransaction", body={"scheduled_transaction": payload}
389
+ )
390
+ self.verify(
391
+ "getScheduledTransactionById",
392
+ "scheduled_transaction",
393
+ {"memo": self.PREFIX + "-updated"},
394
+ )
395
+ self.invoke("deleteScheduledTransaction")
396
+ self.assert_deleted(
397
+ self.invoke("getScheduledTransactions"),
398
+ "scheduled_transactions",
399
+ self.ids["scheduled_transaction_id"],
400
+ )
401
+ del self.ids["scheduled_transaction_id"]
402
+
403
+ def reads(self) -> None:
404
+ """Run every read whose fixture prerequisites are available."""
405
+ for operation in self.contract.operations.values():
406
+ if operation.write or operation.identifier in self.completed:
407
+ continue
408
+ required = {
409
+ str(param["name"])
410
+ for param in self.contract.parameters(operation)
411
+ if param["in"] == "path"
412
+ }
413
+ if required <= set(self.ids):
414
+ self.invoke(operation.identifier)
415
+ else:
416
+ self.gaps.append(operation.identifier + ": fixture IDs unavailable")
417
+
418
+ def verify(
419
+ self,
420
+ operation: str,
421
+ field: str,
422
+ expected: dict[str, JsonValue],
423
+ *,
424
+ path: dict[str, object] | None = None,
425
+ ) -> None:
426
+ """Read an affected record and compare exact changed fields."""
427
+ record = self.contract.object(self.invoke(operation, path=path)[field])
428
+ if any(record.get(key) != value for key, value in expected.items()):
429
+ self.state["pending"] = self.state.get("last_mutation")
430
+ self.store.write(self.state_file, self.state, secret=True)
431
+ raise AppError("Live mutation readback mismatch; inspect the journal.")
432
+
433
+ def assert_deleted(
434
+ self, data: dict[str, JsonValue], field: str, identifier: str
435
+ ) -> None:
436
+ """Accept absence or a documented deleted marker after deletion."""
437
+ records = data[field]
438
+ if not isinstance(records, list):
439
+ raise AppError("Expected deletion verification list.")
440
+ if any(
441
+ self.contract.object(record).get("id") == identifier
442
+ and self.contract.object(record).get("deleted") is not True
443
+ for record in records
444
+ ):
445
+ raise AppError("Deleted fixture remains active.")
446
+
447
+
448
+ if __name__ == "__main__":
449
+ LiveRunner.main()
ynab_api_cli/py.typed ADDED
File without changes
@@ -0,0 +1,3 @@
1
+ """Bundled reviewed contract, catalog, provenance, and supplementary skill."""
2
+
3
+ from __future__ import annotations