sqlite-export-for-ynab 2.9.1__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.
- sqlite_export_for_ynab/__init__.py +6 -0
- sqlite_export_for_ynab/__main__.py +6 -0
- sqlite_export_for_ynab/_main.py +687 -0
- sqlite_export_for_ynab/ddl/__init__.py +0 -0
- sqlite_export_for_ynab/ddl/create-relations.sql +334 -0
- sqlite_export_for_ynab/ddl/drop-relations.sql +23 -0
- sqlite_export_for_ynab/py.typed +0 -0
- sqlite_export_for_ynab-2.9.1.dist-info/METADATA +594 -0
- sqlite_export_for_ynab-2.9.1.dist-info/RECORD +17 -0
- sqlite_export_for_ynab-2.9.1.dist-info/WHEEL +5 -0
- sqlite_export_for_ynab-2.9.1.dist-info/entry_points.txt +2 -0
- sqlite_export_for_ynab-2.9.1.dist-info/licenses/LICENSE +21 -0
- sqlite_export_for_ynab-2.9.1.dist-info/top_level.txt +3 -0
- testing/__init__.py +0 -0
- testing/fixtures.py +626 -0
- tests/__init__.py +0 -0
- tests/_main_test.py +1125 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from dataclasses import fields
|
|
10
|
+
from importlib import resources
|
|
11
|
+
from importlib.metadata import version
|
|
12
|
+
from itertools import batched
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
from typing import Literal
|
|
16
|
+
from typing import overload
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
import aiosqlite
|
|
20
|
+
import asyncio_for_ynab # noqa: F401
|
|
21
|
+
import fasteners
|
|
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
|
|
37
|
+
from rich.progress import BarColumn
|
|
38
|
+
from rich.progress import Progress
|
|
39
|
+
from rich.progress import TaskID
|
|
40
|
+
from rich.progress import TextColumn
|
|
41
|
+
from rich.progress import TimeElapsedColumn
|
|
42
|
+
from tenacity import retry
|
|
43
|
+
from tenacity import stop_after_attempt
|
|
44
|
+
|
|
45
|
+
from sqlite_export_for_ynab import ddl
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from collections.abc import AsyncIterator
|
|
49
|
+
from collections.abc import Awaitable
|
|
50
|
+
from collections.abc import Callable
|
|
51
|
+
from collections.abc import Iterator
|
|
52
|
+
from collections.abc import Sequence
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
from rich.progress import MofNCompleteColumn
|
|
56
|
+
# https://github.com/benleb/surepy/issues/240
|
|
57
|
+
except ImportError: # pragma: no cover
|
|
58
|
+
from rich.progress import ProgressColumn
|
|
59
|
+
from rich.progress import Task
|
|
60
|
+
from rich.text import Text
|
|
61
|
+
|
|
62
|
+
if TYPE_CHECKING:
|
|
63
|
+
from rich.table import Column
|
|
64
|
+
|
|
65
|
+
class MofNCompleteColumn(ProgressColumn): # type:ignore[no-redef]
|
|
66
|
+
def __init__(self, separator: str = "/", table_column: Column | None = None):
|
|
67
|
+
self.separator = separator
|
|
68
|
+
super().__init__(table_column=table_column)
|
|
69
|
+
|
|
70
|
+
def render(self, task: Task) -> Text:
|
|
71
|
+
"""Show completed/total."""
|
|
72
|
+
completed = int(task.completed)
|
|
73
|
+
total = int(task.total) if task.total is not None else "?"
|
|
74
|
+
total_width = len(str(total))
|
|
75
|
+
return Text(
|
|
76
|
+
f"{completed:{total_width}d}{self.separator}{total}",
|
|
77
|
+
style="progress.download",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_EntryTable = (
|
|
82
|
+
Literal["accounts"]
|
|
83
|
+
| Literal["account_periodic_values"]
|
|
84
|
+
| Literal["category_groups"]
|
|
85
|
+
| Literal["categories"]
|
|
86
|
+
| Literal["payees"]
|
|
87
|
+
| Literal["transactions"]
|
|
88
|
+
| Literal["subtransactions"]
|
|
89
|
+
| Literal["scheduled_transactions"]
|
|
90
|
+
| Literal["scheduled_subtransactions"]
|
|
91
|
+
)
|
|
92
|
+
_Endpoint = (
|
|
93
|
+
Literal["accounts"]
|
|
94
|
+
| Literal["categories"]
|
|
95
|
+
| Literal["payees"]
|
|
96
|
+
| Literal["transactions"]
|
|
97
|
+
| Literal["scheduled_transactions"]
|
|
98
|
+
)
|
|
99
|
+
_ENDPOINTS = tuple(lit.__args__[0] for lit in _Endpoint.__args__)
|
|
100
|
+
_ALL_RELATIONS = frozenset(
|
|
101
|
+
("plans", "flat_transactions", "scheduled_flat_transactions")
|
|
102
|
+
+ tuple(lit.__args__[0] for lit in _EntryTable.__args__)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
_ENV_TOKEN = "YNAB_PERSONAL_ACCESS_TOKEN"
|
|
106
|
+
|
|
107
|
+
_PACKAGE = "sqlite-export-for-ynab"
|
|
108
|
+
|
|
109
|
+
_BATCH_SIZE = 100
|
|
110
|
+
_SYNC_LOCK_TIMEOUT = 30.0
|
|
111
|
+
|
|
112
|
+
_PROGRESS_COLUMNS = (
|
|
113
|
+
TextColumn("[progress.description]{task.description}"),
|
|
114
|
+
BarColumn(),
|
|
115
|
+
MofNCompleteColumn(),
|
|
116
|
+
TimeElapsedColumn(),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def resolve_token(token_override: str | None = None) -> str:
|
|
121
|
+
token = token_override or os.environ.get(_ENV_TOKEN)
|
|
122
|
+
if token:
|
|
123
|
+
return token
|
|
124
|
+
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"Must set YNAB access token as {_ENV_TOKEN!r} environment variable or pass "
|
|
127
|
+
"token_override directly. See https://api.ynab.com/#personal-access-tokens"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def async_main(
|
|
132
|
+
argv: Sequence[str] | None = None, *, token_override: str | None = None
|
|
133
|
+
) -> int:
|
|
134
|
+
parser = argparse.ArgumentParser(prog=_PACKAGE)
|
|
135
|
+
parser.add_argument(
|
|
136
|
+
"--db",
|
|
137
|
+
help="The path to the SQLite database file.",
|
|
138
|
+
type=Path,
|
|
139
|
+
default=default_db_path(),
|
|
140
|
+
)
|
|
141
|
+
parser.add_argument(
|
|
142
|
+
"--full-refresh",
|
|
143
|
+
action="store_true",
|
|
144
|
+
help="**DROP ALL TABLES** and fetch all data again.",
|
|
145
|
+
)
|
|
146
|
+
parser.add_argument(
|
|
147
|
+
"--version", action="version", version=f"%(prog)s {version(_PACKAGE)}"
|
|
148
|
+
)
|
|
149
|
+
parser.add_argument(
|
|
150
|
+
"--quiet",
|
|
151
|
+
action="store_true",
|
|
152
|
+
help="Suppress all CLI output, including progress bars.",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
args = parser.parse_args(argv)
|
|
156
|
+
db: Path = args.db
|
|
157
|
+
full_refresh: bool = args.full_refresh
|
|
158
|
+
quiet: bool = args.quiet
|
|
159
|
+
|
|
160
|
+
token = resolve_token(token_override)
|
|
161
|
+
|
|
162
|
+
await sync(token, db, full_refresh, quiet=quiet)
|
|
163
|
+
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def default_db_path() -> Path:
|
|
168
|
+
return (
|
|
169
|
+
(
|
|
170
|
+
Path(xdg_data_home)
|
|
171
|
+
if (xdg_data_home := os.environ.get("XDG_DATA_HOME"))
|
|
172
|
+
else Path.home() / ".local" / "share"
|
|
173
|
+
)
|
|
174
|
+
/ _PACKAGE
|
|
175
|
+
/ "db.sqlite"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _print(message: str, *, quiet: bool) -> None:
|
|
180
|
+
if not quiet:
|
|
181
|
+
print(message)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class _Context:
|
|
186
|
+
progress: Progress
|
|
187
|
+
con: aiosqlite.Connection
|
|
188
|
+
lock: fasteners.InterProcessLock
|
|
189
|
+
api_client: ApiClient
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass
|
|
193
|
+
class _YnabPlanData:
|
|
194
|
+
accounts: list[Account]
|
|
195
|
+
category_groups: list[CategoryGroupWithCategories]
|
|
196
|
+
payees: list[Payee]
|
|
197
|
+
transactions: list[TransactionDetail]
|
|
198
|
+
server_knowledge: int
|
|
199
|
+
scheduled_transactions: list[ScheduledTransactionDetail]
|
|
200
|
+
|
|
201
|
+
def has_data(self) -> bool:
|
|
202
|
+
return any(
|
|
203
|
+
getattr(self, field.name)
|
|
204
|
+
for field in fields(self)
|
|
205
|
+
if field.name != "server_knowledge"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@asynccontextmanager
|
|
210
|
+
async def _context(
|
|
211
|
+
db: Path,
|
|
212
|
+
configuration: Configuration,
|
|
213
|
+
*,
|
|
214
|
+
quiet: bool,
|
|
215
|
+
timeout: float = _SYNC_LOCK_TIMEOUT,
|
|
216
|
+
) -> AsyncIterator[_Context]:
|
|
217
|
+
progress = Progress(*_PROGRESS_COLUMNS, disable=quiet)
|
|
218
|
+
lock_path = db.parent / f"{db.name}.lock"
|
|
219
|
+
lock = fasteners.InterProcessLock(lock_path)
|
|
220
|
+
async with (
|
|
221
|
+
ApiClient(configuration) as api_client,
|
|
222
|
+
aiosqlite.connect(db) as con,
|
|
223
|
+
):
|
|
224
|
+
con.row_factory = aiosqlite.Row
|
|
225
|
+
loop = asyncio.get_running_loop()
|
|
226
|
+
deadline = loop.time() + timeout
|
|
227
|
+
_print("Acquiring lock...", quiet=quiet)
|
|
228
|
+
while True:
|
|
229
|
+
acquired = await asyncio.to_thread(lock.acquire, False)
|
|
230
|
+
if acquired:
|
|
231
|
+
_print("Done", quiet=quiet)
|
|
232
|
+
break
|
|
233
|
+
if loop.time() >= deadline:
|
|
234
|
+
raise TimeoutError(
|
|
235
|
+
f"Timed out waiting {timeout} seconds for sync lock at {lock.path}"
|
|
236
|
+
)
|
|
237
|
+
await asyncio.sleep(0.1)
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
yield _Context(progress, con, lock, api_client)
|
|
241
|
+
finally:
|
|
242
|
+
try:
|
|
243
|
+
await asyncio.to_thread(lock.release)
|
|
244
|
+
finally:
|
|
245
|
+
await AsyncPath(lock_path).unlink(missing_ok=True)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@contextmanager
|
|
249
|
+
def _progress(context: _Context) -> Iterator[None]:
|
|
250
|
+
context.progress.start()
|
|
251
|
+
try:
|
|
252
|
+
yield
|
|
253
|
+
finally:
|
|
254
|
+
context.progress.stop()
|
|
255
|
+
for task_id in context.progress.task_ids:
|
|
256
|
+
context.progress.remove_task(task_id)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
async def sync(
|
|
260
|
+
token: str, db: Path, full_refresh: bool, *, quiet: bool = False
|
|
261
|
+
) -> None:
|
|
262
|
+
await AsyncPath(db).parent.mkdir(parents=True, exist_ok=True)
|
|
263
|
+
|
|
264
|
+
configuration = Configuration(access_token=token)
|
|
265
|
+
async with _context(
|
|
266
|
+
db, configuration, quiet=quiet, timeout=_SYNC_LOCK_TIMEOUT
|
|
267
|
+
) as context:
|
|
268
|
+
plans = await _get_plan_summaries(context.api_client)
|
|
269
|
+
|
|
270
|
+
if full_refresh:
|
|
271
|
+
_print("Dropping relations...", quiet=quiet)
|
|
272
|
+
async with context.con.cursor() as cur:
|
|
273
|
+
await cur.executescript(await contents("drop-relations.sql"))
|
|
274
|
+
await context.con.commit()
|
|
275
|
+
_print("Done", quiet=quiet)
|
|
276
|
+
|
|
277
|
+
async with context.con.cursor() as cur:
|
|
278
|
+
relations = await get_relations(cur)
|
|
279
|
+
if relations != _ALL_RELATIONS:
|
|
280
|
+
_print("Recreating relations...", quiet=quiet)
|
|
281
|
+
await cur.executescript(await contents("create-relations.sql"))
|
|
282
|
+
await context.con.commit()
|
|
283
|
+
_print("Done", quiet=quiet)
|
|
284
|
+
|
|
285
|
+
_print("Fetching plan data...", quiet=quiet)
|
|
286
|
+
async with context.con.cursor() as cur:
|
|
287
|
+
lkos = await get_last_knowledge_of_server(cur)
|
|
288
|
+
with _progress(context):
|
|
289
|
+
task = context.progress.add_task(
|
|
290
|
+
"Plan Data", total=len(plans) * len(_ENDPOINTS)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
all_data = await _get_all_ynab(
|
|
294
|
+
context, [str(plan.id) for plan in plans], lkos, task
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
_print("Done", quiet=quiet)
|
|
298
|
+
|
|
299
|
+
if any(plan_data.has_data() for plan_data in all_data.values()):
|
|
300
|
+
_print("Inserting plan data...", quiet=quiet)
|
|
301
|
+
with _progress(context):
|
|
302
|
+
await insert_plan_data(context, plans, all_data)
|
|
303
|
+
await context.con.commit()
|
|
304
|
+
_print("Done", quiet=quiet)
|
|
305
|
+
else:
|
|
306
|
+
_print("No new data fetched", quiet=quiet)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
async def contents(filename: str) -> str:
|
|
310
|
+
return await AsyncPath(str(resources.files(ddl) / filename)).read_text()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
async def get_relations(cur: aiosqlite.Cursor) -> set[str]:
|
|
314
|
+
await cur.execute(
|
|
315
|
+
"SELECT name FROM sqlite_master WHERE type='table' OR type='view'"
|
|
316
|
+
)
|
|
317
|
+
return {t["name"] for t in await cur.fetchall()}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
async def get_last_knowledge_of_server(cur: aiosqlite.Cursor) -> dict[str, int]:
|
|
321
|
+
await cur.execute(
|
|
322
|
+
"SELECT id, last_knowledge_of_server FROM plans",
|
|
323
|
+
)
|
|
324
|
+
return {r["id"]: r["last_knowledge_of_server"] for r in await cur.fetchall()}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
async def insert_plan_data(
|
|
328
|
+
context: _Context, plans: list[PlanSummary], all_data: dict[str, _YnabPlanData]
|
|
329
|
+
) -> None:
|
|
330
|
+
await insert_plans(
|
|
331
|
+
context,
|
|
332
|
+
plans,
|
|
333
|
+
{plan_id: d.server_knowledge for plan_id, d in all_data.items()},
|
|
334
|
+
)
|
|
335
|
+
await asyncio.gather(
|
|
336
|
+
*(
|
|
337
|
+
asyncio.gather(
|
|
338
|
+
insert_accounts(context, plan_id, all_data[plan_id].accounts),
|
|
339
|
+
insert_category_groups(
|
|
340
|
+
context, plan_id, all_data[plan_id].category_groups
|
|
341
|
+
),
|
|
342
|
+
insert_payees(context, plan_id, all_data[plan_id].payees),
|
|
343
|
+
)
|
|
344
|
+
for plan_id in all_data
|
|
345
|
+
),
|
|
346
|
+
)
|
|
347
|
+
await asyncio.gather(
|
|
348
|
+
*(
|
|
349
|
+
asyncio.gather(
|
|
350
|
+
insert_transactions(context, plan_id, all_data[plan_id].transactions),
|
|
351
|
+
insert_scheduled_transactions(
|
|
352
|
+
context, plan_id, all_data[plan_id].scheduled_transactions
|
|
353
|
+
),
|
|
354
|
+
)
|
|
355
|
+
for plan_id in all_data
|
|
356
|
+
),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
async def insert_plans(
|
|
361
|
+
context: _Context, plans: list[PlanSummary], lkos: dict[str, int]
|
|
362
|
+
) -> None:
|
|
363
|
+
async with context.con.cursor() as cur:
|
|
364
|
+
for plan_batch in batched(plans, _BATCH_SIZE):
|
|
365
|
+
await cur.executemany(
|
|
366
|
+
"""
|
|
367
|
+
INSERT OR REPLACE INTO plans (
|
|
368
|
+
id
|
|
369
|
+
, name
|
|
370
|
+
, currency_format_currency_symbol
|
|
371
|
+
, currency_format_decimal_digits
|
|
372
|
+
, currency_format_decimal_separator
|
|
373
|
+
, currency_format_display_symbol
|
|
374
|
+
, currency_format_group_separator
|
|
375
|
+
, currency_format_iso_code
|
|
376
|
+
, currency_format_symbol_first
|
|
377
|
+
, last_knowledge_of_server
|
|
378
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
379
|
+
""",
|
|
380
|
+
(
|
|
381
|
+
(
|
|
382
|
+
plan_id := str(plan.id),
|
|
383
|
+
plan.name,
|
|
384
|
+
getattr(cf := plan.currency_format, "currency_symbol", None),
|
|
385
|
+
getattr(cf, "decimal_digits", None),
|
|
386
|
+
getattr(cf, "decimal_separator", None),
|
|
387
|
+
getattr(cf, "display_symbol", None),
|
|
388
|
+
getattr(cf, "group_separator", None),
|
|
389
|
+
getattr(cf, "iso_code", None),
|
|
390
|
+
getattr(cf, "symbol_first", None),
|
|
391
|
+
lkos[plan_id],
|
|
392
|
+
)
|
|
393
|
+
for plan in plan_batch
|
|
394
|
+
),
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
_LOAN_ACCOUNT_PERIODIC_VALUES = frozenset(
|
|
399
|
+
("debt_escrow_amounts", "debt_interest_rates", "debt_minimum_payments")
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
async def insert_accounts(
|
|
404
|
+
context: _Context,
|
|
405
|
+
plan_id: str,
|
|
406
|
+
accounts: list[Account],
|
|
407
|
+
) -> None:
|
|
408
|
+
# YNAB's LoanAccountPeriodValues are untyped dicts so we need to turn them into a more standard sub-entry view
|
|
409
|
+
updated_accounts = [
|
|
410
|
+
{
|
|
411
|
+
"account_periodic_values": [
|
|
412
|
+
{
|
|
413
|
+
"name": key,
|
|
414
|
+
"account_id": account["id"],
|
|
415
|
+
"date": apvk,
|
|
416
|
+
"amount": apvv,
|
|
417
|
+
}
|
|
418
|
+
for key in _LOAN_ACCOUNT_PERIODIC_VALUES
|
|
419
|
+
for apvk, apvv in account[key].items()
|
|
420
|
+
]
|
|
421
|
+
}
|
|
422
|
+
| {k: v for k, v in account.items() if k not in _LOAN_ACCOUNT_PERIODIC_VALUES}
|
|
423
|
+
for account in (acc.model_dump(mode="json") for acc in accounts)
|
|
424
|
+
]
|
|
425
|
+
|
|
426
|
+
await insert_nested_entries(
|
|
427
|
+
context,
|
|
428
|
+
plan_id,
|
|
429
|
+
updated_accounts,
|
|
430
|
+
"Accounts",
|
|
431
|
+
"accounts",
|
|
432
|
+
"account_periodic_values",
|
|
433
|
+
"account_periodic_values",
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
async def insert_category_groups(
|
|
438
|
+
context: _Context,
|
|
439
|
+
plan_id: str,
|
|
440
|
+
category_groups: list[CategoryGroupWithCategories],
|
|
441
|
+
) -> None:
|
|
442
|
+
await insert_nested_entries(
|
|
443
|
+
context,
|
|
444
|
+
plan_id,
|
|
445
|
+
[cg.model_dump(mode="json") for cg in category_groups],
|
|
446
|
+
"Categories",
|
|
447
|
+
"category_groups",
|
|
448
|
+
"categories",
|
|
449
|
+
"categories",
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
async def insert_payees(
|
|
454
|
+
context: _Context,
|
|
455
|
+
plan_id: str,
|
|
456
|
+
payees: list[Payee],
|
|
457
|
+
) -> None:
|
|
458
|
+
if not payees:
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
task_id = context.progress.add_task("Payees", total=len(payees))
|
|
462
|
+
await insert_entries(
|
|
463
|
+
context, "payees", plan_id, [p.model_dump(mode="json") for p in payees], task_id
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
async def insert_transactions(
|
|
468
|
+
context: _Context,
|
|
469
|
+
plan_id: str,
|
|
470
|
+
transactions: list[TransactionDetail],
|
|
471
|
+
) -> None:
|
|
472
|
+
await insert_nested_entries(
|
|
473
|
+
context,
|
|
474
|
+
plan_id,
|
|
475
|
+
# by_alias=True properly renames 'var_date' to 'date'
|
|
476
|
+
[t.model_dump(mode="json", by_alias=True) for t in transactions],
|
|
477
|
+
"Transactions",
|
|
478
|
+
"transactions",
|
|
479
|
+
"subtransactions",
|
|
480
|
+
"subtransactions",
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
async def insert_scheduled_transactions(
|
|
485
|
+
context: _Context,
|
|
486
|
+
plan_id: str,
|
|
487
|
+
scheduled_transactions: list[ScheduledTransactionDetail],
|
|
488
|
+
) -> None:
|
|
489
|
+
await insert_nested_entries(
|
|
490
|
+
context,
|
|
491
|
+
plan_id,
|
|
492
|
+
[st.model_dump(mode="json") for st in scheduled_transactions],
|
|
493
|
+
"Scheduled Transactions",
|
|
494
|
+
"scheduled_transactions",
|
|
495
|
+
"subtransactions",
|
|
496
|
+
"scheduled_subtransactions",
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@overload
|
|
501
|
+
async def insert_nested_entries(
|
|
502
|
+
context: _Context,
|
|
503
|
+
plan_id: str,
|
|
504
|
+
entries: list[dict[str, Any]],
|
|
505
|
+
desc: Literal["Accounts"],
|
|
506
|
+
entries_name: Literal["accounts"],
|
|
507
|
+
subentries_name: Literal["account_periodic_values"],
|
|
508
|
+
subentries_table_name: Literal["account_periodic_values"],
|
|
509
|
+
) -> None: ...
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
@overload
|
|
513
|
+
async def insert_nested_entries(
|
|
514
|
+
context: _Context,
|
|
515
|
+
plan_id: str,
|
|
516
|
+
entries: list[dict[str, Any]],
|
|
517
|
+
desc: Literal["Categories"],
|
|
518
|
+
entries_name: Literal["category_groups"],
|
|
519
|
+
subentries_name: Literal["categories"],
|
|
520
|
+
subentries_table_name: Literal["categories"],
|
|
521
|
+
) -> None: ...
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@overload
|
|
525
|
+
async def insert_nested_entries(
|
|
526
|
+
context: _Context,
|
|
527
|
+
plan_id: str,
|
|
528
|
+
entries: list[dict[str, Any]],
|
|
529
|
+
desc: Literal["Transactions"],
|
|
530
|
+
entries_name: Literal["transactions"],
|
|
531
|
+
subentries_name: Literal["subtransactions"],
|
|
532
|
+
subentries_table_name: Literal["subtransactions"],
|
|
533
|
+
) -> None: ...
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
@overload
|
|
537
|
+
async def insert_nested_entries(
|
|
538
|
+
context: _Context,
|
|
539
|
+
plan_id: str,
|
|
540
|
+
entries: list[dict[str, Any]],
|
|
541
|
+
desc: Literal["Scheduled Transactions"],
|
|
542
|
+
entries_name: Literal["scheduled_transactions"],
|
|
543
|
+
subentries_name: Literal["subtransactions"],
|
|
544
|
+
subentries_table_name: Literal["scheduled_subtransactions"],
|
|
545
|
+
) -> None: ...
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
async def insert_nested_entries(
|
|
549
|
+
context: _Context,
|
|
550
|
+
plan_id: str,
|
|
551
|
+
entries: list[dict[str, Any]],
|
|
552
|
+
desc: (
|
|
553
|
+
Literal["Accounts"]
|
|
554
|
+
| Literal["Categories"]
|
|
555
|
+
| Literal["Transactions"]
|
|
556
|
+
| Literal["Scheduled Transactions"]
|
|
557
|
+
),
|
|
558
|
+
entries_name: (
|
|
559
|
+
Literal["accounts"]
|
|
560
|
+
| Literal["category_groups"]
|
|
561
|
+
| Literal["transactions"]
|
|
562
|
+
| Literal["scheduled_transactions"]
|
|
563
|
+
),
|
|
564
|
+
subentries_name: (
|
|
565
|
+
Literal["account_periodic_values"]
|
|
566
|
+
| Literal["categories"]
|
|
567
|
+
| Literal["subtransactions"]
|
|
568
|
+
),
|
|
569
|
+
subentries_table_name: (
|
|
570
|
+
Literal["account_periodic_values"]
|
|
571
|
+
| Literal["categories"]
|
|
572
|
+
| Literal["subtransactions"]
|
|
573
|
+
| Literal["scheduled_subtransactions"]
|
|
574
|
+
),
|
|
575
|
+
) -> None:
|
|
576
|
+
if not entries:
|
|
577
|
+
return
|
|
578
|
+
|
|
579
|
+
task_id = context.progress.add_task(
|
|
580
|
+
desc, total=sum(1 + len(e[subentries_name]) for e in entries)
|
|
581
|
+
)
|
|
582
|
+
await insert_entries(
|
|
583
|
+
context,
|
|
584
|
+
entries_name,
|
|
585
|
+
plan_id,
|
|
586
|
+
[{k: v for k, v in entry.items() if k != subentries_name} for entry in entries],
|
|
587
|
+
task_id,
|
|
588
|
+
)
|
|
589
|
+
await insert_entries(
|
|
590
|
+
context,
|
|
591
|
+
subentries_table_name,
|
|
592
|
+
plan_id,
|
|
593
|
+
[subentry for entry in entries for subentry in entry[subentries_name]],
|
|
594
|
+
task_id,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
async def insert_entries(
|
|
599
|
+
context: _Context,
|
|
600
|
+
table: _EntryTable,
|
|
601
|
+
plan_id: str,
|
|
602
|
+
entries: list[dict[str, Any]],
|
|
603
|
+
task_id: TaskID,
|
|
604
|
+
) -> None:
|
|
605
|
+
if not entries:
|
|
606
|
+
return
|
|
607
|
+
|
|
608
|
+
async with context.con.cursor() as cur:
|
|
609
|
+
await cur.execute(f"PRAGMA table_info({table})")
|
|
610
|
+
table_columns = {row["name"] async for row in cur}
|
|
611
|
+
|
|
612
|
+
# Ignore any keys the YNAB API returns that aren't columns in the DDL so
|
|
613
|
+
# newly-added API fields don't break the insert.
|
|
614
|
+
entry_keys = tuple(k for k in entries[0] if k in table_columns)
|
|
615
|
+
sql = f"INSERT OR REPLACE INTO {table} ({', '.join(entry_keys + ('plan_id',))}) VALUES ({', '.join('?' * (len(entry_keys) + 1))})"
|
|
616
|
+
|
|
617
|
+
async with context.con.cursor() as cur:
|
|
618
|
+
for entry_batch in batched(entries, _BATCH_SIZE):
|
|
619
|
+
values_batch = [
|
|
620
|
+
tuple(entry[key] for key in entry_keys) + (plan_id,)
|
|
621
|
+
for entry in entry_batch
|
|
622
|
+
]
|
|
623
|
+
await cur.executemany(sql, values_batch)
|
|
624
|
+
context.progress.update(task_id, advance=len(values_batch))
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
@retry(stop=stop_after_attempt(3))
|
|
628
|
+
async def _get_plan_summaries(api_client: ApiClient) -> list[PlanSummary]:
|
|
629
|
+
return (await PlansApi(api_client).get_plans()).data.plans
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
async def _get_all_ynab(
|
|
633
|
+
context: _Context, plan_ids: list[str], lkos: dict[str, int], task_id: TaskID
|
|
634
|
+
) -> dict[str, _YnabPlanData]:
|
|
635
|
+
return dict(
|
|
636
|
+
await asyncio.gather(
|
|
637
|
+
*(_get_plan_data(context, plan_id, lkos, task_id) for plan_id in plan_ids)
|
|
638
|
+
)
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
async def _get_plan_data(
|
|
643
|
+
context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
|
|
644
|
+
) -> tuple[str, _YnabPlanData]:
|
|
645
|
+
py = _ProgressYnab(context, plan_id, lkos, task_id)
|
|
646
|
+
accounts, categories, payees, transactions, scheduled = await asyncio.gather(
|
|
647
|
+
py.get(AccountsApi(context.api_client).get_accounts),
|
|
648
|
+
py.get(CategoriesApi(context.api_client).get_categories),
|
|
649
|
+
py.get(PayeesApi(context.api_client).get_payees),
|
|
650
|
+
py.get(TransactionsApi(context.api_client).get_transactions),
|
|
651
|
+
py.get(ScheduledTransactionsApi(context.api_client).get_scheduled_transactions),
|
|
652
|
+
)
|
|
653
|
+
return (
|
|
654
|
+
plan_id,
|
|
655
|
+
_YnabPlanData(
|
|
656
|
+
accounts=accounts.data.accounts,
|
|
657
|
+
category_groups=categories.data.category_groups,
|
|
658
|
+
payees=payees.data.payees,
|
|
659
|
+
transactions=transactions.data.transactions,
|
|
660
|
+
server_knowledge=transactions.data.server_knowledge,
|
|
661
|
+
scheduled_transactions=scheduled.data.scheduled_transactions,
|
|
662
|
+
),
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
@dataclass(slots=True)
|
|
667
|
+
class _ProgressYnab:
|
|
668
|
+
context: _Context
|
|
669
|
+
plan_id: str
|
|
670
|
+
lkos: dict[str, int]
|
|
671
|
+
task_id: TaskID
|
|
672
|
+
|
|
673
|
+
@retry(stop=stop_after_attempt(3))
|
|
674
|
+
async def get[T](self, endpoint: Callable[..., Awaitable[T]]) -> T:
|
|
675
|
+
try:
|
|
676
|
+
return await endpoint(
|
|
677
|
+
plan_id=self.plan_id,
|
|
678
|
+
last_knowledge_of_server=self.lkos.get(self.plan_id),
|
|
679
|
+
)
|
|
680
|
+
finally:
|
|
681
|
+
self.context.progress.update(self.task_id, advance=1)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def main(
|
|
685
|
+
argv: Sequence[str] | None = None, *, token_override: str | None = None
|
|
686
|
+
) -> int:
|
|
687
|
+
return asyncio.run(async_main(argv, token_override=token_override))
|
|
File without changes
|