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/__init__.py
ADDED
ynab_api_cli/__main__.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""CLI orchestration with safe diagnostics and explicit mutation execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Callable, Sequence
|
|
11
|
+
from importlib.resources import files
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
|
|
16
|
+
from ynab_api_cli.cli import Command, CommandLine
|
|
17
|
+
from ynab_api_cli.compat import Compatibility
|
|
18
|
+
from ynab_api_cli.config import ConfigStore
|
|
19
|
+
from ynab_api_cli.contract import Contract
|
|
20
|
+
from ynab_api_cli.exceptions import AppError
|
|
21
|
+
from ynab_api_cli.generated.bindings import Bindings
|
|
22
|
+
from ynab_api_cli.transport import Transport
|
|
23
|
+
from ynab_api_cli.types import JsonCodec, JsonValue, RequestValues
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Application:
|
|
29
|
+
"""Coordinate connection setup and reviewed typed operation dispatch."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self, store: ConfigStore | None = None, transport: Transport | None = None
|
|
33
|
+
) -> None:
|
|
34
|
+
self.contract = Contract()
|
|
35
|
+
self.store = store
|
|
36
|
+
self.transport = transport
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def main(cls, argv: Sequence[str] | None = None) -> None:
|
|
40
|
+
"""Run the console entry point and return its documented exit status."""
|
|
41
|
+
sys.exit(cls().run(argv))
|
|
42
|
+
|
|
43
|
+
def run(self, argv: Sequence[str] | None = None) -> int:
|
|
44
|
+
"""Produce JSON output and omit traceback chains containing secrets."""
|
|
45
|
+
try:
|
|
46
|
+
command = CommandLine(self.contract).parse(argv)
|
|
47
|
+
logging.basicConfig(
|
|
48
|
+
level=command.log_level, format="%(levelname)s: %(message)s"
|
|
49
|
+
)
|
|
50
|
+
output = self.dispatch(command)
|
|
51
|
+
sys.stdout.write(
|
|
52
|
+
json.dumps(output, indent=2, ensure_ascii=False, allow_nan=False) + "\n"
|
|
53
|
+
)
|
|
54
|
+
if isinstance(output, dict) and output.get("compatible") is False:
|
|
55
|
+
return 1
|
|
56
|
+
except (AppError, OSError) as err:
|
|
57
|
+
logger.error( # noqa: TRY400 - never expose credential-bearing tracebacks.
|
|
58
|
+
"%s",
|
|
59
|
+
str(err)
|
|
60
|
+
if isinstance(err, AppError)
|
|
61
|
+
else "Filesystem operation failed.",
|
|
62
|
+
)
|
|
63
|
+
return 1
|
|
64
|
+
except ValidationError:
|
|
65
|
+
logger.error("Generated model validation failed; check compatibility.") # noqa: TRY400 - omit request values.
|
|
66
|
+
return 1
|
|
67
|
+
except KeyboardInterrupt:
|
|
68
|
+
return 130
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
def dispatch(self, command: Command) -> JsonValue:
|
|
72
|
+
"""Route non-network discovery and authenticated commands."""
|
|
73
|
+
routes: dict[str, Callable[[Command], JsonValue]] = {
|
|
74
|
+
"discover": self.discover,
|
|
75
|
+
"inspect": self.inspect,
|
|
76
|
+
"compat": self.compatibility,
|
|
77
|
+
"skill": self.skill,
|
|
78
|
+
}
|
|
79
|
+
if command.name in routes:
|
|
80
|
+
return routes[command.name](command)
|
|
81
|
+
store = self.store if self.store is not None else ConfigStore()
|
|
82
|
+
if command.name in {"configure", "select-plan"}:
|
|
83
|
+
return self.configure(command, store)
|
|
84
|
+
connection = store.load()
|
|
85
|
+
transport = (
|
|
86
|
+
self.transport
|
|
87
|
+
if self.transport is not None
|
|
88
|
+
else Transport(connection, self.contract)
|
|
89
|
+
)
|
|
90
|
+
if command.name == "doctor":
|
|
91
|
+
return self.doctor(command, transport, store)
|
|
92
|
+
return self.execute(command, transport)
|
|
93
|
+
|
|
94
|
+
def execute(self, command: Command, transport: Transport) -> JsonValue:
|
|
95
|
+
"""Validate and preview or submit one reviewed operation."""
|
|
96
|
+
connection = transport.connection
|
|
97
|
+
operation = self.contract.get(command.operation or "")
|
|
98
|
+
request = self.request(command)
|
|
99
|
+
path = dict(request.path)
|
|
100
|
+
if (
|
|
101
|
+
"{plan_id}" in operation.path
|
|
102
|
+
and "plan_id" not in path
|
|
103
|
+
and connection.plan_id is not None
|
|
104
|
+
):
|
|
105
|
+
path["plan_id"] = connection.plan_id
|
|
106
|
+
request = RequestValues(path, request.query, request.body)
|
|
107
|
+
self.contract.validate_request(operation, request)
|
|
108
|
+
if operation.write and not command.execute:
|
|
109
|
+
return {
|
|
110
|
+
"preview": True,
|
|
111
|
+
"operation": operation.identifier,
|
|
112
|
+
"method": operation.method,
|
|
113
|
+
"path": operation.path,
|
|
114
|
+
"request": request.json(),
|
|
115
|
+
}
|
|
116
|
+
response = Bindings.operations[operation.alias](transport, request)
|
|
117
|
+
if command.metadata:
|
|
118
|
+
return {
|
|
119
|
+
"status": response.status,
|
|
120
|
+
"headers": JsonCodec.encode(response.headers),
|
|
121
|
+
"contract_version": self.contract.version,
|
|
122
|
+
"body": response.raw,
|
|
123
|
+
}
|
|
124
|
+
return response.raw
|
|
125
|
+
|
|
126
|
+
def discover(self, command: Command) -> JsonValue:
|
|
127
|
+
"""List the reviewed contract groups and matching operations."""
|
|
128
|
+
groups = {op.group for op in self.contract.operations.values()}
|
|
129
|
+
if command.group is not None and command.group not in groups:
|
|
130
|
+
raise AppError("Unknown resource group.")
|
|
131
|
+
return JsonCodec.encode(
|
|
132
|
+
{
|
|
133
|
+
"contract_version": self.contract.version,
|
|
134
|
+
"groups": sorted(groups),
|
|
135
|
+
"operations": [
|
|
136
|
+
op.identifier
|
|
137
|
+
for op in self.contract.operations.values()
|
|
138
|
+
if op.identifier in self.contract.catalog
|
|
139
|
+
and (command.group is None or op.group == command.group)
|
|
140
|
+
],
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def inspect(self, command: Command) -> JsonValue:
|
|
145
|
+
"""Return the operation and only its reachable schemas."""
|
|
146
|
+
operation = self.contract.get(command.operation or "")
|
|
147
|
+
return {
|
|
148
|
+
"operation": operation.identifier,
|
|
149
|
+
"method": operation.method,
|
|
150
|
+
"path": operation.path,
|
|
151
|
+
"write": operation.write,
|
|
152
|
+
"definition": operation.definition,
|
|
153
|
+
"components": self.reachable(operation.definition),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
def compatibility(self, _command: Command) -> JsonValue:
|
|
157
|
+
"""Check the public upstream definition without credentials."""
|
|
158
|
+
checker = Compatibility(self.contract)
|
|
159
|
+
return checker.compare(checker.fetch())
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def configure(command: Command, store: ConfigStore) -> JsonValue:
|
|
163
|
+
"""Perform only the explicitly selected local configuration action."""
|
|
164
|
+
if command.name == "select-plan":
|
|
165
|
+
store.select_plan(command.plan or "")
|
|
166
|
+
return {"plan_id": command.plan}
|
|
167
|
+
if not sys.stdin.isatty():
|
|
168
|
+
raise AppError("Run configure in an interactive terminal for hidden input.")
|
|
169
|
+
store.configure(
|
|
170
|
+
getpass.getpass("YNAB personal access token (hidden): "),
|
|
171
|
+
replace=command.replace,
|
|
172
|
+
)
|
|
173
|
+
return {"configured": True, "directory": str(store.root)}
|
|
174
|
+
|
|
175
|
+
def doctor(
|
|
176
|
+
self, command: Command, transport: Transport, store: ConfigStore
|
|
177
|
+
) -> dict[str, JsonValue]:
|
|
178
|
+
"""Validate API access separately from offline configuration checks."""
|
|
179
|
+
if not command.offline:
|
|
180
|
+
Bindings.operations["get_user"](transport, RequestValues())
|
|
181
|
+
if transport.connection.plan_id is not None:
|
|
182
|
+
Bindings.operations["get_plan_by_id"](
|
|
183
|
+
transport, RequestValues({"plan_id": transport.connection.plan_id})
|
|
184
|
+
)
|
|
185
|
+
output: dict[str, JsonValue] = {
|
|
186
|
+
"ok": True,
|
|
187
|
+
"api_checked": not command.offline,
|
|
188
|
+
"directory": str(store.root),
|
|
189
|
+
"plan_id": transport.connection.plan_id,
|
|
190
|
+
"contract_version": self.contract.version,
|
|
191
|
+
}
|
|
192
|
+
if command.upstream:
|
|
193
|
+
checker = Compatibility(self.contract)
|
|
194
|
+
report = checker.compare(checker.fetch())
|
|
195
|
+
output["upstream"] = report
|
|
196
|
+
output["compatible"] = report["compatible"]
|
|
197
|
+
return output
|
|
198
|
+
|
|
199
|
+
def request(self, command: Command) -> RequestValues:
|
|
200
|
+
"""Read the uniform envelope or a resource command's body."""
|
|
201
|
+
if command.source is None:
|
|
202
|
+
return command.arguments
|
|
203
|
+
text = command.source
|
|
204
|
+
if text.startswith("@"):
|
|
205
|
+
text = Path(text[1:]).read_text(encoding="utf-8")
|
|
206
|
+
elif text == "-":
|
|
207
|
+
text = sys.stdin.read()
|
|
208
|
+
try:
|
|
209
|
+
raw = JsonCodec.loads(text)
|
|
210
|
+
except ValueError as err:
|
|
211
|
+
raise AppError("Invalid request JSON.") from err
|
|
212
|
+
if command.name != "call":
|
|
213
|
+
return RequestValues(command.arguments.path, command.arguments.query, raw)
|
|
214
|
+
envelope = self.contract.object(raw)
|
|
215
|
+
if set(envelope) - {"path", "query", "body"}:
|
|
216
|
+
raise AppError("Request accepts only path, query, and body fields.")
|
|
217
|
+
base = RequestValues(
|
|
218
|
+
dict(self.contract.object(envelope.get("path", {}))),
|
|
219
|
+
dict(self.contract.object(envelope.get("query", {}))),
|
|
220
|
+
)
|
|
221
|
+
return (
|
|
222
|
+
RequestValues(base.path, base.query, envelope["body"])
|
|
223
|
+
if "body" in envelope
|
|
224
|
+
else base
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def reachable(self, definition: JsonValue) -> dict[str, JsonValue]:
|
|
228
|
+
"""Disclose only schemas reachable from the inspected operation."""
|
|
229
|
+
selected: dict[str, JsonValue] = {}
|
|
230
|
+
pending = [definition]
|
|
231
|
+
schemas = self.contract.object(
|
|
232
|
+
self.contract.object(self.contract.document["components"])["schemas"]
|
|
233
|
+
)
|
|
234
|
+
while pending:
|
|
235
|
+
value = pending.pop()
|
|
236
|
+
if isinstance(value, dict):
|
|
237
|
+
reference = value.get("$ref")
|
|
238
|
+
if isinstance(reference, str) and reference.startswith(
|
|
239
|
+
"#/components/schemas/"
|
|
240
|
+
):
|
|
241
|
+
name = reference.rsplit("/", 1)[-1]
|
|
242
|
+
if name not in selected:
|
|
243
|
+
selected[name] = schemas[name]
|
|
244
|
+
pending.append(schemas[name])
|
|
245
|
+
pending.extend(value.values())
|
|
246
|
+
elif isinstance(value, list):
|
|
247
|
+
pending.extend(value)
|
|
248
|
+
return selected
|
|
249
|
+
|
|
250
|
+
@staticmethod
|
|
251
|
+
def skill(command: Command) -> JsonValue:
|
|
252
|
+
"""Display or export the skill supplied by the installed distribution."""
|
|
253
|
+
resource = files("ynab_api_cli.resources").joinpath("skill")
|
|
254
|
+
if command.operation == "show":
|
|
255
|
+
return {"skill": resource.joinpath("SKILL.md").read_text(encoding="utf-8")}
|
|
256
|
+
if command.source is None:
|
|
257
|
+
raise AppError("skill export requires a destination directory.")
|
|
258
|
+
destination = Path(command.source)
|
|
259
|
+
if destination.exists():
|
|
260
|
+
raise AppError("Skill export destination already exists.")
|
|
261
|
+
shutil.copytree(str(resource), destination)
|
|
262
|
+
return {"exported": str(destination)}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
Application.main()
|
ynab_api_cli/cli.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Contract-derived argument parsing into immutable command settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from ynab_api_cli.contract import Contract
|
|
10
|
+
from ynab_api_cli.types import RequestValues
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Command:
|
|
15
|
+
"""Typed command input separated from argparse's dynamic namespace."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
operation: str | None
|
|
19
|
+
group: str | None
|
|
20
|
+
plan: str | None
|
|
21
|
+
source: str | None
|
|
22
|
+
arguments: RequestValues
|
|
23
|
+
execute: bool
|
|
24
|
+
metadata: bool
|
|
25
|
+
offline: bool
|
|
26
|
+
upstream: bool
|
|
27
|
+
replace: bool
|
|
28
|
+
log_level: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CommandLine:
|
|
32
|
+
"""Expose every reviewed endpoint with useful resource-specific flags."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, contract: Contract) -> None:
|
|
35
|
+
self.contract = contract
|
|
36
|
+
|
|
37
|
+
def parser(self) -> argparse.ArgumentParser:
|
|
38
|
+
"""Build connection, discovery, uniform, and resource subcommands."""
|
|
39
|
+
parser = argparse.ArgumentParser(
|
|
40
|
+
prog="ynab-api-cli", description="Complete reviewed YNAB REST API CLI"
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--log-level", choices=["ERROR", "WARNING", "INFO"], default="WARNING"
|
|
44
|
+
)
|
|
45
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
46
|
+
configure = sub.add_parser(
|
|
47
|
+
"configure", help="Prompt locally for protected credentials"
|
|
48
|
+
)
|
|
49
|
+
configure.add_argument("--replace", action="store_true")
|
|
50
|
+
doctor = sub.add_parser(
|
|
51
|
+
"doctor", help="Validate local settings and read-only API access"
|
|
52
|
+
)
|
|
53
|
+
doctor.add_argument("--offline", action="store_true")
|
|
54
|
+
doctor.add_argument("--upstream", action="store_true")
|
|
55
|
+
select = sub.add_parser("select-plan")
|
|
56
|
+
select.add_argument("plan")
|
|
57
|
+
discover = sub.add_parser("discover")
|
|
58
|
+
discover.add_argument("group", nargs="?")
|
|
59
|
+
describe = sub.add_parser("inspect")
|
|
60
|
+
describe.add_argument("operation")
|
|
61
|
+
call = sub.add_parser("call")
|
|
62
|
+
call.add_argument("operation")
|
|
63
|
+
call.add_argument(
|
|
64
|
+
"--request",
|
|
65
|
+
dest="source",
|
|
66
|
+
default="{}",
|
|
67
|
+
help="JSON envelope, @file, or - for stdin",
|
|
68
|
+
)
|
|
69
|
+
self.execution_flags(call)
|
|
70
|
+
compat = sub.add_parser("compat")
|
|
71
|
+
compat.add_argument("action", choices=["check"])
|
|
72
|
+
skill = sub.add_parser(
|
|
73
|
+
"skill", help="Inspect or export the bundled supplementary skill"
|
|
74
|
+
)
|
|
75
|
+
skill.add_argument("action", choices=["show", "export"])
|
|
76
|
+
skill.add_argument("destination", nargs="?")
|
|
77
|
+
groups = {
|
|
78
|
+
group: sub.add_parser(group).add_subparsers(dest="operation", required=True)
|
|
79
|
+
for group in sorted(
|
|
80
|
+
{
|
|
81
|
+
op.group
|
|
82
|
+
for op in self.contract.operations.values()
|
|
83
|
+
if op.identifier in self.contract.catalog
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
for operation in self.contract.operations.values():
|
|
88
|
+
if operation.identifier not in self.contract.catalog:
|
|
89
|
+
continue
|
|
90
|
+
endpoint = groups[operation.group].add_parser(
|
|
91
|
+
operation.alias.replace("_", "-"),
|
|
92
|
+
help=str(operation.definition.get("summary", "")),
|
|
93
|
+
)
|
|
94
|
+
endpoint.set_defaults(operation=operation.identifier)
|
|
95
|
+
for parameter in self.contract.parameters(operation):
|
|
96
|
+
endpoint.add_argument(
|
|
97
|
+
"--" + str(parameter["name"]).replace("_", "-"),
|
|
98
|
+
dest="argument_" + str(parameter["name"]),
|
|
99
|
+
required=bool(parameter.get("required"))
|
|
100
|
+
and parameter["name"] != "plan_id",
|
|
101
|
+
help=str(parameter.get("description", "")),
|
|
102
|
+
)
|
|
103
|
+
if operation.definition.get("requestBody") is not None:
|
|
104
|
+
endpoint.add_argument(
|
|
105
|
+
"--body",
|
|
106
|
+
dest="source",
|
|
107
|
+
required=True,
|
|
108
|
+
help="JSON object, @file, or - for stdin",
|
|
109
|
+
)
|
|
110
|
+
self.execution_flags(endpoint)
|
|
111
|
+
return parser
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def execution_flags(parser: argparse.ArgumentParser) -> None:
|
|
115
|
+
"""Add mutation guards and optional HTTP metadata."""
|
|
116
|
+
parser.add_argument(
|
|
117
|
+
"--execute", action="store_true", help="Submit an authorized mutation"
|
|
118
|
+
)
|
|
119
|
+
parser.add_argument("--metadata", action="store_true")
|
|
120
|
+
|
|
121
|
+
def parse(self, argv: Sequence[str] | None = None) -> Command:
|
|
122
|
+
"""Convert the namespace and endpoint flags into immutable settings."""
|
|
123
|
+
parser = self.parser()
|
|
124
|
+
args = parser.parse_args(argv)
|
|
125
|
+
name = str(args.command)
|
|
126
|
+
operation = getattr(args, "operation", None)
|
|
127
|
+
path: dict[str, object] = {}
|
|
128
|
+
query: dict[str, object] = {}
|
|
129
|
+
if operation is not None and name not in {"inspect", "call"}:
|
|
130
|
+
for parameter in self.contract.parameters(self.contract.get(operation)):
|
|
131
|
+
raw = getattr(args, "argument_" + str(parameter["name"]), None)
|
|
132
|
+
if raw is not None:
|
|
133
|
+
schema = self.contract.object(parameter["schema"])
|
|
134
|
+
value = self.convert(
|
|
135
|
+
str(raw), str(schema.get("type", "string")), parser
|
|
136
|
+
)
|
|
137
|
+
(path if parameter["in"] == "path" else query)[
|
|
138
|
+
str(parameter["name"])
|
|
139
|
+
] = value
|
|
140
|
+
source = getattr(args, "source", None)
|
|
141
|
+
if name == "skill":
|
|
142
|
+
operation = str(args.action)
|
|
143
|
+
source = getattr(args, "destination", None)
|
|
144
|
+
return Command(
|
|
145
|
+
name,
|
|
146
|
+
operation,
|
|
147
|
+
getattr(args, "group", None),
|
|
148
|
+
getattr(args, "plan", None),
|
|
149
|
+
source,
|
|
150
|
+
RequestValues(path, query),
|
|
151
|
+
bool(getattr(args, "execute", False)),
|
|
152
|
+
bool(getattr(args, "metadata", False)),
|
|
153
|
+
bool(getattr(args, "offline", False)),
|
|
154
|
+
bool(getattr(args, "upstream", False)),
|
|
155
|
+
bool(getattr(args, "replace", False)),
|
|
156
|
+
str(args.log_level),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def convert(text: str, kind: str, parser: argparse.ArgumentParser) -> object:
|
|
161
|
+
"""Decode CLI primitive types without treating false as truthy."""
|
|
162
|
+
if kind == "boolean":
|
|
163
|
+
if text not in {"true", "false"}:
|
|
164
|
+
parser.error("Boolean values must be true or false.")
|
|
165
|
+
return text == "true"
|
|
166
|
+
if kind == "integer":
|
|
167
|
+
try:
|
|
168
|
+
return int(text)
|
|
169
|
+
except ValueError:
|
|
170
|
+
parser.error("Integer argument required.")
|
|
171
|
+
return text
|
ynab_api_cli/compat.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Compare bundled and upstream contracts without modifying installed files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from ynab_api_cli.contract import Contract
|
|
12
|
+
from ynab_api_cli.exceptions import AppError
|
|
13
|
+
from ynab_api_cli.types import JsonCodec, JsonValue
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Compatibility:
|
|
17
|
+
"""Detect additions, removals, and structural contract changes."""
|
|
18
|
+
|
|
19
|
+
URL = "https://api.ynab.com/papi/open_api_spec.yaml"
|
|
20
|
+
DOCUMENTATION_KEYS = frozenset(
|
|
21
|
+
{"description", "summary", "example", "examples", "externalDocs"}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def __init__(self, contract: Contract | None = None) -> None:
|
|
25
|
+
self.contract = contract if contract is not None else Contract()
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def structural(cls, value: JsonValue) -> JsonValue:
|
|
29
|
+
"""Strip documentation while retaining API validation and wire semantics."""
|
|
30
|
+
if isinstance(value, dict):
|
|
31
|
+
return {
|
|
32
|
+
key: cls.structural(item)
|
|
33
|
+
for key, item in value.items()
|
|
34
|
+
if key not in cls.DOCUMENTATION_KEYS
|
|
35
|
+
}
|
|
36
|
+
if isinstance(value, list):
|
|
37
|
+
return [cls.structural(item) for item in value]
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
def compare(self, latest: dict[str, JsonValue]) -> dict[str, JsonValue]:
|
|
41
|
+
"""Report semantic paths and operation changes, including optional additions."""
|
|
42
|
+
upstream = Contract(latest)
|
|
43
|
+
baseline = self.contract.operations
|
|
44
|
+
added = sorted(set(upstream.operations) - set(baseline))
|
|
45
|
+
removed = sorted(set(baseline) - set(upstream.operations))
|
|
46
|
+
changed = [
|
|
47
|
+
name
|
|
48
|
+
for name in sorted(set(baseline) & set(upstream.operations))
|
|
49
|
+
if self.structural(baseline[name].definition)
|
|
50
|
+
!= self.structural(upstream.operations[name].definition)
|
|
51
|
+
or baseline[name].path != upstream.operations[name].path
|
|
52
|
+
or baseline[name].method != upstream.operations[name].method
|
|
53
|
+
]
|
|
54
|
+
schema_changes = self.differences(
|
|
55
|
+
self.structural(self.contract.document["components"]),
|
|
56
|
+
self.structural(latest["components"]),
|
|
57
|
+
"/components",
|
|
58
|
+
)
|
|
59
|
+
server_changes = self.contract.document.get("servers") != latest.get("servers")
|
|
60
|
+
compatible = not (
|
|
61
|
+
added or removed or changed or schema_changes or server_changes
|
|
62
|
+
)
|
|
63
|
+
affected = sorted(
|
|
64
|
+
set(changed)
|
|
65
|
+
| set(removed)
|
|
66
|
+
| (set(baseline) if schema_changes or server_changes else set())
|
|
67
|
+
)
|
|
68
|
+
output: dict[str, JsonValue] = {
|
|
69
|
+
"compatible": compatible,
|
|
70
|
+
"baseline_version": self.contract.version,
|
|
71
|
+
"upstream_version": upstream.version,
|
|
72
|
+
"added": JsonCodec.encode(added),
|
|
73
|
+
"removed": JsonCodec.encode(removed),
|
|
74
|
+
"changed": JsonCodec.encode(changed),
|
|
75
|
+
"schema_changes": JsonCodec.encode(schema_changes),
|
|
76
|
+
"server_changed": server_changes,
|
|
77
|
+
"affected_commands": JsonCodec.encode(affected),
|
|
78
|
+
"missing_tests": JsonCodec.encode(added),
|
|
79
|
+
"documentation_only": compatible and self.contract.document != latest,
|
|
80
|
+
}
|
|
81
|
+
return output
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def differences(cls, old: JsonValue, new: JsonValue, path: str) -> list[str]:
|
|
85
|
+
"""Find changed schema paths without relying on version strings."""
|
|
86
|
+
if isinstance(old, dict) and isinstance(new, dict):
|
|
87
|
+
result: list[str] = []
|
|
88
|
+
for key in sorted(set(old) | set(new)):
|
|
89
|
+
pointer = path + "/" + key.replace("~", "~0").replace("/", "~1")
|
|
90
|
+
result.extend(
|
|
91
|
+
[pointer]
|
|
92
|
+
if key not in old or key not in new
|
|
93
|
+
else cls.differences(old[key], new[key], pointer)
|
|
94
|
+
)
|
|
95
|
+
return result
|
|
96
|
+
return [] if old == new else [path]
|
|
97
|
+
|
|
98
|
+
def fetch(self, client: httpx.Client | None = None) -> dict[str, JsonValue]:
|
|
99
|
+
"""Fetch the official YAML using bounded credential-free HTTPS."""
|
|
100
|
+
try:
|
|
101
|
+
if client is None:
|
|
102
|
+
with httpx.Client(
|
|
103
|
+
verify=True, follow_redirects=False, timeout=30, trust_env=False
|
|
104
|
+
) as session:
|
|
105
|
+
response = session.get(self.URL)
|
|
106
|
+
else:
|
|
107
|
+
response = client.get(self.URL, timeout=30, follow_redirects=False)
|
|
108
|
+
response.raise_for_status()
|
|
109
|
+
return Contract.object(JsonCodec.encode(yaml.safe_load(response.text)))
|
|
110
|
+
except (httpx.HTTPError, yaml.YAMLError, ValueError) as err:
|
|
111
|
+
raise AppError(
|
|
112
|
+
"Cannot fetch or parse the public upstream contract."
|
|
113
|
+
) from err
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def digest(value: dict[str, JsonValue]) -> str:
|
|
117
|
+
"""Hash a canonical contract document for provenance checks."""
|
|
118
|
+
return hashlib.sha256(
|
|
119
|
+
json.dumps(value, sort_keys=True).encode("utf-8")
|
|
120
|
+
).hexdigest()
|
ynab_api_cli/config.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Atomic connection configuration in XDG-style directories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
from ynab_api_cli.contract import Contract
|
|
12
|
+
from ynab_api_cli.exceptions import AppError
|
|
13
|
+
from ynab_api_cli.security import FileSecurity
|
|
14
|
+
from ynab_api_cli.transport import Connection
|
|
15
|
+
from ynab_api_cli.types import JsonCodec, JsonValue
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigStore:
|
|
19
|
+
"""Keep credentials separate from non-secret default settings."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self, root: Path | None = None, security: FileSecurity | None = None
|
|
23
|
+
) -> None:
|
|
24
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
|
|
25
|
+
if root is None and not base.is_absolute():
|
|
26
|
+
raise AppError("XDG_CONFIG_HOME must be absolute.")
|
|
27
|
+
self.root = root if root is not None else base / "ynab-api-cli"
|
|
28
|
+
self.security = security if security is not None else FileSecurity()
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def read(path: Path) -> dict[str, JsonValue]:
|
|
32
|
+
"""Read a JSON object without reporting credential file contents."""
|
|
33
|
+
try:
|
|
34
|
+
return Contract.object(JsonCodec.loads(path.read_text(encoding="utf-8")))
|
|
35
|
+
except (OSError, ValueError) as err:
|
|
36
|
+
raise AppError(f"Cannot read JSON configuration: {path.name}.") from err
|
|
37
|
+
|
|
38
|
+
def write(
|
|
39
|
+
self,
|
|
40
|
+
path: Path,
|
|
41
|
+
values: dict[str, JsonValue],
|
|
42
|
+
*,
|
|
43
|
+
secret: bool = False,
|
|
44
|
+
exclusive: bool = False,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Replace a JSON file atomically after securing its temporary file."""
|
|
47
|
+
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
48
|
+
descriptor, temporary = tempfile.mkstemp(dir=self.root, prefix=".ynab-api-cli-")
|
|
49
|
+
temporary_path = Path(temporary)
|
|
50
|
+
try:
|
|
51
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
52
|
+
stream.write(json.dumps(values, indent=2) + "\n")
|
|
53
|
+
stream.flush()
|
|
54
|
+
os.fsync(stream.fileno())
|
|
55
|
+
if secret:
|
|
56
|
+
self.security.protect(temporary_path)
|
|
57
|
+
if exclusive:
|
|
58
|
+
os.link(temporary_path, path)
|
|
59
|
+
else:
|
|
60
|
+
temporary_path.replace(path)
|
|
61
|
+
except OSError as err:
|
|
62
|
+
raise AppError("Cannot write configuration atomically.") from err
|
|
63
|
+
finally:
|
|
64
|
+
temporary_path.unlink(missing_ok=True)
|
|
65
|
+
|
|
66
|
+
def configure(self, token: str, *, replace: bool = False) -> None:
|
|
67
|
+
"""Create or explicitly replace protected credentials."""
|
|
68
|
+
if not token.strip() or any(character.isspace() for character in token.strip()):
|
|
69
|
+
raise AppError("Token must be nonempty and contain no whitespace.")
|
|
70
|
+
Connection(token.strip())
|
|
71
|
+
path = self.root / "auth.json"
|
|
72
|
+
if path.exists() and not replace:
|
|
73
|
+
raise AppError(
|
|
74
|
+
"Credentials already exist; use configure --replace to rotate."
|
|
75
|
+
)
|
|
76
|
+
previous = self.read(path) if path.exists() else {}
|
|
77
|
+
previous["access_token"] = token.strip()
|
|
78
|
+
self.write(path, previous, secret=True, exclusive=not replace)
|
|
79
|
+
|
|
80
|
+
def load(self) -> Connection:
|
|
81
|
+
"""Validate permissions, credentials, and optional default plan."""
|
|
82
|
+
path = self.root / "auth.json"
|
|
83
|
+
if not path.is_file():
|
|
84
|
+
raise AppError("Missing auth.json; run configure in a local terminal.")
|
|
85
|
+
self.security.validate(path)
|
|
86
|
+
token = self.read(path).get("access_token")
|
|
87
|
+
if (
|
|
88
|
+
not isinstance(token, str)
|
|
89
|
+
or not token.strip()
|
|
90
|
+
or any(character.isspace() for character in token)
|
|
91
|
+
):
|
|
92
|
+
raise AppError(
|
|
93
|
+
"auth.json requires a nonempty access_token without whitespace."
|
|
94
|
+
)
|
|
95
|
+
config = self.root / "config.json"
|
|
96
|
+
plan = self.read(config).get("plan_id") if config.exists() else None
|
|
97
|
+
if plan is not None and (not isinstance(plan, str) or not plan):
|
|
98
|
+
raise AppError("config.json plan_id must be a nonempty string.")
|
|
99
|
+
return Connection(token, plan)
|
|
100
|
+
|
|
101
|
+
def select_plan(self, plan: str) -> None:
|
|
102
|
+
"""Store an explicit UUID without overwriting unrelated settings."""
|
|
103
|
+
try:
|
|
104
|
+
UUID(plan)
|
|
105
|
+
except ValueError as err:
|
|
106
|
+
raise AppError("Select an explicit plan UUID.") from err
|
|
107
|
+
path = self.root / "config.json"
|
|
108
|
+
values = self.read(path) if path.exists() else {}
|
|
109
|
+
values["plan_id"] = plan
|
|
110
|
+
self.write(path, values)
|