sqlite-export-for-ynab 2.6.0__tar.gz → 2.7.1__tar.gz
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.
- {sqlite_export_for_ynab-2.6.0/sqlite_export_for_ynab.egg-info → sqlite_export_for_ynab-2.7.1}/PKG-INFO +9 -2
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/setup.cfg +12 -3
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab/_main.py +236 -180
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1/sqlite_export_for_ynab.egg-info}/PKG-INFO +9 -2
- sqlite_export_for_ynab-2.7.1/sqlite_export_for_ynab.egg-info/requires.txt +13 -0
- sqlite_export_for_ynab-2.7.1/testing/fixtures.py +620 -0
- sqlite_export_for_ynab-2.7.1/tests/_main_test.py +1094 -0
- sqlite_export_for_ynab-2.6.0/sqlite_export_for_ynab.egg-info/requires.txt +0 -5
- sqlite_export_for_ynab-2.6.0/testing/fixtures.py +0 -373
- sqlite_export_for_ynab-2.6.0/tests/_main_test.py +0 -920
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/LICENSE +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/README.md +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/pyproject.toml +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/setup.py +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab/__init__.py +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab/__main__.py +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab/ddl/__init__.py +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab/ddl/create-relations.sql +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab/ddl/drop-relations.sql +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab/py.typed +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab.egg-info/SOURCES.txt +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab.egg-info/dependency_links.txt +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab.egg-info/entry_points.txt +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab.egg-info/top_level.txt +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/testing/__init__.py +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlite_export_for_ynab
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.1
|
|
4
4
|
Summary: SQLite Export for YNAB - Export YNAB Data to SQLite
|
|
5
5
|
Home-page: https://github.com/mxr/sqlite-export-for-ynab
|
|
6
6
|
Author: Max R
|
|
@@ -17,8 +17,15 @@ License-File: LICENSE
|
|
|
17
17
|
Requires-Dist: aiohttp>=3
|
|
18
18
|
Requires-Dist: aiopathlib
|
|
19
19
|
Requires-Dist: aiosqlite
|
|
20
|
+
Requires-Dist: asyncio-for-ynab
|
|
20
21
|
Requires-Dist: fasteners
|
|
21
|
-
Requires-Dist: rich>=
|
|
22
|
+
Requires-Dist: rich>=12
|
|
23
|
+
Requires-Dist: tenacity
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: covdefaults>=2.1.0; extra == "dev"
|
|
26
|
+
Requires-Dist: coverage; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
22
29
|
Dynamic: license-file
|
|
23
30
|
|
|
24
31
|
# sqlite-export-for-ynab
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[metadata]
|
|
2
2
|
name = sqlite_export_for_ynab
|
|
3
|
-
version = 2.
|
|
3
|
+
version = 2.7.1
|
|
4
4
|
description = SQLite Export for YNAB - Export YNAB Data to SQLite
|
|
5
5
|
long_description = file: README.md
|
|
6
6
|
long_description_content_type = text/markdown
|
|
@@ -22,14 +22,23 @@ install_requires =
|
|
|
22
22
|
aiohttp>=3
|
|
23
23
|
aiopathlib
|
|
24
24
|
aiosqlite
|
|
25
|
+
asyncio-for-ynab
|
|
25
26
|
fasteners
|
|
26
|
-
rich>=
|
|
27
|
+
rich>=12
|
|
28
|
+
tenacity
|
|
27
29
|
python_requires = >=3.12
|
|
28
30
|
|
|
29
31
|
[options.entry_points]
|
|
30
32
|
console_scripts =
|
|
31
33
|
sqlite-export-for-ynab = sqlite_export_for_ynab._main:main
|
|
32
34
|
|
|
35
|
+
[options.extras_require]
|
|
36
|
+
dev =
|
|
37
|
+
covdefaults>=2.1.0
|
|
38
|
+
coverage
|
|
39
|
+
pytest
|
|
40
|
+
pytest-asyncio
|
|
41
|
+
|
|
33
42
|
[options.package_data]
|
|
34
43
|
* = *.sql
|
|
35
44
|
sqlite_export_for_ynab =
|
|
@@ -45,7 +54,7 @@ plugins = covdefaults
|
|
|
45
54
|
envlist = py,pypy3,pre-commit
|
|
46
55
|
|
|
47
56
|
[testenv]
|
|
48
|
-
|
|
57
|
+
extras = dev
|
|
49
58
|
commands =
|
|
50
59
|
coverage erase
|
|
51
60
|
coverage run -m pytest {posargs:tests}
|
{sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab/_main.py
RENAMED
|
@@ -2,40 +2,78 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import asyncio
|
|
5
|
-
import json
|
|
6
5
|
import os
|
|
7
6
|
from contextlib import asynccontextmanager
|
|
8
7
|
from contextlib import contextmanager
|
|
9
8
|
from dataclasses import dataclass
|
|
9
|
+
from dataclasses import fields
|
|
10
10
|
from importlib import resources
|
|
11
11
|
from importlib.metadata import version
|
|
12
12
|
from itertools import batched
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import Any
|
|
15
|
-
from typing import ClassVar
|
|
16
15
|
from typing import Literal
|
|
17
16
|
from typing import overload
|
|
18
|
-
from typing import Protocol
|
|
19
17
|
from typing import TYPE_CHECKING
|
|
20
|
-
from urllib.parse import urlencode
|
|
21
|
-
from urllib.parse import urljoin
|
|
22
|
-
from urllib.parse import urlunparse
|
|
23
18
|
|
|
24
|
-
import aiohttp
|
|
25
19
|
import aiosqlite
|
|
20
|
+
import asyncio_for_ynab # noqa: F401
|
|
26
21
|
import fasteners
|
|
27
22
|
from aiopathlib import AsyncPath
|
|
23
|
+
from asyncio_for_ynab import Account
|
|
24
|
+
from asyncio_for_ynab import AccountsApi
|
|
25
|
+
from asyncio_for_ynab import ApiClient
|
|
26
|
+
from asyncio_for_ynab import CategoriesApi
|
|
27
|
+
from asyncio_for_ynab import CategoryGroupWithCategories
|
|
28
|
+
from asyncio_for_ynab import Configuration
|
|
29
|
+
from asyncio_for_ynab import Payee
|
|
30
|
+
from asyncio_for_ynab import PayeesApi
|
|
31
|
+
from asyncio_for_ynab import PlansApi
|
|
32
|
+
from asyncio_for_ynab import PlanSummary
|
|
33
|
+
from asyncio_for_ynab import ScheduledTransactionDetail
|
|
34
|
+
from asyncio_for_ynab import ScheduledTransactionsApi
|
|
35
|
+
from asyncio_for_ynab import TransactionDetail
|
|
36
|
+
from asyncio_for_ynab import TransactionsApi
|
|
28
37
|
from rich.progress import BarColumn
|
|
29
|
-
from rich.progress import MofNCompleteColumn
|
|
30
38
|
from rich.progress import Progress
|
|
31
39
|
from rich.progress import TaskID
|
|
32
40
|
from rich.progress import TextColumn
|
|
33
41
|
from rich.progress import TimeElapsedColumn
|
|
42
|
+
from tenacity import retry
|
|
43
|
+
from tenacity import stop_after_attempt
|
|
34
44
|
|
|
35
45
|
from sqlite_export_for_ynab import ddl
|
|
36
46
|
|
|
37
47
|
if TYPE_CHECKING:
|
|
38
|
-
from collections.abc import AsyncIterator
|
|
48
|
+
from collections.abc import AsyncIterator
|
|
49
|
+
from collections.abc import Iterator
|
|
50
|
+
from collections.abc import Sequence
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
from rich.progress import MofNCompleteColumn
|
|
54
|
+
# https://github.com/benleb/surepy/issues/240
|
|
55
|
+
except ImportError: # pragma: no cover
|
|
56
|
+
from rich.progress import ProgressColumn
|
|
57
|
+
from rich.progress import Task
|
|
58
|
+
from rich.text import Text
|
|
59
|
+
|
|
60
|
+
if TYPE_CHECKING:
|
|
61
|
+
from rich.table import Column
|
|
62
|
+
|
|
63
|
+
class MofNCompleteColumn(ProgressColumn): # type:ignore[no-redef]
|
|
64
|
+
def __init__(self, separator: str = "/", table_column: Column | None = None):
|
|
65
|
+
self.separator = separator
|
|
66
|
+
super().__init__(table_column=table_column)
|
|
67
|
+
|
|
68
|
+
def render(self, task: Task) -> Text:
|
|
69
|
+
"""Show completed/total."""
|
|
70
|
+
completed = int(task.completed)
|
|
71
|
+
total = int(task.total) if task.total is not None else "?"
|
|
72
|
+
total_width = len(str(total))
|
|
73
|
+
return Text(
|
|
74
|
+
f"{completed:{total_width}d}{self.separator}{total}",
|
|
75
|
+
style="progress.download",
|
|
76
|
+
)
|
|
39
77
|
|
|
40
78
|
|
|
41
79
|
_EntryTable = (
|
|
@@ -83,7 +121,8 @@ def resolve_token(token_override: str | None = None) -> str:
|
|
|
83
121
|
return token
|
|
84
122
|
|
|
85
123
|
raise ValueError(
|
|
86
|
-
f"Must set YNAB access token as {_ENV_TOKEN!r} environment variable or pass
|
|
124
|
+
f"Must set YNAB access token as {_ENV_TOKEN!r} environment variable or pass "
|
|
125
|
+
"token_override directly. See https://api.ynab.com/#personal-access-tokens"
|
|
87
126
|
)
|
|
88
127
|
|
|
89
128
|
|
|
@@ -142,20 +181,44 @@ def _print(message: str, *, quiet: bool) -> None:
|
|
|
142
181
|
|
|
143
182
|
@dataclass
|
|
144
183
|
class _Context:
|
|
145
|
-
session: aiohttp.ClientSession
|
|
146
184
|
progress: Progress
|
|
147
185
|
con: aiosqlite.Connection
|
|
148
186
|
lock: fasteners.InterProcessLock
|
|
187
|
+
api_client: ApiClient
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass
|
|
191
|
+
class _YnabPlanData:
|
|
192
|
+
accounts: list[Account]
|
|
193
|
+
category_groups: list[CategoryGroupWithCategories]
|
|
194
|
+
payees: list[Payee]
|
|
195
|
+
transactions: list[TransactionDetail]
|
|
196
|
+
server_knowledge: int
|
|
197
|
+
scheduled_transactions: list[ScheduledTransactionDetail]
|
|
198
|
+
|
|
199
|
+
def has_data(self) -> bool:
|
|
200
|
+
return any(
|
|
201
|
+
getattr(self, field.name)
|
|
202
|
+
for field in fields(self)
|
|
203
|
+
if field.name != "server_knowledge"
|
|
204
|
+
)
|
|
149
205
|
|
|
150
206
|
|
|
151
207
|
@asynccontextmanager
|
|
152
208
|
async def _context(
|
|
153
|
-
db: Path,
|
|
209
|
+
db: Path,
|
|
210
|
+
configuration: Configuration,
|
|
211
|
+
*,
|
|
212
|
+
quiet: bool,
|
|
213
|
+
timeout: float = _SYNC_LOCK_TIMEOUT,
|
|
154
214
|
) -> AsyncIterator[_Context]:
|
|
155
215
|
progress = Progress(*_PROGRESS_COLUMNS, disable=quiet)
|
|
156
216
|
lock_path = db.parent / f"{db.name}.lock"
|
|
157
217
|
lock = fasteners.InterProcessLock(lock_path)
|
|
158
|
-
async with
|
|
218
|
+
async with (
|
|
219
|
+
ApiClient(configuration) as api_client,
|
|
220
|
+
aiosqlite.connect(db) as con,
|
|
221
|
+
):
|
|
159
222
|
con.row_factory = aiosqlite.Row
|
|
160
223
|
loop = asyncio.get_running_loop()
|
|
161
224
|
deadline = loop.time() + timeout
|
|
@@ -172,7 +235,7 @@ async def _context(
|
|
|
172
235
|
await asyncio.sleep(0.1)
|
|
173
236
|
|
|
174
237
|
try:
|
|
175
|
-
yield _Context(
|
|
238
|
+
yield _Context(progress, con, lock, api_client)
|
|
176
239
|
finally:
|
|
177
240
|
try:
|
|
178
241
|
await asyncio.to_thread(lock.release)
|
|
@@ -196,10 +259,11 @@ async def sync(
|
|
|
196
259
|
) -> None:
|
|
197
260
|
await AsyncPath(db).parent.mkdir(parents=True, exist_ok=True)
|
|
198
261
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
262
|
+
configuration = Configuration(access_token=token)
|
|
263
|
+
async with _context(
|
|
264
|
+
db, configuration, quiet=quiet, timeout=_SYNC_LOCK_TIMEOUT
|
|
265
|
+
) as context:
|
|
266
|
+
plans = await _get_plan_summaries(context.api_client)
|
|
203
267
|
|
|
204
268
|
if full_refresh:
|
|
205
269
|
_print("Dropping relations...", quiet=quiet)
|
|
@@ -220,63 +284,24 @@ async def sync(
|
|
|
220
284
|
async with context.con.cursor() as cur:
|
|
221
285
|
lkos = await get_last_knowledge_of_server(cur)
|
|
222
286
|
with _progress(context):
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
context,
|
|
226
|
-
context.progress.add_task(
|
|
227
|
-
"Plan Data", total=len(plans) * len(_ENDPOINTS)
|
|
228
|
-
),
|
|
287
|
+
task = context.progress.add_task(
|
|
288
|
+
"Plan Data", total=len(plans) * len(_ENDPOINTS)
|
|
229
289
|
)
|
|
230
290
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
_ENDPOINTS,
|
|
234
|
-
await asyncio.gather(
|
|
235
|
-
*(
|
|
236
|
-
asyncio.gather(*jobs(yc, endpoint, plan_ids, lkos))
|
|
237
|
-
for endpoint in _ENDPOINTS
|
|
238
|
-
)
|
|
239
|
-
),
|
|
240
|
-
strict=True,
|
|
241
|
-
)
|
|
291
|
+
all_data = await _get_all_ynab(
|
|
292
|
+
context, [str(plan.id) for plan in plans], lkos, task
|
|
242
293
|
)
|
|
243
294
|
|
|
244
|
-
all_account_data = endpoint_data["accounts"]
|
|
245
|
-
all_cat_data = endpoint_data["categories"]
|
|
246
|
-
all_payee_data = endpoint_data["payees"]
|
|
247
|
-
all_txn_data = endpoint_data["transactions"]
|
|
248
|
-
all_sched_txn_data = endpoint_data["scheduled_transactions"]
|
|
249
|
-
|
|
250
|
-
new_lkos = {
|
|
251
|
-
plan_id: transaction_data["server_knowledge"]
|
|
252
|
-
for plan_id, transaction_data in zip(plan_ids, all_txn_data, strict=True)
|
|
253
|
-
}
|
|
254
295
|
_print("Done", quiet=quiet)
|
|
255
296
|
|
|
256
|
-
if (
|
|
257
|
-
not any(t["accounts"] for t in all_account_data)
|
|
258
|
-
and not any(t["category_groups"] for t in all_cat_data)
|
|
259
|
-
and not any(p["payees"] for p in all_payee_data)
|
|
260
|
-
and not any(t["transactions"] for t in all_txn_data)
|
|
261
|
-
and not any(s["scheduled_transactions"] for s in all_sched_txn_data)
|
|
262
|
-
):
|
|
263
|
-
_print("No new data fetched", quiet=quiet)
|
|
264
|
-
else:
|
|
297
|
+
if any(plan_data.has_data() for plan_data in all_data.values()):
|
|
265
298
|
_print("Inserting plan data...", quiet=quiet)
|
|
266
299
|
with _progress(context):
|
|
267
|
-
await insert_plan_data(
|
|
268
|
-
context,
|
|
269
|
-
plans,
|
|
270
|
-
plan_ids,
|
|
271
|
-
all_account_data,
|
|
272
|
-
all_cat_data,
|
|
273
|
-
all_payee_data,
|
|
274
|
-
all_txn_data,
|
|
275
|
-
all_sched_txn_data,
|
|
276
|
-
new_lkos,
|
|
277
|
-
)
|
|
300
|
+
await insert_plan_data(context, plans, all_data)
|
|
278
301
|
await context.con.commit()
|
|
279
302
|
_print("Done", quiet=quiet)
|
|
303
|
+
else:
|
|
304
|
+
_print("No new data fetched", quiet=quiet)
|
|
280
305
|
|
|
281
306
|
|
|
282
307
|
async def contents(filename: str) -> str:
|
|
@@ -298,49 +323,40 @@ async def get_last_knowledge_of_server(cur: aiosqlite.Cursor) -> dict[str, int]:
|
|
|
298
323
|
|
|
299
324
|
|
|
300
325
|
async def insert_plan_data(
|
|
301
|
-
context: _Context,
|
|
302
|
-
plans: list[dict[str, Any]],
|
|
303
|
-
plan_ids: list[str],
|
|
304
|
-
all_account_data: list[dict[str, Any]],
|
|
305
|
-
all_cat_data: list[dict[str, Any]],
|
|
306
|
-
all_payee_data: list[dict[str, Any]],
|
|
307
|
-
all_txn_data: list[dict[str, Any]],
|
|
308
|
-
all_sched_txn_data: list[dict[str, Any]],
|
|
309
|
-
new_lkos: dict[str, int],
|
|
326
|
+
context: _Context, plans: list[PlanSummary], all_data: dict[str, _YnabPlanData]
|
|
310
327
|
) -> None:
|
|
311
|
-
await insert_plans(
|
|
328
|
+
await insert_plans(
|
|
329
|
+
context,
|
|
330
|
+
plans,
|
|
331
|
+
{plan_id: d.server_knowledge for plan_id, d in all_data.items()},
|
|
332
|
+
)
|
|
312
333
|
await asyncio.gather(
|
|
313
334
|
*(
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
insert_payees(context, plan_id, payee_data["payees"])
|
|
323
|
-
for plan_id, payee_data in zip(plan_ids, all_payee_data, strict=True)
|
|
335
|
+
asyncio.gather(
|
|
336
|
+
insert_accounts(context, plan_id, all_data[plan_id].accounts),
|
|
337
|
+
insert_category_groups(
|
|
338
|
+
context, plan_id, all_data[plan_id].category_groups
|
|
339
|
+
),
|
|
340
|
+
insert_payees(context, plan_id, all_data[plan_id].payees),
|
|
341
|
+
)
|
|
342
|
+
for plan_id in all_data
|
|
324
343
|
),
|
|
325
344
|
)
|
|
326
345
|
await asyncio.gather(
|
|
327
346
|
*(
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
context, plan_id, sched_txn_data["scheduled_transactions"]
|
|
334
|
-
)
|
|
335
|
-
for plan_id, sched_txn_data in zip(
|
|
336
|
-
plan_ids, all_sched_txn_data, strict=True
|
|
347
|
+
asyncio.gather(
|
|
348
|
+
insert_transactions(context, plan_id, all_data[plan_id].transactions),
|
|
349
|
+
insert_scheduled_transactions(
|
|
350
|
+
context, plan_id, all_data[plan_id].scheduled_transactions
|
|
351
|
+
),
|
|
337
352
|
)
|
|
353
|
+
for plan_id in all_data
|
|
338
354
|
),
|
|
339
355
|
)
|
|
340
356
|
|
|
341
357
|
|
|
342
358
|
async def insert_plans(
|
|
343
|
-
context: _Context, plans: list[
|
|
359
|
+
context: _Context, plans: list[PlanSummary], lkos: dict[str, int]
|
|
344
360
|
) -> None:
|
|
345
361
|
async with context.con.cursor() as cur:
|
|
346
362
|
for plan_batch in batched(plans, _BATCH_SIZE):
|
|
@@ -361,15 +377,15 @@ async def insert_plans(
|
|
|
361
377
|
""",
|
|
362
378
|
(
|
|
363
379
|
(
|
|
364
|
-
plan_id := plan
|
|
365
|
-
plan
|
|
366
|
-
plan
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
380
|
+
plan_id := str(plan.id),
|
|
381
|
+
plan.name,
|
|
382
|
+
getattr(cf := plan.currency_format, "currency_symbol", None),
|
|
383
|
+
getattr(cf, "decimal_digits", None),
|
|
384
|
+
getattr(cf, "decimal_separator", None),
|
|
385
|
+
getattr(cf, "display_symbol", None),
|
|
386
|
+
getattr(cf, "group_separator", None),
|
|
387
|
+
getattr(cf, "iso_code", None),
|
|
388
|
+
getattr(cf, "symbol_first", None),
|
|
373
389
|
lkos[plan_id],
|
|
374
390
|
)
|
|
375
391
|
for plan in plan_batch
|
|
@@ -385,7 +401,7 @@ _LOAN_ACCOUNT_PERIODIC_VALUES = frozenset(
|
|
|
385
401
|
async def insert_accounts(
|
|
386
402
|
context: _Context,
|
|
387
403
|
plan_id: str,
|
|
388
|
-
accounts: list[
|
|
404
|
+
accounts: list[Account],
|
|
389
405
|
) -> None:
|
|
390
406
|
# YNAB's LoanAccountPeriodValues are untyped dicts so we need to turn them into a more standard sub-entry view
|
|
391
407
|
updated_accounts = [
|
|
@@ -402,7 +418,7 @@ async def insert_accounts(
|
|
|
402
418
|
]
|
|
403
419
|
}
|
|
404
420
|
| {k: v for k, v in account.items() if k not in _LOAN_ACCOUNT_PERIODIC_VALUES}
|
|
405
|
-
for account in accounts
|
|
421
|
+
for account in (acc.model_dump(mode="json") for acc in accounts)
|
|
406
422
|
]
|
|
407
423
|
|
|
408
424
|
await insert_nested_entries(
|
|
@@ -419,12 +435,12 @@ async def insert_accounts(
|
|
|
419
435
|
async def insert_category_groups(
|
|
420
436
|
context: _Context,
|
|
421
437
|
plan_id: str,
|
|
422
|
-
category_groups: list[
|
|
438
|
+
category_groups: list[CategoryGroupWithCategories],
|
|
423
439
|
) -> None:
|
|
424
440
|
await insert_nested_entries(
|
|
425
441
|
context,
|
|
426
442
|
plan_id,
|
|
427
|
-
category_groups,
|
|
443
|
+
[cg.model_dump(mode="json") for cg in category_groups],
|
|
428
444
|
"Categories",
|
|
429
445
|
"category_groups",
|
|
430
446
|
"categories",
|
|
@@ -435,24 +451,27 @@ async def insert_category_groups(
|
|
|
435
451
|
async def insert_payees(
|
|
436
452
|
context: _Context,
|
|
437
453
|
plan_id: str,
|
|
438
|
-
payees: list[
|
|
454
|
+
payees: list[Payee],
|
|
439
455
|
) -> None:
|
|
440
456
|
if not payees:
|
|
441
457
|
return
|
|
442
458
|
|
|
443
459
|
task_id = context.progress.add_task("Payees", total=len(payees))
|
|
444
|
-
await insert_entries(
|
|
460
|
+
await insert_entries(
|
|
461
|
+
context, "payees", plan_id, [p.model_dump(mode="json") for p in payees], task_id
|
|
462
|
+
)
|
|
445
463
|
|
|
446
464
|
|
|
447
465
|
async def insert_transactions(
|
|
448
466
|
context: _Context,
|
|
449
467
|
plan_id: str,
|
|
450
|
-
transactions: list[
|
|
468
|
+
transactions: list[TransactionDetail],
|
|
451
469
|
) -> None:
|
|
452
470
|
await insert_nested_entries(
|
|
453
471
|
context,
|
|
454
472
|
plan_id,
|
|
455
|
-
|
|
473
|
+
# by_alias=True properly renames 'var_date' to 'date'
|
|
474
|
+
[t.model_dump(mode="json", by_alias=True) for t in transactions],
|
|
456
475
|
"Transactions",
|
|
457
476
|
"transactions",
|
|
458
477
|
"subtransactions",
|
|
@@ -463,12 +482,12 @@ async def insert_transactions(
|
|
|
463
482
|
async def insert_scheduled_transactions(
|
|
464
483
|
context: _Context,
|
|
465
484
|
plan_id: str,
|
|
466
|
-
scheduled_transactions: list[
|
|
485
|
+
scheduled_transactions: list[ScheduledTransactionDetail],
|
|
467
486
|
) -> None:
|
|
468
487
|
await insert_nested_entries(
|
|
469
488
|
context,
|
|
470
489
|
plan_id,
|
|
471
|
-
scheduled_transactions,
|
|
490
|
+
[st.model_dump(mode="json") for st in scheduled_transactions],
|
|
472
491
|
"Scheduled Transactions",
|
|
473
492
|
"scheduled_transactions",
|
|
474
493
|
"subtransactions",
|
|
@@ -597,81 +616,118 @@ async def insert_entries(
|
|
|
597
616
|
context.progress.update(task_id, advance=len(values_batch))
|
|
598
617
|
|
|
599
618
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
plan_ids: list[str],
|
|
604
|
-
lkos: dict[str, int],
|
|
605
|
-
) -> list[Awaitable[dict[str, Any]]]:
|
|
606
|
-
return [
|
|
607
|
-
yc(f"plans/{plan_id}/{endpoint}", last_knowledge_of_server=lkos.get(plan_id))
|
|
608
|
-
for plan_id in plan_ids
|
|
609
|
-
]
|
|
619
|
+
@retry(stop=stop_after_attempt(3))
|
|
620
|
+
async def _get_plan_summaries(api_client: ApiClient) -> list[PlanSummary]:
|
|
621
|
+
return (await PlansApi(api_client).get_plans()).data.plans
|
|
610
622
|
|
|
611
623
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
624
|
+
async def _get_all_ynab(
|
|
625
|
+
context: _Context, plan_ids: list[str], lkos: dict[str, int], task_id: TaskID
|
|
626
|
+
) -> dict[str, _YnabPlanData]:
|
|
627
|
+
return dict(
|
|
628
|
+
await asyncio.gather(
|
|
629
|
+
*(_get_plan_data(context, plan_id, lkos, task_id) for plan_id in plan_ids)
|
|
630
|
+
)
|
|
631
|
+
)
|
|
616
632
|
|
|
617
633
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
634
|
+
async def _get_plan_data(
|
|
635
|
+
context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
|
|
636
|
+
) -> tuple[str, _YnabPlanData]:
|
|
637
|
+
(
|
|
638
|
+
accounts,
|
|
639
|
+
categories,
|
|
640
|
+
payees,
|
|
641
|
+
transactions_serverknowledge,
|
|
642
|
+
scheduled_transactions,
|
|
643
|
+
) = await asyncio.gather(
|
|
644
|
+
_get_accounts(context, plan_id, lkos, task_id),
|
|
645
|
+
_get_categories(context, plan_id, lkos, task_id),
|
|
646
|
+
_get_payees(context, plan_id, lkos, task_id),
|
|
647
|
+
_get_transactions(context, plan_id, lkos, task_id),
|
|
648
|
+
_get_scheduled_transactions(context, plan_id, lkos, task_id),
|
|
649
|
+
)
|
|
650
|
+
transactions, server_knowledge = transactions_serverknowledge
|
|
651
|
+
return (
|
|
652
|
+
plan_id,
|
|
653
|
+
_YnabPlanData(
|
|
654
|
+
accounts=accounts,
|
|
655
|
+
category_groups=categories,
|
|
656
|
+
payees=payees,
|
|
657
|
+
transactions=transactions,
|
|
658
|
+
server_knowledge=server_knowledge,
|
|
659
|
+
scheduled_transactions=scheduled_transactions,
|
|
660
|
+
),
|
|
661
|
+
)
|
|
631
662
|
|
|
632
663
|
|
|
633
|
-
@
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
async def __call__(
|
|
643
|
-
self, path: str, last_knowledge_of_server: int | None = None
|
|
644
|
-
) -> dict[str, Any]:
|
|
645
|
-
headers = {
|
|
646
|
-
"Authorization": f"Bearer {self.token}",
|
|
647
|
-
"Content-Type": "application/json",
|
|
648
|
-
}
|
|
649
|
-
url = urlunparse(
|
|
650
|
-
(
|
|
651
|
-
self.BASE_SCHEME,
|
|
652
|
-
self.BASE_NETLOC,
|
|
653
|
-
urljoin(self.BASE_PATH, path),
|
|
654
|
-
"",
|
|
655
|
-
urlencode(
|
|
656
|
-
{"last_knowledge_of_server": last_knowledge_of_server}
|
|
657
|
-
if last_knowledge_of_server
|
|
658
|
-
else {}
|
|
659
|
-
),
|
|
660
|
-
"",
|
|
661
|
-
)
|
|
662
|
-
)
|
|
664
|
+
@retry(stop=stop_after_attempt(3))
|
|
665
|
+
async def _get_accounts(
|
|
666
|
+
context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
|
|
667
|
+
) -> list[Account]:
|
|
668
|
+
resp = await AccountsApi(context.api_client).get_accounts(
|
|
669
|
+
plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
|
|
670
|
+
)
|
|
671
|
+
context.progress.update(task_id, advance=1)
|
|
672
|
+
return resp.data.accounts
|
|
663
673
|
|
|
664
|
-
for i in range(3):
|
|
665
|
-
try:
|
|
666
|
-
async with self.session.get(url, headers=headers) as resp:
|
|
667
|
-
body = await resp.text()
|
|
668
674
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
675
|
+
@retry(stop=stop_after_attempt(3))
|
|
676
|
+
async def _get_categories(
|
|
677
|
+
context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
|
|
678
|
+
) -> list[CategoryGroupWithCategories]:
|
|
679
|
+
resp = await CategoriesApi(context.api_client).get_categories(
|
|
680
|
+
plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
|
|
681
|
+
)
|
|
682
|
+
context.progress.update(task_id, advance=1)
|
|
683
|
+
return resp.data.category_groups
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
@retry(stop=stop_after_attempt(3))
|
|
687
|
+
async def _get_payees(
|
|
688
|
+
context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
|
|
689
|
+
) -> list[Payee]:
|
|
690
|
+
resp = await PayeesApi(context.api_client).get_payees(
|
|
691
|
+
plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
|
|
692
|
+
)
|
|
693
|
+
context.progress.update(task_id, advance=1)
|
|
694
|
+
return resp.data.payees
|
|
673
695
|
|
|
674
|
-
|
|
696
|
+
|
|
697
|
+
@retry(stop=stop_after_attempt(3))
|
|
698
|
+
async def _get_transactions(
|
|
699
|
+
context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
|
|
700
|
+
) -> tuple[list[TransactionDetail], int]:
|
|
701
|
+
resp = await TransactionsApi(context.api_client).get_transactions(
|
|
702
|
+
plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
|
|
703
|
+
)
|
|
704
|
+
context.progress.update(task_id, advance=1)
|
|
705
|
+
return resp.data.transactions, resp.data.server_knowledge
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
@retry(stop=stop_after_attempt(3))
|
|
709
|
+
async def _get_scheduled_transactions(
|
|
710
|
+
context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
|
|
711
|
+
) -> list[ScheduledTransactionDetail]:
|
|
712
|
+
resp = await ScheduledTransactionsApi(
|
|
713
|
+
context.api_client
|
|
714
|
+
).get_scheduled_transactions(
|
|
715
|
+
plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
|
|
716
|
+
)
|
|
717
|
+
context.progress.update(task_id, advance=1)
|
|
718
|
+
return resp.data.scheduled_transactions
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
# @retry(stop=stop_after_attempt(3))
|
|
722
|
+
# async def _get_ynab[T](
|
|
723
|
+
# context: _Context,
|
|
724
|
+
# getter: Callable[..., Awaitable[T]],
|
|
725
|
+
# task_id: TaskID,
|
|
726
|
+
# ) -> T:
|
|
727
|
+
# try:
|
|
728
|
+
# return await getter()
|
|
729
|
+
# finally:
|
|
730
|
+
# context.progress.update(task_id, advance=1)
|
|
675
731
|
|
|
676
732
|
|
|
677
733
|
def main(
|