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.
- ynab_api_cli/__init__.py +3 -0
- ynab_api_cli/__main__.py +266 -0
- ynab_api_cli/cli.py +171 -0
- ynab_api_cli/compat.py +120 -0
- ynab_api_cli/config.py +110 -0
- ynab_api_cli/contract.py +197 -0
- ynab_api_cli/contract_testing.py +141 -0
- ynab_api_cli/development.py +269 -0
- ynab_api_cli/distribution.py +123 -0
- ynab_api_cli/exceptions.py +7 -0
- ynab_api_cli/generated/__init__.py +3 -0
- ynab_api_cli/generated/api/__init__.py +3 -0
- ynab_api_cli/generated/api/accounts/__init__.py +3 -0
- ynab_api_cli/generated/api/accounts/create_account.py +54 -0
- ynab_api_cli/generated/api/accounts/get_account_by_id.py +53 -0
- ynab_api_cli/generated/api/accounts/get_accounts.py +57 -0
- ynab_api_cli/generated/api/categories/__init__.py +3 -0
- ynab_api_cli/generated/api/categories/create_category.py +54 -0
- ynab_api_cli/generated/api/categories/create_category_group.py +58 -0
- ynab_api_cli/generated/api/categories/get_categories.py +57 -0
- ynab_api_cli/generated/api/categories/get_category_by_id.py +52 -0
- ynab_api_cli/generated/api/categories/get_month_category_by_id.py +58 -0
- ynab_api_cli/generated/api/categories/update_category.py +57 -0
- ynab_api_cli/generated/api/categories/update_category_group.py +63 -0
- ynab_api_cli/generated/api/categories/update_month_category.py +65 -0
- ynab_api_cli/generated/api/money_movements/__init__.py +3 -0
- ynab_api_cli/generated/api/money_movements/get_money_movement_groups.py +51 -0
- ynab_api_cli/generated/api/money_movements/get_money_movement_groups_by_month.py +57 -0
- ynab_api_cli/generated/api/money_movements/get_money_movements.py +51 -0
- ynab_api_cli/generated/api/money_movements/get_money_movements_by_month.py +57 -0
- ynab_api_cli/generated/api/months/__init__.py +3 -0
- ynab_api_cli/generated/api/months/get_plan_month.py +55 -0
- ynab_api_cli/generated/api/months/get_plan_months.py +59 -0
- ynab_api_cli/generated/api/payee_locations/__init__.py +3 -0
- ynab_api_cli/generated/api/payee_locations/get_payee_location_by_id.py +54 -0
- ynab_api_cli/generated/api/payee_locations/get_payee_locations.py +51 -0
- ynab_api_cli/generated/api/payee_locations/get_payee_locations_by_payee.py +54 -0
- ynab_api_cli/generated/api/payees/__init__.py +3 -0
- ynab_api_cli/generated/api/payees/create_payee.py +54 -0
- ynab_api_cli/generated/api/payees/get_payee_by_id.py +52 -0
- ynab_api_cli/generated/api/payees/get_payees.py +57 -0
- ynab_api_cli/generated/api/payees/update_payee.py +57 -0
- ynab_api_cli/generated/api/plans/__init__.py +3 -0
- ynab_api_cli/generated/api/plans/get_plan_by_id.py +57 -0
- ynab_api_cli/generated/api/plans/get_plan_settings_by_id.py +49 -0
- ynab_api_cli/generated/api/plans/get_plans.py +51 -0
- ynab_api_cli/generated/api/scheduled_transactions/__init__.py +3 -0
- ynab_api_cli/generated/api/scheduled_transactions/create_scheduled_transaction.py +58 -0
- ynab_api_cli/generated/api/scheduled_transactions/delete_scheduled_transaction.py +56 -0
- ynab_api_cli/generated/api/scheduled_transactions/get_scheduled_transaction_by_id.py +56 -0
- ynab_api_cli/generated/api/scheduled_transactions/get_scheduled_transactions.py +59 -0
- ynab_api_cli/generated/api/scheduled_transactions/update_scheduled_transaction.py +63 -0
- ynab_api_cli/generated/api/transactions/__init__.py +3 -0
- ynab_api_cli/generated/api/transactions/create_transaction.py +58 -0
- ynab_api_cli/generated/api/transactions/delete_transaction.py +54 -0
- ynab_api_cli/generated/api/transactions/get_transaction_by_id.py +54 -0
- ynab_api_cli/generated/api/transactions/get_transactions.py +80 -0
- ynab_api_cli/generated/api/transactions/get_transactions_by_account.py +87 -0
- ynab_api_cli/generated/api/transactions/get_transactions_by_category.py +89 -0
- ynab_api_cli/generated/api/transactions/get_transactions_by_month.py +87 -0
- ynab_api_cli/generated/api/transactions/get_transactions_by_payee.py +89 -0
- ynab_api_cli/generated/api/transactions/import_transactions.py +54 -0
- ynab_api_cli/generated/api/transactions/update_transaction.py +59 -0
- ynab_api_cli/generated/api/transactions/update_transactions.py +58 -0
- ynab_api_cli/generated/api/user/__init__.py +3 -0
- ynab_api_cli/generated/api/user/get_user.py +42 -0
- ynab_api_cli/generated/bindings.py +141 -0
- ynab_api_cli/generated/models/__init__.py +416 -0
- ynab_api_cli/generated/models/account.py +71 -0
- ynab_api_cli/generated/models/account_base.py +57 -0
- ynab_api_cli/generated/models/account_response.py +19 -0
- ynab_api_cli/generated/models/account_response_data.py +19 -0
- ynab_api_cli/generated/models/account_type.py +23 -0
- ynab_api_cli/generated/models/accounts_response.py +21 -0
- ynab_api_cli/generated/models/accounts_response_data.py +20 -0
- ynab_api_cli/generated/models/bulk_response.py +19 -0
- ynab_api_cli/generated/models/bulk_response_data.py +21 -0
- ynab_api_cli/generated/models/bulk_response_data_bulk.py +15 -0
- ynab_api_cli/generated/models/bulk_transactions.py +21 -0
- ynab_api_cli/generated/models/categories_response.py +21 -0
- ynab_api_cli/generated/models/categories_response_data.py +22 -0
- ynab_api_cli/generated/models/category.py +104 -0
- ynab_api_cli/generated/models/category_base.py +74 -0
- ynab_api_cli/generated/models/category_group.py +20 -0
- ynab_api_cli/generated/models/category_group_with_categories.py +25 -0
- ynab_api_cli/generated/models/category_response.py +21 -0
- ynab_api_cli/generated/models/category_response_data.py +19 -0
- ynab_api_cli/generated/models/currency_format_type_0.py +21 -0
- ynab_api_cli/generated/models/date_format_type_0.py +14 -0
- ynab_api_cli/generated/models/debt_1.py +18 -0
- ynab_api_cli/generated/models/debt_2.py +18 -0
- ynab_api_cli/generated/models/debt_3.py +18 -0
- ynab_api_cli/generated/models/error_detail.py +16 -0
- ynab_api_cli/generated/models/error_response.py +19 -0
- ynab_api_cli/generated/models/existing_category.py +34 -0
- ynab_api_cli/generated/models/existing_transaction.py +43 -0
- ynab_api_cli/generated/models/flag_1.py +17 -0
- ynab_api_cli/generated/models/flag_2.py +17 -0
- ynab_api_cli/generated/models/flag_3.py +17 -0
- ynab_api_cli/generated/models/get_transactions_by_account_type.py +12 -0
- ynab_api_cli/generated/models/get_transactions_by_category_type.py +12 -0
- ynab_api_cli/generated/models/get_transactions_by_month_type.py +12 -0
- ynab_api_cli/generated/models/get_transactions_by_payee_type.py +12 -0
- ynab_api_cli/generated/models/get_transactions_type.py +12 -0
- ynab_api_cli/generated/models/goal_1.py +15 -0
- ynab_api_cli/generated/models/goal_2.py +15 -0
- ynab_api_cli/generated/models/goal_3.py +15 -0
- ynab_api_cli/generated/models/hybrid_transaction.py +70 -0
- ynab_api_cli/generated/models/hybrid_transaction_type.py +12 -0
- ynab_api_cli/generated/models/hybrid_transactions_response.py +21 -0
- ynab_api_cli/generated/models/hybrid_transactions_response_data.py +22 -0
- ynab_api_cli/generated/models/loan_account_periodic_value_type_0.py +33 -0
- ynab_api_cli/generated/models/money_movement.py +35 -0
- ynab_api_cli/generated/models/money_movement_base.py +33 -0
- ynab_api_cli/generated/models/money_movement_group.py +25 -0
- ynab_api_cli/generated/models/money_movement_groups_response.py +21 -0
- ynab_api_cli/generated/models/money_movement_groups_response_data.py +22 -0
- ynab_api_cli/generated/models/money_movements_response.py +21 -0
- ynab_api_cli/generated/models/money_movements_response_data.py +20 -0
- ynab_api_cli/generated/models/month_detail.py +42 -0
- ynab_api_cli/generated/models/month_detail_base.py +30 -0
- ynab_api_cli/generated/models/month_detail_response.py +21 -0
- ynab_api_cli/generated/models/month_detail_response_data.py +19 -0
- ynab_api_cli/generated/models/month_summaries_response.py +21 -0
- ynab_api_cli/generated/models/month_summaries_response_data.py +20 -0
- ynab_api_cli/generated/models/month_summary.py +37 -0
- ynab_api_cli/generated/models/month_summary_base.py +25 -0
- ynab_api_cli/generated/models/new_category.py +34 -0
- ynab_api_cli/generated/models/new_transaction.py +44 -0
- ynab_api_cli/generated/models/patch_category_group_wrapper.py +19 -0
- ynab_api_cli/generated/models/patch_category_wrapper.py +19 -0
- ynab_api_cli/generated/models/patch_month_category_wrapper.py +19 -0
- ynab_api_cli/generated/models/patch_payee_wrapper.py +19 -0
- ynab_api_cli/generated/models/patch_transactions_wrapper.py +21 -0
- ynab_api_cli/generated/models/payee.py +23 -0
- ynab_api_cli/generated/models/payee_location.py +20 -0
- ynab_api_cli/generated/models/payee_location_response.py +21 -0
- ynab_api_cli/generated/models/payee_location_response_data.py +19 -0
- ynab_api_cli/generated/models/payee_locations_response.py +21 -0
- ynab_api_cli/generated/models/payee_locations_response_data.py +19 -0
- ynab_api_cli/generated/models/payee_response.py +19 -0
- ynab_api_cli/generated/models/payee_response_data.py +19 -0
- ynab_api_cli/generated/models/payees_response.py +19 -0
- ynab_api_cli/generated/models/payees_response_data.py +20 -0
- ynab_api_cli/generated/models/plan_detail.py +74 -0
- ynab_api_cli/generated/models/plan_detail_response.py +21 -0
- ynab_api_cli/generated/models/plan_detail_response_data.py +20 -0
- ynab_api_cli/generated/models/plan_settings.py +21 -0
- ynab_api_cli/generated/models/plan_settings_response.py +21 -0
- ynab_api_cli/generated/models/plan_settings_response_data.py +19 -0
- ynab_api_cli/generated/models/plan_summary.py +38 -0
- ynab_api_cli/generated/models/plan_summary_response.py +21 -0
- ynab_api_cli/generated/models/plan_summary_response_data.py +24 -0
- ynab_api_cli/generated/models/post_account_wrapper.py +19 -0
- ynab_api_cli/generated/models/post_category_group_wrapper.py +19 -0
- ynab_api_cli/generated/models/post_category_wrapper.py +19 -0
- ynab_api_cli/generated/models/post_payee.py +14 -0
- ynab_api_cli/generated/models/post_payee_wrapper.py +19 -0
- ynab_api_cli/generated/models/post_scheduled_transaction_wrapper.py +23 -0
- ynab_api_cli/generated/models/post_transactions_wrapper.py +24 -0
- ynab_api_cli/generated/models/put_scheduled_transaction_wrapper.py +23 -0
- ynab_api_cli/generated/models/put_transaction_wrapper.py +19 -0
- ynab_api_cli/generated/models/save_account.py +18 -0
- ynab_api_cli/generated/models/save_account_type.py +16 -0
- ynab_api_cli/generated/models/save_category.py +34 -0
- ynab_api_cli/generated/models/save_category_goal_frequency.py +13 -0
- ynab_api_cli/generated/models/save_category_group.py +14 -0
- ynab_api_cli/generated/models/save_category_group_response.py +21 -0
- ynab_api_cli/generated/models/save_category_group_response_data.py +20 -0
- ynab_api_cli/generated/models/save_category_response.py +21 -0
- ynab_api_cli/generated/models/save_category_response_data.py +20 -0
- ynab_api_cli/generated/models/save_month_category.py +14 -0
- ynab_api_cli/generated/models/save_payee.py +16 -0
- ynab_api_cli/generated/models/save_payee_response.py +21 -0
- ynab_api_cli/generated/models/save_payee_response_data.py +20 -0
- ynab_api_cli/generated/models/save_scheduled_transaction.py +37 -0
- ynab_api_cli/generated/models/save_sub_transaction.py +22 -0
- ynab_api_cli/generated/models/save_transaction_with_id_or_import_id.py +45 -0
- ynab_api_cli/generated/models/save_transaction_with_optional_fields.py +43 -0
- ynab_api_cli/generated/models/save_transactions_response.py +21 -0
- ynab_api_cli/generated/models/save_transactions_response_data.py +29 -0
- ynab_api_cli/generated/models/scheduled_sub_transaction.py +31 -0
- ynab_api_cli/generated/models/scheduled_sub_transaction_base.py +29 -0
- ynab_api_cli/generated/models/scheduled_transaction_detail.py +53 -0
- ynab_api_cli/generated/models/scheduled_transaction_frequency.py +23 -0
- ynab_api_cli/generated/models/scheduled_transaction_response.py +21 -0
- ynab_api_cli/generated/models/scheduled_transaction_response_data.py +23 -0
- ynab_api_cli/generated/models/scheduled_transaction_summary.py +43 -0
- ynab_api_cli/generated/models/scheduled_transaction_summary_base.py +41 -0
- ynab_api_cli/generated/models/scheduled_transaction_summary_base_frequency.py +23 -0
- ynab_api_cli/generated/models/scheduled_transactions_response.py +21 -0
- ynab_api_cli/generated/models/scheduled_transactions_response_data.py +24 -0
- ynab_api_cli/generated/models/sub_transaction.py +34 -0
- ynab_api_cli/generated/models/sub_transaction_base.py +32 -0
- ynab_api_cli/generated/models/transaction_cleared_status.py +13 -0
- ynab_api_cli/generated/models/transaction_detail.py +70 -0
- ynab_api_cli/generated/models/transaction_response.py +21 -0
- ynab_api_cli/generated/models/transaction_response_data.py +20 -0
- ynab_api_cli/generated/models/transaction_summary.py +62 -0
- ynab_api_cli/generated/models/transaction_summary_base.py +60 -0
- ynab_api_cli/generated/models/transactions_import_response.py +21 -0
- ynab_api_cli/generated/models/transactions_import_response_data.py +14 -0
- ynab_api_cli/generated/models/transactions_response.py +21 -0
- ynab_api_cli/generated/models/transactions_response_data.py +20 -0
- ynab_api_cli/generated/models/user.py +16 -0
- ynab_api_cli/generated/models/user_response.py +19 -0
- ynab_api_cli/generated/models/user_response_data.py +19 -0
- ynab_api_cli/live.py +449 -0
- ynab_api_cli/py.typed +0 -0
- ynab_api_cli/resources/__init__.py +3 -0
- ynab_api_cli/resources/catalog.json +178 -0
- ynab_api_cli/resources/openapi.yaml +4035 -0
- ynab_api_cli/resources/provenance.json +9 -0
- ynab_api_cli/resources/schema_features.json +913 -0
- ynab_api_cli/resources/skill/SKILL.md +21 -0
- ynab_api_cli/resources/skill/references/operations.md +36 -0
- ynab_api_cli/resources/skill/references/setup.md +32 -0
- ynab_api_cli/security.py +83 -0
- ynab_api_cli/transport.py +133 -0
- ynab_api_cli/types.py +110 -0
- ynab_api_cli/verification.py +167 -0
- ynab_api_cli-0.1.0.dist-info/METADATA +90 -0
- ynab_api_cli-0.1.0.dist-info/RECORD +225 -0
- ynab_api_cli-0.1.0.dist-info/WHEEL +4 -0
- ynab_api_cli-0.1.0.dist-info/entry_points.txt +3 -0
ynab_api_cli/contract.py
ADDED
|
@@ -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()
|