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