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
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ynab-api-cli
|
|
3
|
+
description: Use the YNAB API CLI to inspect plans, accounts, categories, transactions, scheduled transactions, and other REST API resources; configure a connection or perform explicitly requested changes.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# YNAB API CLI
|
|
7
|
+
|
|
8
|
+
Use `ynab-api-cli`, the REST-only CLI distributed with this skill. YNAB calls budgets **plans** in the current API.
|
|
9
|
+
|
|
10
|
+
- Resolve the installed executable with `ynab-api-cli --help`. For a source checkout, use `uv run --project <project> ynab-api-cli`.
|
|
11
|
+
- On first use in a session, run `doctor`. If configuration is missing, read [references/setup.md](references/setup.md) and give the user the hidden local token-prompt command. Never request a token in chat.
|
|
12
|
+
- `discover`, `discover GROUP`, and `inspect OPERATION_ID` require no connection and make no API requests. Inspect only the operation needed; its output includes only reachable schemas.
|
|
13
|
+
- Use resource commands for ordinary operations or `call OPERATION_ID --request @file` for a JSON envelope containing `path`, `query`, and optional `body`.
|
|
14
|
+
- Prefer explicit UUIDs. A configured default plan is supplied only when plan_id is omitted. Resolve duplicate names through IDs; never infer selection from list position.
|
|
15
|
+
- Read [references/operations.md](references/operations.md) for milliunits, request examples, delta responses, splits, and transfers.
|
|
16
|
+
- Read affected records before an authorized change. Writes preview by default; review target IDs and the exact payload, then use `--execute` within the user's authorization. Existing authorization is sufficient.
|
|
17
|
+
- Verify submitted changes through subsequent reads. After ambiguous failures, inspect state before retrying; the CLI never retries automatically.
|
|
18
|
+
- `compat check` reports upstream contract drift. It does not change the installed CLI or grant permission to adopt new operations.
|
|
19
|
+
- Output is JSON; errors go to stderr. Report exact records and any incomplete verification.
|
|
20
|
+
|
|
21
|
+
Export this bundled skill with `ynab-api-cli skill export <new-directory>`. This skill is supplementary; the CLI works independently of Codex.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Operation conventions
|
|
2
|
+
|
|
3
|
+
```text
|
|
4
|
+
ynab-api-cli discover transactions
|
|
5
|
+
ynab-api-cli inspect getTransactions
|
|
6
|
+
ynab-api-cli transactions get-transactions --plan-id UUID --since-date 2026-01-01
|
|
7
|
+
ynab-api-cli call getTransactions --request @request.json
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The equivalent uniform request is:
|
|
11
|
+
|
|
12
|
+
```json
|
|
13
|
+
{"path":{"plan_id":"UUID"},"query":{"since_date":"2026-01-01"}}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- Amounts are integer milliunits: 12.34 currency units becomes 12340. Expenses are negative. Preserve the plan's currency.
|
|
17
|
+
- Dates follow the inspected ISO schema. Booleans use `true` or `false`; cleared status uses its declared enum.
|
|
18
|
+
- An omitted field differs from explicit null. Never replace unspecified fields with null during an update.
|
|
19
|
+
- Preserve server_knowledge and deleted records when intentionally processing delta responses. The CLI does not maintain synchronization state.
|
|
20
|
+
- Splits and transfers affect linked records. Inspect their models before editing them.
|
|
21
|
+
- Transaction creation accepts exactly one of `transaction` or `transactions`. Direct Import is a separate mutation from creating transactions with import_id.
|
|
22
|
+
|
|
23
|
+
Create an authorized expense using a body file:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{"transaction":{"account_id":"ACCOUNT_UUID","date":"2026-01-01","amount":-12340,"category_id":"CATEGORY_UUID","memo":"Authorized expense","approved":false}}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
ynab-api-cli transactions create-transaction --plan-id PLAN_UUID --body @expense.json
|
|
31
|
+
ynab-api-cli transactions create-transaction --plan-id PLAN_UUID --body @expense.json --execute
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The first command previews without contacting YNAB. The second submits once. Read the returned transaction ID using get-transaction-by-id to verify the result.
|
|
35
|
+
|
|
36
|
+
`--metadata` adds HTTP status, relevant headers, and contract version. Unknown parameters and invalid schemas fail before networking; properties allowed by the contract are retained. [Official API conventions](https://api.ynab.com/#data-formats).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Connection setup
|
|
2
|
+
|
|
3
|
+
1. Install the CLI from a release wheel or source checkout using `uv tool install <wheel-or-project-path>`.
|
|
4
|
+
2. Open [YNAB Developer Settings](https://app.ynab.com/settings/developer) and generate a personal access token for your own account. [Authentication documentation](https://api.ynab.com/#personal-access-tokens).
|
|
5
|
+
3. In a local interactive terminal:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
ynab-api-cli configure
|
|
9
|
+
ynab-api-cli doctor
|
|
10
|
+
ynab-api-cli plans get-plans
|
|
11
|
+
ynab-api-cli select-plan EXPLICIT_PLAN_UUID
|
|
12
|
+
ynab-api-cli doctor
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Configuration uses `$XDG_CONFIG_HOME/ynab-api-cli` or `~/.config/ynab-api-cli`, including Windows. XDG_CONFIG_HOME must be absolute. `auth.json` holds `access_token`; `config.json` holds the optional `plan_id`. Existing unrelated fields are preserved.
|
|
16
|
+
|
|
17
|
+
`configure` uses hidden input and protects credentials with POSIX mode 600 or a private current-user Windows ACL. It refuses redirected input and existing credentials. `configure --replace` rotates credentials explicitly. Credentials are plaintext: use a private, local directory and exclude it from source control and shared storage.
|
|
18
|
+
|
|
19
|
+
`doctor --offline` validates files and protection only; it does not prove authentication. Online doctor reads the user and configured plan. `doctor --upstream` additionally checks the published contract.
|
|
20
|
+
|
|
21
|
+
| Failure | Action |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| Missing auth.json | Run configure locally |
|
|
24
|
+
| Permissions/ACL failure | Repair file protection or use configure --replace |
|
|
25
|
+
| HTTP 401 | Replace an invalid or revoked token |
|
|
26
|
+
| HTTP 403 | Check account access and token scope |
|
|
27
|
+
| HTTP 404 on selected plan | List plans and select the correct UUID |
|
|
28
|
+
| HTTP 429 | Stop requests; respect rate-limit guidance |
|
|
29
|
+
| Network failure during a write | Read affected records before any retry |
|
|
30
|
+
| Contract or model mismatch | Run compat check and review the supported version |
|
|
31
|
+
|
|
32
|
+
OAuth refresh and delegated multi-user authentication are outside this release. Each adopter configures a personal token locally.
|
ynab_api_cli/security.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Protect credentials with private POSIX permissions or Windows ACLs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ynab_api_cli.exceptions import AppError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FileSecurity:
|
|
14
|
+
"""Apply and inspect credential protection without revealing file contents."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, platform: str = os.name) -> None:
|
|
17
|
+
self.platform = platform
|
|
18
|
+
|
|
19
|
+
def powershell(self, script: str) -> str:
|
|
20
|
+
"""Execute a fixed ACL script with no profile or interactive window."""
|
|
21
|
+
encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
|
|
22
|
+
try:
|
|
23
|
+
result = subprocess.run( # noqa: S603 - encoded fixed ACL script; no shell.
|
|
24
|
+
[
|
|
25
|
+
str(
|
|
26
|
+
Path(os.environ.get("SYSTEMROOT", "C:/Windows"))
|
|
27
|
+
/ "System32/WindowsPowerShell/v1.0/powershell.exe"
|
|
28
|
+
),
|
|
29
|
+
"-NoProfile",
|
|
30
|
+
"-NonInteractive",
|
|
31
|
+
"-EncodedCommand",
|
|
32
|
+
encoded,
|
|
33
|
+
],
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
check=True,
|
|
37
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
38
|
+
)
|
|
39
|
+
except (OSError, subprocess.CalledProcessError) as err:
|
|
40
|
+
raise AppError(
|
|
41
|
+
"Cannot inspect or protect Windows credential ACLs."
|
|
42
|
+
) from err
|
|
43
|
+
return result.stdout.strip()
|
|
44
|
+
|
|
45
|
+
def protect(self, path: Path) -> None:
|
|
46
|
+
"""Replace permissions on a newly created credential file."""
|
|
47
|
+
if self.platform != "nt":
|
|
48
|
+
path.chmod(0o600)
|
|
49
|
+
return
|
|
50
|
+
literal = str(path).replace("'", "''")
|
|
51
|
+
self.powershell(
|
|
52
|
+
"$ErrorActionPreference='Stop'; "
|
|
53
|
+
"$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User; "
|
|
54
|
+
"$acl=New-Object System.Security.AccessControl.FileSecurity; "
|
|
55
|
+
"$acl.SetOwner($sid); $acl.SetAccessRuleProtection($true,$false); "
|
|
56
|
+
"$rule=New-Object System.Security.AccessControl.FileSystemAccessRule"
|
|
57
|
+
"($sid,'FullControl','Allow'); "
|
|
58
|
+
"$acl.AddAccessRule($rule); "
|
|
59
|
+
f"[System.IO.File]::SetAccessControl('{literal}', $acl)"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def validate(self, path: Path) -> None:
|
|
63
|
+
"""Refuse credential files readable by other principals."""
|
|
64
|
+
if self.platform != "nt":
|
|
65
|
+
if path.stat().st_mode & 0o077:
|
|
66
|
+
raise AppError("Credential file must have mode 600.")
|
|
67
|
+
return
|
|
68
|
+
literal = str(path).replace("'", "''")
|
|
69
|
+
result = self.powershell(
|
|
70
|
+
"$ErrorActionPreference='Stop'; "
|
|
71
|
+
"$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent()"
|
|
72
|
+
".User.Value; "
|
|
73
|
+
f"$acl=[System.IO.File]::GetAccessControl('{literal}'); "
|
|
74
|
+
"$bad=@($acl.Access | Where-Object { "
|
|
75
|
+
"$_.AccessControlType -eq 'Allow' -and $_.IdentityReference.Translate"
|
|
76
|
+
"([System.Security.Principal.SecurityIdentifier]).Value -ne $sid }); "
|
|
77
|
+
"if ($bad.Count -eq 0 -and $acl.AreAccessRulesProtected) "
|
|
78
|
+
"{ 'private' } else { 'shared' }"
|
|
79
|
+
)
|
|
80
|
+
if result != "private":
|
|
81
|
+
raise AppError(
|
|
82
|
+
"Credential ACL is not private; rerun configure --replace locally."
|
|
83
|
+
)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Bounded REST execution with independent schema checks and raw JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from http import HTTPStatus
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ynab_api_cli.contract import Contract
|
|
13
|
+
from ynab_api_cli.exceptions import AppError
|
|
14
|
+
from ynab_api_cli.types import JsonCodec, RequestValues, Response
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class Connection:
|
|
19
|
+
"""Connection settings whose token is never included in representations."""
|
|
20
|
+
|
|
21
|
+
token: str = field(repr=False)
|
|
22
|
+
plan_id: str | None = None
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
"""Reject invalid header characters without including token contents."""
|
|
26
|
+
if (
|
|
27
|
+
not self.token
|
|
28
|
+
or not self.token.isascii()
|
|
29
|
+
or not self.token.isprintable()
|
|
30
|
+
or any(character.isspace() for character in self.token)
|
|
31
|
+
):
|
|
32
|
+
raise AppError("Access token must use visible ASCII without whitespace.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Transport:
|
|
36
|
+
"""Submit exactly one request; forbid credentials following redirects."""
|
|
37
|
+
|
|
38
|
+
BASE_URL = "https://api.ynab.com/v1"
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
connection: Connection,
|
|
43
|
+
contract: Contract | None = None,
|
|
44
|
+
client: httpx.Client | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.connection = connection
|
|
47
|
+
self.contract = contract if contract is not None else Contract()
|
|
48
|
+
self.client = client
|
|
49
|
+
|
|
50
|
+
def send(self, identifier: str, request: RequestValues) -> Response[None]:
|
|
51
|
+
"""Validate, serialize, send once, and validate the response JSON."""
|
|
52
|
+
operation = self.contract.get(identifier)
|
|
53
|
+
self.contract.validate_request(operation, request)
|
|
54
|
+
values = request.json()
|
|
55
|
+
path = operation.path
|
|
56
|
+
for name, value in self.contract.object(values["path"]).items():
|
|
57
|
+
path = path.replace("{" + name + "}", quote(str(value), safe=""))
|
|
58
|
+
query = {
|
|
59
|
+
key: self.query_value(value)
|
|
60
|
+
for key, value in self.contract.object(values["query"]).items()
|
|
61
|
+
}
|
|
62
|
+
body = (
|
|
63
|
+
json.dumps(values["body"], ensure_ascii=False, allow_nan=False).encode(
|
|
64
|
+
"utf-8"
|
|
65
|
+
)
|
|
66
|
+
if "body" in values
|
|
67
|
+
else None
|
|
68
|
+
)
|
|
69
|
+
headers = {
|
|
70
|
+
"Authorization": "Bearer " + self.connection.token,
|
|
71
|
+
"Accept": "application/json",
|
|
72
|
+
}
|
|
73
|
+
if body is not None:
|
|
74
|
+
headers["Content-Type"] = "application/json"
|
|
75
|
+
try:
|
|
76
|
+
if self.client is not None:
|
|
77
|
+
response = self.client.request(
|
|
78
|
+
operation.method,
|
|
79
|
+
self.BASE_URL + path,
|
|
80
|
+
params=query,
|
|
81
|
+
content=body,
|
|
82
|
+
headers=headers,
|
|
83
|
+
timeout=30,
|
|
84
|
+
follow_redirects=False,
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
with httpx.Client(
|
|
88
|
+
verify=True,
|
|
89
|
+
follow_redirects=False,
|
|
90
|
+
transport=httpx.HTTPTransport(retries=0),
|
|
91
|
+
trust_env=False,
|
|
92
|
+
) as client:
|
|
93
|
+
response = client.request(
|
|
94
|
+
operation.method,
|
|
95
|
+
self.BASE_URL + path,
|
|
96
|
+
params=query,
|
|
97
|
+
content=body,
|
|
98
|
+
headers=headers,
|
|
99
|
+
timeout=30,
|
|
100
|
+
)
|
|
101
|
+
except httpx.HTTPError as err:
|
|
102
|
+
uncertainty = (
|
|
103
|
+
" Write outcome may be unknown; inspect state before retrying."
|
|
104
|
+
if operation.write
|
|
105
|
+
else ""
|
|
106
|
+
)
|
|
107
|
+
raise AppError("YNAB network failure; no retry." + uncertainty) from err
|
|
108
|
+
if response.status_code >= HTTPStatus.MULTIPLE_CHOICES:
|
|
109
|
+
retry = response.headers.get("Retry-After")
|
|
110
|
+
suffix = (
|
|
111
|
+
f" Retry-After: {retry}."
|
|
112
|
+
if retry is not None and retry.isdecimal()
|
|
113
|
+
else ""
|
|
114
|
+
)
|
|
115
|
+
raise AppError(f"YNAB HTTP {response.status_code}; no retry." + suffix)
|
|
116
|
+
try:
|
|
117
|
+
raw = JsonCodec.loads(response.text)
|
|
118
|
+
except ValueError as err:
|
|
119
|
+
raise AppError(
|
|
120
|
+
"YNAB returned malformed JSON; outcome may be unknown."
|
|
121
|
+
) from err
|
|
122
|
+
self.contract.validate_response(operation, response.status_code, raw)
|
|
123
|
+
relevant = {
|
|
124
|
+
key: value
|
|
125
|
+
for key, value in response.headers.items()
|
|
126
|
+
if key.lower() in {"content-type", "x-rate-limit", "retry-after"}
|
|
127
|
+
}
|
|
128
|
+
return Response(response.status_code, raw, relevant, None)
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def query_value(value: object) -> str:
|
|
132
|
+
"""Encode boolean query values in their lowercase wire form."""
|
|
133
|
+
return str(value).lower() if isinstance(value, bool) else str(value)
|
ynab_api_cli/types.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Shared JSON values and typed REST requests and responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import date
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import NoReturn, TypeVar, cast
|
|
12
|
+
from uuid import UUID
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, GetCoreSchemaHandler, TypeAdapter
|
|
15
|
+
from pydantic_core import CoreSchema, core_schema
|
|
16
|
+
|
|
17
|
+
type JsonValue = (
|
|
18
|
+
bool | int | float | str | list[JsonValue] | dict[str, JsonValue] | None
|
|
19
|
+
)
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Unset:
|
|
24
|
+
"""Distinguish an omitted field from an explicit JSON null."""
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def __get_pydantic_core_schema__(
|
|
28
|
+
cls, _source_type: object, _handler: GetCoreSchemaHandler
|
|
29
|
+
) -> CoreSchema:
|
|
30
|
+
"""Require the actual sentinel; never coerce a JSON object into omission."""
|
|
31
|
+
return core_schema.is_instance_schema(cls)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
UNSET = Unset()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class JsonCodec:
|
|
38
|
+
"""Convert validated Python values without erasing JSON field presence."""
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def encode(cls, value: object) -> JsonValue:
|
|
42
|
+
"""Normalize JSON-compatible values and typed contract models."""
|
|
43
|
+
if isinstance(value, BaseModel):
|
|
44
|
+
return cls.encode(
|
|
45
|
+
value.model_dump(mode="python", by_alias=True, exclude_unset=True)
|
|
46
|
+
)
|
|
47
|
+
if isinstance(value, dict):
|
|
48
|
+
return {
|
|
49
|
+
str(key): cls.encode(item)
|
|
50
|
+
for key, item in value.items()
|
|
51
|
+
if not isinstance(item, Unset)
|
|
52
|
+
}
|
|
53
|
+
if isinstance(value, (list, tuple)):
|
|
54
|
+
return [cls.encode(item) for item in value]
|
|
55
|
+
if isinstance(value, Enum):
|
|
56
|
+
return cls.encode(value.value)
|
|
57
|
+
if isinstance(value, (date, UUID)):
|
|
58
|
+
return value.isoformat() if isinstance(value, date) else str(value)
|
|
59
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
60
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
61
|
+
raise ValueError("Non-finite numbers are not valid JSON.")
|
|
62
|
+
return value
|
|
63
|
+
raise ValueError("Value cannot be represented as JSON.")
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def loads(text: str) -> JsonValue:
|
|
67
|
+
"""Decode JSON into its recursive value type."""
|
|
68
|
+
# JSON's parser guarantees this recursive union for successfully decoded input.
|
|
69
|
+
return cast(
|
|
70
|
+
"JsonValue", json.loads(text, parse_constant=JsonCodec.reject_constant)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def reject_constant(_text: str) -> NoReturn:
|
|
75
|
+
"""Reject non-standard JSON numeric constants without exposing values."""
|
|
76
|
+
raise ValueError("Non-finite numbers are not valid JSON.")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class RequestValues:
|
|
81
|
+
"""Raw or typed endpoint arguments with explicit body presence."""
|
|
82
|
+
|
|
83
|
+
path: Mapping[str, object] = field(default_factory=dict)
|
|
84
|
+
query: Mapping[str, object] = field(default_factory=dict)
|
|
85
|
+
body: object = UNSET
|
|
86
|
+
|
|
87
|
+
def value(self, section: str, name: str, adapter: TypeAdapter[T]) -> T:
|
|
88
|
+
"""Decode one argument using the generated endpoint's exact type."""
|
|
89
|
+
source = self.path if section == "path" else self.query
|
|
90
|
+
return adapter.validate_python(source.get(name, UNSET))
|
|
91
|
+
|
|
92
|
+
def json(self) -> dict[str, JsonValue]:
|
|
93
|
+
"""Normalize the request without inserting omitted bodies or fields."""
|
|
94
|
+
values: dict[str, JsonValue] = {
|
|
95
|
+
"path": JsonCodec.encode(self.path),
|
|
96
|
+
"query": JsonCodec.encode(self.query),
|
|
97
|
+
}
|
|
98
|
+
if not isinstance(self.body, Unset):
|
|
99
|
+
values["body"] = JsonCodec.encode(self.body)
|
|
100
|
+
return values
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True, slots=True)
|
|
104
|
+
class Response[T_co]:
|
|
105
|
+
"""HTTP response retaining raw JSON, headers, and its typed representation."""
|
|
106
|
+
|
|
107
|
+
status: int
|
|
108
|
+
raw: JsonValue
|
|
109
|
+
headers: dict[str, str]
|
|
110
|
+
parsed: T_co
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Reviewed feature manifests and public upstream candidate verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from ynab_api_cli.compat import Compatibility
|
|
18
|
+
from ynab_api_cli.contract import Contract
|
|
19
|
+
from ynab_api_cli.contract_testing import SchemaCases
|
|
20
|
+
from ynab_api_cli.development import Development
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Verification:
|
|
24
|
+
"""Keep acceptance manifests reviewed and detect all structural changes."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, root: Path | None = None) -> None:
|
|
27
|
+
self.root = root if root is not None else Path(__file__).resolve().parents[2]
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def main(cls) -> None:
|
|
31
|
+
"""Execute the public poe verification tasks."""
|
|
32
|
+
parser = argparse.ArgumentParser()
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"command",
|
|
35
|
+
choices=["manifest-init", "contract-check", "compat-check", "skill-check"],
|
|
36
|
+
)
|
|
37
|
+
command = str(parser.parse_args().command)
|
|
38
|
+
instance = cls()
|
|
39
|
+
if command == "manifest-init":
|
|
40
|
+
with (
|
|
41
|
+
instance.root / "src/ynab_api_cli/resources/schema_features.json"
|
|
42
|
+
).open("x", encoding="utf-8") as stream:
|
|
43
|
+
json.dump(instance.manifest(), stream, indent=2)
|
|
44
|
+
stream.write("\n")
|
|
45
|
+
elif command == "contract-check":
|
|
46
|
+
instance.contract_check()
|
|
47
|
+
elif command == "skill-check":
|
|
48
|
+
instance.skill_check()
|
|
49
|
+
else:
|
|
50
|
+
instance.compat_check()
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def manifest() -> dict[str, str]:
|
|
54
|
+
"""Fingerprint every declared schema node, enum variant, and null branch."""
|
|
55
|
+
return {
|
|
56
|
+
probe.identifier: hashlib.sha256(
|
|
57
|
+
json.dumps(
|
|
58
|
+
Compatibility.structural(probe.schema), sort_keys=True
|
|
59
|
+
).encode("utf-8")
|
|
60
|
+
).hexdigest()
|
|
61
|
+
for probe in SchemaCases(Contract()).probes()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def contract_check(self) -> None:
|
|
65
|
+
"""Require review when any existing or additional schema feature changes."""
|
|
66
|
+
resource = self.root / "src/ynab_api_cli/resources"
|
|
67
|
+
reviewed = json.loads(
|
|
68
|
+
(resource / "schema_features.json").read_text(encoding="utf-8")
|
|
69
|
+
)
|
|
70
|
+
if reviewed != self.manifest():
|
|
71
|
+
raise SystemExit(
|
|
72
|
+
"Schema feature manifest differs; review all changed cases."
|
|
73
|
+
)
|
|
74
|
+
subprocess.run(
|
|
75
|
+
[
|
|
76
|
+
sys.executable,
|
|
77
|
+
"-m",
|
|
78
|
+
"pytest",
|
|
79
|
+
"tests/test_contract.py",
|
|
80
|
+
"tests/test_typing.py",
|
|
81
|
+
"tests/test_features.py",
|
|
82
|
+
"-q",
|
|
83
|
+
],
|
|
84
|
+
cwd=self.root,
|
|
85
|
+
check=True,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def skill_check(self) -> None:
|
|
89
|
+
"""Ensure canonical and installed skill files match exactly."""
|
|
90
|
+
canonical = self.root / "skills/ynab-api-cli"
|
|
91
|
+
bundled = self.root / "src/ynab_api_cli/resources/skill"
|
|
92
|
+
left = {
|
|
93
|
+
str(path.relative_to(canonical)): path.read_bytes()
|
|
94
|
+
for path in canonical.rglob("*")
|
|
95
|
+
if path.is_file()
|
|
96
|
+
}
|
|
97
|
+
right = {
|
|
98
|
+
str(path.relative_to(bundled)): path.read_bytes()
|
|
99
|
+
for path in bundled.rglob("*")
|
|
100
|
+
if path.is_file()
|
|
101
|
+
}
|
|
102
|
+
if not left or left != right:
|
|
103
|
+
raise SystemExit("Canonical and bundled skill resources differ.")
|
|
104
|
+
|
|
105
|
+
def compat_check(self) -> None:
|
|
106
|
+
"""Generate, type-check, and test a candidate without editing the checkout."""
|
|
107
|
+
checker = Compatibility()
|
|
108
|
+
latest = checker.fetch()
|
|
109
|
+
report = checker.compare(latest)
|
|
110
|
+
sys.stdout.write(json.dumps(report, indent=2) + "\n")
|
|
111
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
112
|
+
candidate = Path(temporary) / "candidate"
|
|
113
|
+
shutil.copytree(
|
|
114
|
+
self.root,
|
|
115
|
+
candidate,
|
|
116
|
+
ignore=shutil.ignore_patterns(
|
|
117
|
+
".git",
|
|
118
|
+
".venv",
|
|
119
|
+
".ruff_cache",
|
|
120
|
+
".pytest_cache",
|
|
121
|
+
"__pycache__",
|
|
122
|
+
"dist",
|
|
123
|
+
".hypothesis",
|
|
124
|
+
".coverage",
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
spec_path = candidate / "src/ynab_api_cli/resources/openapi.yaml"
|
|
128
|
+
spec_path.write_text(
|
|
129
|
+
yaml.safe_dump(latest, sort_keys=False), encoding="utf-8"
|
|
130
|
+
)
|
|
131
|
+
Development(candidate).generate(check=False)
|
|
132
|
+
subprocess.run( # noqa: S603 - fixed checker module; no shell.
|
|
133
|
+
[
|
|
134
|
+
sys.executable,
|
|
135
|
+
"-m",
|
|
136
|
+
"ty",
|
|
137
|
+
"check",
|
|
138
|
+
str(candidate),
|
|
139
|
+
"--python",
|
|
140
|
+
sys.executable,
|
|
141
|
+
],
|
|
142
|
+
cwd=candidate,
|
|
143
|
+
check=True,
|
|
144
|
+
)
|
|
145
|
+
environment = dict(os.environ)
|
|
146
|
+
environment["PYTHONPATH"] = str(candidate / "src")
|
|
147
|
+
subprocess.run(
|
|
148
|
+
[
|
|
149
|
+
sys.executable,
|
|
150
|
+
"-m",
|
|
151
|
+
"pytest",
|
|
152
|
+
"tests/test_contract.py",
|
|
153
|
+
"tests/test_compat.py",
|
|
154
|
+
"tests/test_typing.py",
|
|
155
|
+
"tests/test_features.py",
|
|
156
|
+
"-q",
|
|
157
|
+
],
|
|
158
|
+
cwd=candidate,
|
|
159
|
+
env=environment,
|
|
160
|
+
check=True,
|
|
161
|
+
)
|
|
162
|
+
if report["compatible"] is not True:
|
|
163
|
+
raise SystemExit(1)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
Verification.main()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: ynab-api-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Complete contract-driven REST CLI for YNAB
|
|
5
|
+
Author: Mike Hill
|
|
6
|
+
Requires-Dist: httpx>=0.28.1
|
|
7
|
+
Requires-Dist: jsonschema[format]>=4.26.0
|
|
8
|
+
Requires-Dist: pydantic>=2.14.1
|
|
9
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Project-URL: Homepage, https://github.com/mikejhill/ynab-api-cli
|
|
12
|
+
Project-URL: Repository, https://github.com/mikejhill/ynab-api-cli
|
|
13
|
+
Project-URL: Issues, https://github.com/mikejhill/ynab-api-cli/issues
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# YNAB API CLI
|
|
17
|
+
|
|
18
|
+
Complete, synchronous REST CLI for the reviewed YNAB API contract. The official SDK is not a dependency. Generated, strictly typed endpoint classes and models are included for Python callers.
|
|
19
|
+
|
|
20
|
+
## Install and connect
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
uv tool install ynab-api-cli
|
|
24
|
+
ynab-api-cli configure
|
|
25
|
+
ynab-api-cli doctor
|
|
26
|
+
ynab-api-cli plans get-plans
|
|
27
|
+
ynab-api-cli select-plan PLAN_UUID
|
|
28
|
+
ynab-api-cli doctor
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Generate your personal token in [YNAB Developer Settings](https://app.ynab.com/settings/developer), then enter it through the hidden terminal prompt. Do not put it in command arguments. Configuration is stored under `$XDG_CONFIG_HOME/ynab-api-cli`, falling back to `~/.config/ynab-api-cli` on all platforms. Credentials are plaintext and protected with private POSIX permissions or a current-user Windows ACL.
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
ynab-api-cli discover
|
|
35
|
+
ynab-api-cli discover transactions
|
|
36
|
+
ynab-api-cli inspect createTransaction
|
|
37
|
+
ynab-api-cli transactions get-transactions --since-date 2026-01-01
|
|
38
|
+
ynab-api-cli call getTransactions --request @request.json
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Uniform requests use `{"path":{},"query":{},"body":{}}`; omit body for operations without one. `--request -` and `--body -` read stdin. Endpoint flags use kebab-case names. Boolean flags take `true` or `false`.
|
|
42
|
+
|
|
43
|
+
Writes preview by default. Add `--execute` only for an intended mutation and verify its result through reads. Requests use TLS, a 30-second timeout, and no automatic retries or redirects. An uncertain write must be reconciled before retrying. Amounts are integer milliunits, and explicit null differs from omission.
|
|
44
|
+
|
|
45
|
+
`--metadata` adds status, selected headers, and contract version. Exit codes: 0 success, 1 configuration/API/compatibility failure, 2 usage error, and 130 interruption. `doctor --offline` does not validate API access. `configure --replace` explicitly rotates credentials while preserving unrelated fields.
|
|
46
|
+
|
|
47
|
+
## Typed Python interface
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from ynab_api_cli.generated.api.transactions.get_transactions import Endpoint
|
|
51
|
+
from ynab_api_cli.transport import Connection, Transport
|
|
52
|
+
|
|
53
|
+
# Load protected credentials through ConfigStore in real applications.
|
|
54
|
+
response = Endpoint.execute(transport=Transport(Connection(token)), plan_id=plan_id)
|
|
55
|
+
raw_json = response.raw
|
|
56
|
+
typed_result = response.parsed
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Typed endpoint execution directly submits mutations; preview guards belong to the CLI. The transport independently validates requests and responses against the bundled contract. Raw JSON remains available alongside typed parsing.
|
|
60
|
+
|
|
61
|
+
## Development and maintenance
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
uv sync --locked
|
|
65
|
+
uv run poe fix
|
|
66
|
+
uv run poe check
|
|
67
|
+
uv run poe codegen-check
|
|
68
|
+
uv run poe contract-check
|
|
69
|
+
uv run poe compat-check
|
|
70
|
+
uv build
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The contract snapshot, reviewed operation catalog, and provenance are packaged resources. Generator configuration lives in pyproject.toml; templates and deterministic transforms produce all generated code. Do not edit generated files manually.
|
|
74
|
+
|
|
75
|
+
See [maintenance](docs/maintenance.md) for reviewed upgrades, test coverage, and CI. See [live testing](docs/live-testing.md) before enabling writes against a dedicated test plan.
|
|
76
|
+
|
|
77
|
+
## Supplementary Codex skill
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
ynab-api-cli skill show
|
|
81
|
+
ynab-api-cli skill export /path/to/codex/skills/ynab-api-cli
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Export refuses an existing destination. The canonical skill is in skills/ynab-api-cli and is also included as installed package data. The CLI works independently of Codex.
|
|
85
|
+
|
|
86
|
+
For a source checkout, install with `uv tool install .` or run commands with `uv run ynab-api-cli`.
|
|
87
|
+
|
|
88
|
+
## Releases
|
|
89
|
+
|
|
90
|
+
The publishing pipeline follows VerdiClip's release-please and PyPI trusted-publishing pattern. See [publishing](docs/publishing.md) for the initial project setup, release process, and TestPyPI option.
|