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,197 @@
1
+ """Load the reviewed OpenAPI contract and validate runtime JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from importlib.resources import files
8
+
9
+ import yaml
10
+ from jsonschema import Draft202012Validator, FormatChecker
11
+
12
+ from ynab_api_cli.exceptions import AppError
13
+ from ynab_api_cli.types import JsonCodec, JsonValue, RequestValues
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class Operation:
18
+ """Reviewed operation identity and its wire contract."""
19
+
20
+ identifier: str
21
+ alias: str
22
+ group: str
23
+ method: str
24
+ path: str
25
+ definition: dict[str, JsonValue]
26
+
27
+ @property
28
+ def write(self) -> bool:
29
+ """Classify mutations by the HTTP method."""
30
+ return self.method not in {"GET", "HEAD", "OPTIONS"}
31
+
32
+
33
+ class Contract:
34
+ """Single source for operation metadata and JSON Schema validation."""
35
+
36
+ def __init__(self, document: dict[str, JsonValue] | None = None) -> None:
37
+ resource = files("ynab_api_cli.resources")
38
+ loaded = (
39
+ JsonCodec.encode(
40
+ yaml.safe_load(
41
+ resource.joinpath("openapi.yaml").read_text(encoding="utf-8")
42
+ )
43
+ )
44
+ if document is None
45
+ else document
46
+ )
47
+ self.document = self.object(loaded)
48
+ self.normalized_components = self.normalize(self.document["components"])
49
+ self.version = str(self.object(self.document["info"])["version"])
50
+ self.operations = self.inventory()
51
+ self.catalog = self.object(
52
+ JsonCodec.loads(
53
+ resource.joinpath("catalog.json").read_text(encoding="utf-8")
54
+ )
55
+ )
56
+
57
+ @staticmethod
58
+ def object(value: JsonValue) -> dict[str, JsonValue]:
59
+ """Require a JSON object without leaking its contents in errors."""
60
+ if not isinstance(value, dict):
61
+ raise AppError("Expected a JSON object.")
62
+ return value
63
+
64
+ @staticmethod
65
+ def snake(value: str) -> str:
66
+ """Convert a contract operation ID into a Python-compatible name."""
67
+ return re.sub(r"(?<!^)(?=[A-Z])", "_", value).lower()
68
+
69
+ def inventory(self) -> dict[str, Operation]:
70
+ """Enumerate HTTP operations directly from the specification."""
71
+ result: dict[str, Operation] = {}
72
+ for path, raw in self.object(self.document["paths"]).items():
73
+ for method, entry in self.object(raw).items():
74
+ if method not in {
75
+ "get",
76
+ "post",
77
+ "put",
78
+ "patch",
79
+ "delete",
80
+ "head",
81
+ "options",
82
+ }:
83
+ continue
84
+ definition = self.object(entry)
85
+ identifier = str(definition["operationId"])
86
+ tags = definition["tags"]
87
+ if not isinstance(tags, list) or not tags:
88
+ raise AppError("Operation has no resource tag.")
89
+ result[identifier] = Operation(
90
+ identifier,
91
+ self.snake(identifier),
92
+ str(tags[0]).lower().replace(" ", "-"),
93
+ method.upper(),
94
+ path,
95
+ definition,
96
+ )
97
+ return result
98
+
99
+ def get(self, name: str) -> Operation:
100
+ """Resolve only operations explicitly admitted by the reviewed catalog."""
101
+ for operation in self.operations.values():
102
+ if (
103
+ name in {operation.identifier, operation.alias}
104
+ and operation.identifier in self.catalog
105
+ ):
106
+ return operation
107
+ raise AppError("Unknown or unreviewed operation; run discover.")
108
+
109
+ def parameters(self, operation: Operation) -> list[dict[str, JsonValue]]:
110
+ """Return declared endpoint parameters."""
111
+ values = operation.definition.get("parameters", [])
112
+ if not isinstance(values, list):
113
+ raise AppError("Invalid parameter definition.")
114
+ return [self.object(item) for item in values]
115
+
116
+ def validate(
117
+ self, schema: dict[str, JsonValue], value: JsonValue, label: str
118
+ ) -> None:
119
+ """Validate constraints and formats without quoting personal values."""
120
+ complete = self.object(self.normalize(schema))
121
+ complete["components"] = self.normalized_components
122
+ errors = list(
123
+ Draft202012Validator(complete, format_checker=FormatChecker()).iter_errors(
124
+ value
125
+ )
126
+ )
127
+ if errors:
128
+ location = "/".join(str(part) for part in errors[0].absolute_path) or "root"
129
+ raise AppError(
130
+ f"Invalid {label} at {location}: {errors[0].validator} constraint."
131
+ )
132
+
133
+ @classmethod
134
+ def normalize(cls, value: JsonValue) -> JsonValue:
135
+ """Honor legacy nullable annotations in the published 3.1 document."""
136
+ if isinstance(value, list):
137
+ return [cls.normalize(item) for item in value]
138
+ if isinstance(value, dict):
139
+ result: dict[str, JsonValue] = {
140
+ key: cls.normalize(item)
141
+ for key, item in value.items()
142
+ if key != "nullable"
143
+ }
144
+ if value.get("nullable") is True:
145
+ return {"anyOf": [result, {"type": "null"}]}
146
+ return result
147
+ return value
148
+
149
+ def validate_request(self, operation: Operation, request: RequestValues) -> None:
150
+ """Validate path, query, body presence, and documented semantic rules."""
151
+ values = request.json()
152
+ for section in ("path", "query"):
153
+ parameters = [
154
+ item for item in self.parameters(operation) if item["in"] == section
155
+ ]
156
+ schema: dict[str, JsonValue] = {
157
+ "type": "object",
158
+ "properties": {
159
+ str(item["name"]): item["schema"] for item in parameters
160
+ },
161
+ "required": [
162
+ str(item["name"])
163
+ for item in parameters
164
+ if item.get("required") is True
165
+ ],
166
+ "additionalProperties": False,
167
+ }
168
+ self.validate(schema, values[section], section)
169
+ body = operation.definition.get("requestBody")
170
+ if body is None:
171
+ if "body" in values:
172
+ raise AppError("This operation does not accept a request body.")
173
+ return
174
+ definition = self.object(body)
175
+ if "body" not in values:
176
+ if definition.get("required") is True:
177
+ raise AppError("This operation requires a request body.")
178
+ return
179
+ content = self.object(self.object(definition["content"])["application/json"])
180
+ self.validate(self.object(content["schema"]), values["body"], "body")
181
+ if operation.identifier == "createTransaction":
182
+ payload = self.object(values["body"])
183
+ if ("transaction" in payload) == ("transactions" in payload):
184
+ raise AppError("Provide exactly one of transaction or transactions.")
185
+
186
+ def validate_response(
187
+ self, operation: Operation, status: int, raw: JsonValue
188
+ ) -> None:
189
+ """Validate the actual success response against its declared schema."""
190
+ response = self.object(operation.definition["responses"]).get(str(status))
191
+ if response is None:
192
+ raise AppError(
193
+ f"Unexpected HTTP {status}; bundled API contract {self.version}."
194
+ )
195
+ content = self.object(self.object(response)["content"])
196
+ schema = self.object(self.object(content["application/json"])["schema"])
197
+ self.validate(schema, raw, "response")
@@ -0,0 +1,141 @@
1
+ """Enumerate contract features and synthesize supplemental schema probes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from ynab_api_cli.contract import Contract
8
+ from ynab_api_cli.types import JsonValue
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class Probe:
13
+ """One schema feature and a candidate value to validate independently."""
14
+
15
+ identifier: str
16
+ schema: dict[str, JsonValue]
17
+ value: JsonValue
18
+
19
+
20
+ class SchemaCases:
21
+ """Exercise each reachable schema node in addition to handwritten wire cases."""
22
+
23
+ UUID = "00000000-0000-4000-8000-000000000001"
24
+
25
+ def __init__(self, contract: Contract) -> None:
26
+ self.contract = contract
27
+ self.schemas = contract.object(
28
+ contract.object(contract.document["components"])["schemas"]
29
+ )
30
+
31
+ def sample(self, schema: dict[str, JsonValue]) -> JsonValue:
32
+ """Construct a positive witness for the schema's declared structure."""
33
+ reference = schema.get("$ref")
34
+ if isinstance(reference, str):
35
+ return self.sample(
36
+ self.contract.object(self.schemas[reference.rsplit("/", 1)[-1]])
37
+ )
38
+ enum = schema.get("enum")
39
+ if isinstance(enum, list):
40
+ return enum[0]
41
+ for key in ("allOf", "anyOf", "oneOf"):
42
+ branches = schema.get(key)
43
+ if isinstance(branches, list):
44
+ values = [self.sample(self.contract.object(item)) for item in branches]
45
+ if key != "allOf":
46
+ return values[0]
47
+ if not all(isinstance(item, dict) for item in values):
48
+ return values[0]
49
+ merged: dict[str, JsonValue] = {}
50
+ for item in values:
51
+ merged.update(self.contract.object(item))
52
+ return merged
53
+ return self.primitive(schema)
54
+
55
+ def primitive(self, schema: dict[str, JsonValue]) -> JsonValue:
56
+ """Construct a witness for a primitive, array, or object schema."""
57
+ kind = schema.get("type")
58
+ if isinstance(kind, list):
59
+ kind = next(item for item in kind if item != "null")
60
+ if kind == "object" or "properties" in schema or "required" in schema:
61
+ result = {
62
+ key: self.sample(self.contract.object(value))
63
+ for key, value in self.contract.object(
64
+ schema.get("properties", {})
65
+ ).items()
66
+ }
67
+ extras = schema.get("additionalProperties")
68
+ if isinstance(extras, dict):
69
+ result["2026-01-01"] = self.sample(extras)
70
+ return result
71
+ if kind == "array":
72
+ return [self.sample(self.contract.object(schema["items"]))]
73
+ if kind in {"integer", "number"}:
74
+ return schema.get("minimum", 0)
75
+ if kind == "boolean":
76
+ return False
77
+ if kind == "null":
78
+ return None
79
+ return self.string(schema)
80
+
81
+ @staticmethod
82
+ def string(schema: dict[str, JsonValue]) -> str:
83
+ """Produce format-aware deterministic string samples."""
84
+ values = {
85
+ "uuid": SchemaCases.UUID,
86
+ "date": "2026-01-01",
87
+ "date-time": "2026-01-01T00:00:00Z",
88
+ }
89
+ result = values.get(str(schema.get("format")), "fixture")
90
+ minimum = schema.get("minLength", 0)
91
+ maximum = schema.get("maxLength", len(result))
92
+ if isinstance(minimum, int) and isinstance(maximum, int):
93
+ return result.ljust(minimum, "x")[:maximum]
94
+ return result
95
+
96
+ def nodes(
97
+ self, value: JsonValue, pointer: str
98
+ ) -> list[tuple[str, dict[str, JsonValue]]]:
99
+ """Walk every schema node with stable JSON-pointer identifiers."""
100
+ result: list[tuple[str, dict[str, JsonValue]]] = []
101
+ if isinstance(value, dict):
102
+ if (
103
+ isinstance(value.get("type"), (str, list))
104
+ or isinstance(value.get("$ref"), str)
105
+ or any(
106
+ isinstance(value.get(key), list)
107
+ for key in ("allOf", "anyOf", "oneOf", "enum")
108
+ )
109
+ ):
110
+ result.append((pointer, value))
111
+ for key, item in value.items():
112
+ result.extend(self.nodes(item, pointer + "/" + key))
113
+ elif isinstance(value, list):
114
+ for index, item in enumerate(value):
115
+ result.extend(self.nodes(item, pointer + "/" + str(index)))
116
+ return result
117
+
118
+ def probes(self) -> list[Probe]:
119
+ """Build positive probes for each node, enum member, and nullable branch."""
120
+ result: list[Probe] = []
121
+ roots: dict[str, JsonValue] = {"components": self.schemas}
122
+ for operation in self.contract.operations.values():
123
+ roots[operation.identifier] = operation.definition
124
+ for pointer, schema in self.nodes(roots, ""):
125
+ result.append(Probe(pointer, schema, self.sample(schema)))
126
+ enum = schema.get("enum")
127
+ if isinstance(enum, list):
128
+ result.extend(
129
+ Probe(pointer + "/enum/" + str(index), schema, item)
130
+ for index, item in enumerate(enum)
131
+ )
132
+ kind = schema.get("type")
133
+ if schema.get("nullable") is True:
134
+ result.append(Probe(pointer + "/legacy-nullable", schema, None))
135
+ if (
136
+ isinstance(kind, list)
137
+ and "null" in kind
138
+ and (not isinstance(enum, list) or None in enum)
139
+ ):
140
+ result.append(Probe(pointer + "/nullable", schema, None))
141
+ return result
@@ -0,0 +1,269 @@
1
+ """Deterministic code generation and release verification commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ import tomllib
14
+ from datetime import UTC, datetime
15
+ from importlib.metadata import version
16
+ from pathlib import Path
17
+
18
+ import yaml
19
+
20
+
21
+ class Development:
22
+ """Run the pinned generator with reviewed templates and transformations."""
23
+
24
+ def __init__(self, root: Path | None = None) -> None:
25
+ self.root = root if root is not None else Path(__file__).resolve().parents[2]
26
+
27
+ @classmethod
28
+ def main(cls) -> None:
29
+ """Dispatch development tasks through the project entry point."""
30
+ parser = argparse.ArgumentParser()
31
+ parser.add_argument(
32
+ "command", choices=["generate", "codegen-check", "initialize"]
33
+ )
34
+ args = parser.parse_args()
35
+ if args.command == "initialize":
36
+ cls().initialize()
37
+ return
38
+ cls().generate(check=args.command == "codegen-check")
39
+
40
+ def initialize(self) -> None:
41
+ """Record the initial reviewed baseline exactly once."""
42
+ resource = self.root / "src/ynab_api_cli/resources"
43
+ path = resource / "openapi.yaml"
44
+ spec = yaml.safe_load(path.read_text(encoding="utf-8"))
45
+ catalog = {
46
+ op["operationId"]: {"method": verb.upper(), "path": route}
47
+ for route, item in spec["paths"].items()
48
+ for verb, op in item.items()
49
+ if verb in {"get", "post", "put", "patch", "delete"}
50
+ }
51
+ provenance = {
52
+ "source_url": "https://api.ynab.com/papi/open_api_spec.yaml",
53
+ "retrieved_at": datetime.now(UTC).isoformat(),
54
+ "spec_sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
55
+ "api_version": spec["info"]["version"],
56
+ "generator": "openapi-python-client",
57
+ "generator_version": version("openapi-python-client"),
58
+ "operation_count": len(catalog),
59
+ }
60
+ for name, value in (("catalog.json", catalog), ("provenance.json", provenance)):
61
+ with (resource / name).open("x", encoding="utf-8") as stream:
62
+ stream.write(json.dumps(value, indent=2) + "\n")
63
+
64
+ def generate(self, *, check: bool) -> None:
65
+ """Generate into a temporary directory and compare or install source."""
66
+ root = self.root
67
+ config = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))[
68
+ "tool"
69
+ ]["ynab_api_cli"]["codegen"]
70
+ with tempfile.TemporaryDirectory() as temporary:
71
+ workspace = Path(temporary)
72
+ config_path = workspace / "config.json"
73
+ config_path.write_text(json.dumps(config), encoding="utf-8")
74
+ output = workspace / "generated"
75
+ result = subprocess.run( # noqa: S603 - fixed installed module and local paths, no shell.
76
+ [
77
+ sys.executable,
78
+ "-m",
79
+ "openapi_python_client",
80
+ "generate",
81
+ "--path",
82
+ str(root / "src/ynab_api_cli/resources/openapi.yaml"),
83
+ "--meta",
84
+ "none",
85
+ "--config",
86
+ str(config_path),
87
+ "--custom-template-path",
88
+ str(root / "templates"),
89
+ "--output-path",
90
+ str(output),
91
+ ],
92
+ capture_output=True,
93
+ text=True,
94
+ check=False,
95
+ )
96
+ if (
97
+ result.returncode
98
+ or "warning" in (result.stdout + result.stderr).lower()
99
+ ):
100
+ sys.stderr.write(result.stdout + result.stderr)
101
+ raise SystemExit(1)
102
+ for name in ("client.py", "types.py", "errors.py"):
103
+ (output / name).unlink()
104
+ for path in output.rglob("*.py"):
105
+ self.transform(path, output)
106
+ self.bindings(output)
107
+ subprocess.run( # noqa: S603 - fixed installed ruff module, no shell.
108
+ [
109
+ sys.executable,
110
+ "-m",
111
+ "ruff",
112
+ "format",
113
+ str(output),
114
+ "--config",
115
+ str(root / "pyproject.toml"),
116
+ ],
117
+ check=True,
118
+ )
119
+ subprocess.run( # noqa: S603 - fixed installed ruff module, no shell.
120
+ [
121
+ sys.executable,
122
+ "-m",
123
+ "ruff",
124
+ "check",
125
+ str(output),
126
+ "--fix",
127
+ "--config",
128
+ str(root / "pyproject.toml"),
129
+ ],
130
+ check=True,
131
+ )
132
+ subprocess.run( # noqa: S603 - fixed installed ruff module, no shell.
133
+ [
134
+ sys.executable,
135
+ "-m",
136
+ "ruff",
137
+ "format",
138
+ str(output),
139
+ "--config",
140
+ str(root / "pyproject.toml"),
141
+ ],
142
+ check=True,
143
+ )
144
+ destination = (root / "src/ynab_api_cli/generated").resolve()
145
+ if not destination.is_relative_to((root / "src/ynab_api_cli").resolve()):
146
+ raise SystemExit("Generated destination escapes the project.")
147
+ if check:
148
+ if self.fingerprints(output) != self.fingerprints(destination):
149
+ raise SystemExit(
150
+ "Generated source differs; run poe generate and review."
151
+ )
152
+ else:
153
+ if destination.exists():
154
+ shutil.rmtree(destination)
155
+ shutil.copytree(
156
+ output, destination, ignore=shutil.ignore_patterns(".ruff_cache")
157
+ )
158
+
159
+ @staticmethod
160
+ def transform(path: Path, root: Path) -> None:
161
+ """Use absolute package imports and complete generated file headers."""
162
+ text = path.read_text(encoding="utf-8")
163
+ if path.name == "__init__.py":
164
+ if path.parent == root:
165
+ text = '"""Generated REST client declarations."""\n'
166
+ elif path.parent.name != "models":
167
+ text = '"""Generated endpoint declarations."""\n'
168
+ else:
169
+ text = text.replace(
170
+ '""" Contains all the data models used in inputs/outputs """',
171
+ '"""Generated request and response models."""',
172
+ )
173
+ package = ["ynab_api_cli", "generated", *path.relative_to(root).parts[:-1]]
174
+ for match in list(re.finditer(r"from (\.+)([\w.]*) import", text)):
175
+ prefix = ".".join(package[: len(package) - len(match[1]) + 1])
176
+ suffix = "." + match[2] if match[2] else ""
177
+ text = text.replace(match[0], f"from {prefix}{suffix} import")
178
+ text = re.sub(r"from ynab_api_cli.generated.types import .*\n", "", text)
179
+ text = text.replace("from enum import Enum", "from enum import StrEnum")
180
+ text = re.sub(
181
+ r"class (\w+)\(str, Enum\):",
182
+ r'class \1(StrEnum):\n """Generated contract enumeration."""',
183
+ text,
184
+ )
185
+ text = re.sub(
186
+ r" def __str__\(self\) -> str:\n return str\(self.value\)\n",
187
+ "",
188
+ text,
189
+ )
190
+ if not text.startswith('"""'):
191
+ text = (
192
+ '"""Generated declarations; regenerate rather than editing."""\n' + text
193
+ )
194
+ if "from __future__ import annotations" not in text:
195
+ closing = text.index('"""', 3) + 3
196
+ text = (
197
+ text[:closing]
198
+ + "\nfrom __future__ import annotations\n"
199
+ + text[closing:]
200
+ )
201
+ if path.name == "__init__.py" and path.parent.name == "models":
202
+ text += '''
203
+ from pydantic import BaseModel
204
+
205
+ class ModelRegistry:
206
+ """Resolve forward references after all models have loaded."""
207
+ @classmethod
208
+ def rebuild(cls) -> None:
209
+ """Resolve generated models against the complete namespace."""
210
+ namespace = globals()
211
+ for value in tuple(namespace.values()):
212
+ if (
213
+ isinstance(value, type) and value is not BaseModel
214
+ and issubclass(value, BaseModel)
215
+ ):
216
+ value.model_rebuild(_types_namespace=namespace)
217
+
218
+ ModelRegistry.rebuild()
219
+ '''
220
+ path.write_text(text, encoding="utf-8")
221
+
222
+ @staticmethod
223
+ def fingerprints(root: Path) -> dict[str, str]:
224
+ """Hash generated Python files independently of cache artifacts."""
225
+ return {
226
+ str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest()
227
+ for path in root.rglob("*.py")
228
+ }
229
+
230
+ @staticmethod
231
+ def bindings(root: Path) -> None:
232
+ """Generate static class bindings without reflective endpoint invocation."""
233
+ imports: list[str] = []
234
+ entries: list[str] = []
235
+ for index, path in enumerate(sorted((root / "api").glob("*/*.py"))):
236
+ if path.name == "__init__.py":
237
+ continue
238
+ module = ".".join(path.parent.relative_to(root).parts)
239
+ alias = f"operation{index}"
240
+ imports.append(
241
+ f"from ynab_api_cli.generated.{module} import {path.stem} as {alias}"
242
+ )
243
+ entries.append(f' "{path.stem}": {alias}.Endpoint.dispatch,')
244
+ text = "\n".join(
245
+ [
246
+ '"""Generated static endpoint dispatch; regenerate to change."""',
247
+ "from __future__ import annotations",
248
+ "from collections.abc import Callable, Mapping",
249
+ "from types import MappingProxyType",
250
+ "from typing import ClassVar",
251
+ "from ynab_api_cli.transport import Transport",
252
+ "from ynab_api_cli.types import RequestValues, Response",
253
+ *imports,
254
+ "",
255
+ "class Bindings:",
256
+ ' """Statically reference every generated typed operation."""',
257
+ " operations: ClassVar[Mapping[str,",
258
+ " Callable[[Transport, RequestValues], Response[object]]",
259
+ " ]] = MappingProxyType({",
260
+ *entries,
261
+ " })",
262
+ "",
263
+ ]
264
+ )
265
+ (root / "bindings.py").write_text(text, encoding="utf-8")
266
+
267
+
268
+ if __name__ == "__main__":
269
+ Development.main()