sqlite-export-for-ynab 2.6.0__tar.gz → 2.7.0__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.0}/PKG-INFO +3 -1
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/setup.cfg +3 -1
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/_main.py +210 -179
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0/sqlite_export_for_ynab.egg-info}/PKG-INFO +3 -1
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/requires.txt +2 -0
- sqlite_export_for_ynab-2.7.0/testing/fixtures.py +620 -0
- sqlite_export_for_ynab-2.7.0/tests/_main_test.py +1094 -0
- 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.0}/LICENSE +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/README.md +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/pyproject.toml +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/setup.py +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/__init__.py +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/__main__.py +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/ddl/__init__.py +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/ddl/create-relations.sql +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/ddl/drop-relations.sql +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/py.typed +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/SOURCES.txt +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/dependency_links.txt +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/entry_points.txt +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/top_level.txt +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/testing/__init__.py +0 -0
- {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/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.0
|
|
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,10 @@ 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
22
|
Requires-Dist: rich>=14
|
|
23
|
+
Requires-Dist: tenacity
|
|
22
24
|
Dynamic: license-file
|
|
23
25
|
|
|
24
26
|
# 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.0
|
|
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,8 +22,10 @@ install_requires =
|
|
|
22
22
|
aiohttp>=3
|
|
23
23
|
aiopathlib
|
|
24
24
|
aiosqlite
|
|
25
|
+
asyncio-for-ynab
|
|
25
26
|
fasteners
|
|
26
27
|
rich>=14
|
|
28
|
+
tenacity
|
|
27
29
|
python_requires = >=3.12
|
|
28
30
|
|
|
29
31
|
[options.entry_points]
|
{sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/_main.py
RENAMED
|
@@ -2,40 +2,53 @@ 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
38
|
from rich.progress import MofNCompleteColumn
|
|
30
39
|
from rich.progress import Progress
|
|
31
40
|
from rich.progress import TaskID
|
|
32
41
|
from rich.progress import TextColumn
|
|
33
42
|
from rich.progress import TimeElapsedColumn
|
|
43
|
+
from tenacity import retry
|
|
44
|
+
from tenacity import stop_after_attempt
|
|
34
45
|
|
|
35
46
|
from sqlite_export_for_ynab import ddl
|
|
36
47
|
|
|
37
48
|
if TYPE_CHECKING:
|
|
38
|
-
from collections.abc import AsyncIterator
|
|
49
|
+
from collections.abc import AsyncIterator
|
|
50
|
+
from collections.abc import Iterator
|
|
51
|
+
from collections.abc import Sequence
|
|
39
52
|
|
|
40
53
|
|
|
41
54
|
_EntryTable = (
|
|
@@ -83,7 +96,8 @@ def resolve_token(token_override: str | None = None) -> str:
|
|
|
83
96
|
return token
|
|
84
97
|
|
|
85
98
|
raise ValueError(
|
|
86
|
-
f"Must set YNAB access token as {_ENV_TOKEN!r} environment variable or pass
|
|
99
|
+
f"Must set YNAB access token as {_ENV_TOKEN!r} environment variable or pass "
|
|
100
|
+
"token_override directly. See https://api.ynab.com/#personal-access-tokens"
|
|
87
101
|
)
|
|
88
102
|
|
|
89
103
|
|
|
@@ -142,20 +156,44 @@ def _print(message: str, *, quiet: bool) -> None:
|
|
|
142
156
|
|
|
143
157
|
@dataclass
|
|
144
158
|
class _Context:
|
|
145
|
-
session: aiohttp.ClientSession
|
|
146
159
|
progress: Progress
|
|
147
160
|
con: aiosqlite.Connection
|
|
148
161
|
lock: fasteners.InterProcessLock
|
|
162
|
+
api_client: ApiClient
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass
|
|
166
|
+
class _YnabPlanData:
|
|
167
|
+
accounts: list[Account]
|
|
168
|
+
category_groups: list[CategoryGroupWithCategories]
|
|
169
|
+
payees: list[Payee]
|
|
170
|
+
transactions: list[TransactionDetail]
|
|
171
|
+
server_knowledge: int
|
|
172
|
+
scheduled_transactions: list[ScheduledTransactionDetail]
|
|
173
|
+
|
|
174
|
+
def has_data(self) -> bool:
|
|
175
|
+
return any(
|
|
176
|
+
getattr(self, field.name)
|
|
177
|
+
for field in fields(self)
|
|
178
|
+
if field.name != "server_knowledge"
|
|
179
|
+
)
|
|
149
180
|
|
|
150
181
|
|
|
151
182
|
@asynccontextmanager
|
|
152
183
|
async def _context(
|
|
153
|
-
db: Path,
|
|
184
|
+
db: Path,
|
|
185
|
+
configuration: Configuration,
|
|
186
|
+
*,
|
|
187
|
+
quiet: bool,
|
|
188
|
+
timeout: float = _SYNC_LOCK_TIMEOUT,
|
|
154
189
|
) -> AsyncIterator[_Context]:
|
|
155
190
|
progress = Progress(*_PROGRESS_COLUMNS, disable=quiet)
|
|
156
191
|
lock_path = db.parent / f"{db.name}.lock"
|
|
157
192
|
lock = fasteners.InterProcessLock(lock_path)
|
|
158
|
-
async with
|
|
193
|
+
async with (
|
|
194
|
+
ApiClient(configuration) as api_client,
|
|
195
|
+
aiosqlite.connect(db) as con,
|
|
196
|
+
):
|
|
159
197
|
con.row_factory = aiosqlite.Row
|
|
160
198
|
loop = asyncio.get_running_loop()
|
|
161
199
|
deadline = loop.time() + timeout
|
|
@@ -172,7 +210,7 @@ async def _context(
|
|
|
172
210
|
await asyncio.sleep(0.1)
|
|
173
211
|
|
|
174
212
|
try:
|
|
175
|
-
yield _Context(
|
|
213
|
+
yield _Context(progress, con, lock, api_client)
|
|
176
214
|
finally:
|
|
177
215
|
try:
|
|
178
216
|
await asyncio.to_thread(lock.release)
|
|
@@ -196,10 +234,11 @@ async def sync(
|
|
|
196
234
|
) -> None:
|
|
197
235
|
await AsyncPath(db).parent.mkdir(parents=True, exist_ok=True)
|
|
198
236
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
237
|
+
configuration = Configuration(access_token=token)
|
|
238
|
+
async with _context(
|
|
239
|
+
db, configuration, quiet=quiet, timeout=_SYNC_LOCK_TIMEOUT
|
|
240
|
+
) as context:
|
|
241
|
+
plans = await _get_plan_summaries(context.api_client)
|
|
203
242
|
|
|
204
243
|
if full_refresh:
|
|
205
244
|
_print("Dropping relations...", quiet=quiet)
|
|
@@ -220,63 +259,24 @@ async def sync(
|
|
|
220
259
|
async with context.con.cursor() as cur:
|
|
221
260
|
lkos = await get_last_knowledge_of_server(cur)
|
|
222
261
|
with _progress(context):
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
context,
|
|
226
|
-
context.progress.add_task(
|
|
227
|
-
"Plan Data", total=len(plans) * len(_ENDPOINTS)
|
|
228
|
-
),
|
|
262
|
+
task = context.progress.add_task(
|
|
263
|
+
"Plan Data", total=len(plans) * len(_ENDPOINTS)
|
|
229
264
|
)
|
|
230
265
|
|
|
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
|
-
)
|
|
266
|
+
all_data = await _get_all_ynab(
|
|
267
|
+
context, [str(plan.id) for plan in plans], lkos, task
|
|
242
268
|
)
|
|
243
269
|
|
|
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
270
|
_print("Done", quiet=quiet)
|
|
255
271
|
|
|
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:
|
|
272
|
+
if any(plan_data.has_data() for plan_data in all_data.values()):
|
|
265
273
|
_print("Inserting plan data...", quiet=quiet)
|
|
266
274
|
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
|
-
)
|
|
275
|
+
await insert_plan_data(context, plans, all_data)
|
|
278
276
|
await context.con.commit()
|
|
279
277
|
_print("Done", quiet=quiet)
|
|
278
|
+
else:
|
|
279
|
+
_print("No new data fetched", quiet=quiet)
|
|
280
280
|
|
|
281
281
|
|
|
282
282
|
async def contents(filename: str) -> str:
|
|
@@ -298,49 +298,40 @@ async def get_last_knowledge_of_server(cur: aiosqlite.Cursor) -> dict[str, int]:
|
|
|
298
298
|
|
|
299
299
|
|
|
300
300
|
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],
|
|
301
|
+
context: _Context, plans: list[PlanSummary], all_data: dict[str, _YnabPlanData]
|
|
310
302
|
) -> None:
|
|
311
|
-
await insert_plans(
|
|
303
|
+
await insert_plans(
|
|
304
|
+
context,
|
|
305
|
+
plans,
|
|
306
|
+
{plan_id: d.server_knowledge for plan_id, d in all_data.items()},
|
|
307
|
+
)
|
|
312
308
|
await asyncio.gather(
|
|
313
309
|
*(
|
|
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)
|
|
310
|
+
asyncio.gather(
|
|
311
|
+
insert_accounts(context, plan_id, all_data[plan_id].accounts),
|
|
312
|
+
insert_category_groups(
|
|
313
|
+
context, plan_id, all_data[plan_id].category_groups
|
|
314
|
+
),
|
|
315
|
+
insert_payees(context, plan_id, all_data[plan_id].payees),
|
|
316
|
+
)
|
|
317
|
+
for plan_id in all_data
|
|
324
318
|
),
|
|
325
319
|
)
|
|
326
320
|
await asyncio.gather(
|
|
327
321
|
*(
|
|
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
|
|
322
|
+
asyncio.gather(
|
|
323
|
+
insert_transactions(context, plan_id, all_data[plan_id].transactions),
|
|
324
|
+
insert_scheduled_transactions(
|
|
325
|
+
context, plan_id, all_data[plan_id].scheduled_transactions
|
|
326
|
+
),
|
|
337
327
|
)
|
|
328
|
+
for plan_id in all_data
|
|
338
329
|
),
|
|
339
330
|
)
|
|
340
331
|
|
|
341
332
|
|
|
342
333
|
async def insert_plans(
|
|
343
|
-
context: _Context, plans: list[
|
|
334
|
+
context: _Context, plans: list[PlanSummary], lkos: dict[str, int]
|
|
344
335
|
) -> None:
|
|
345
336
|
async with context.con.cursor() as cur:
|
|
346
337
|
for plan_batch in batched(plans, _BATCH_SIZE):
|
|
@@ -361,15 +352,15 @@ async def insert_plans(
|
|
|
361
352
|
""",
|
|
362
353
|
(
|
|
363
354
|
(
|
|
364
|
-
plan_id := plan
|
|
365
|
-
plan
|
|
366
|
-
plan
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
355
|
+
plan_id := str(plan.id),
|
|
356
|
+
plan.name,
|
|
357
|
+
getattr(cf := plan.currency_format, "currency_symbol", None),
|
|
358
|
+
getattr(cf, "decimal_digits", None),
|
|
359
|
+
getattr(cf, "decimal_separator", None),
|
|
360
|
+
getattr(cf, "display_symbol", None),
|
|
361
|
+
getattr(cf, "group_separator", None),
|
|
362
|
+
getattr(cf, "iso_code", None),
|
|
363
|
+
getattr(cf, "symbol_first", None),
|
|
373
364
|
lkos[plan_id],
|
|
374
365
|
)
|
|
375
366
|
for plan in plan_batch
|
|
@@ -385,7 +376,7 @@ _LOAN_ACCOUNT_PERIODIC_VALUES = frozenset(
|
|
|
385
376
|
async def insert_accounts(
|
|
386
377
|
context: _Context,
|
|
387
378
|
plan_id: str,
|
|
388
|
-
accounts: list[
|
|
379
|
+
accounts: list[Account],
|
|
389
380
|
) -> None:
|
|
390
381
|
# YNAB's LoanAccountPeriodValues are untyped dicts so we need to turn them into a more standard sub-entry view
|
|
391
382
|
updated_accounts = [
|
|
@@ -402,7 +393,7 @@ async def insert_accounts(
|
|
|
402
393
|
]
|
|
403
394
|
}
|
|
404
395
|
| {k: v for k, v in account.items() if k not in _LOAN_ACCOUNT_PERIODIC_VALUES}
|
|
405
|
-
for account in accounts
|
|
396
|
+
for account in (acc.model_dump(mode="json") for acc in accounts)
|
|
406
397
|
]
|
|
407
398
|
|
|
408
399
|
await insert_nested_entries(
|
|
@@ -419,12 +410,12 @@ async def insert_accounts(
|
|
|
419
410
|
async def insert_category_groups(
|
|
420
411
|
context: _Context,
|
|
421
412
|
plan_id: str,
|
|
422
|
-
category_groups: list[
|
|
413
|
+
category_groups: list[CategoryGroupWithCategories],
|
|
423
414
|
) -> None:
|
|
424
415
|
await insert_nested_entries(
|
|
425
416
|
context,
|
|
426
417
|
plan_id,
|
|
427
|
-
category_groups,
|
|
418
|
+
[cg.model_dump(mode="json") for cg in category_groups],
|
|
428
419
|
"Categories",
|
|
429
420
|
"category_groups",
|
|
430
421
|
"categories",
|
|
@@ -435,24 +426,27 @@ async def insert_category_groups(
|
|
|
435
426
|
async def insert_payees(
|
|
436
427
|
context: _Context,
|
|
437
428
|
plan_id: str,
|
|
438
|
-
payees: list[
|
|
429
|
+
payees: list[Payee],
|
|
439
430
|
) -> None:
|
|
440
431
|
if not payees:
|
|
441
432
|
return
|
|
442
433
|
|
|
443
434
|
task_id = context.progress.add_task("Payees", total=len(payees))
|
|
444
|
-
await insert_entries(
|
|
435
|
+
await insert_entries(
|
|
436
|
+
context, "payees", plan_id, [p.model_dump(mode="json") for p in payees], task_id
|
|
437
|
+
)
|
|
445
438
|
|
|
446
439
|
|
|
447
440
|
async def insert_transactions(
|
|
448
441
|
context: _Context,
|
|
449
442
|
plan_id: str,
|
|
450
|
-
transactions: list[
|
|
443
|
+
transactions: list[TransactionDetail],
|
|
451
444
|
) -> None:
|
|
452
445
|
await insert_nested_entries(
|
|
453
446
|
context,
|
|
454
447
|
plan_id,
|
|
455
|
-
|
|
448
|
+
# by_alias=True properly renames 'var_date' to 'date'
|
|
449
|
+
[t.model_dump(mode="json", by_alias=True) for t in transactions],
|
|
456
450
|
"Transactions",
|
|
457
451
|
"transactions",
|
|
458
452
|
"subtransactions",
|
|
@@ -463,12 +457,12 @@ async def insert_transactions(
|
|
|
463
457
|
async def insert_scheduled_transactions(
|
|
464
458
|
context: _Context,
|
|
465
459
|
plan_id: str,
|
|
466
|
-
scheduled_transactions: list[
|
|
460
|
+
scheduled_transactions: list[ScheduledTransactionDetail],
|
|
467
461
|
) -> None:
|
|
468
462
|
await insert_nested_entries(
|
|
469
463
|
context,
|
|
470
464
|
plan_id,
|
|
471
|
-
scheduled_transactions,
|
|
465
|
+
[st.model_dump(mode="json") for st in scheduled_transactions],
|
|
472
466
|
"Scheduled Transactions",
|
|
473
467
|
"scheduled_transactions",
|
|
474
468
|
"subtransactions",
|
|
@@ -597,81 +591,118 @@ async def insert_entries(
|
|
|
597
591
|
context.progress.update(task_id, advance=len(values_batch))
|
|
598
592
|
|
|
599
593
|
|
|
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
|
-
]
|
|
594
|
+
@retry(stop=stop_after_attempt(3))
|
|
595
|
+
async def _get_plan_summaries(api_client: ApiClient) -> list[PlanSummary]:
|
|
596
|
+
return (await PlansApi(api_client).get_plans()).data.plans
|
|
610
597
|
|
|
611
598
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
599
|
+
async def _get_all_ynab(
|
|
600
|
+
context: _Context, plan_ids: list[str], lkos: dict[str, int], task_id: TaskID
|
|
601
|
+
) -> dict[str, _YnabPlanData]:
|
|
602
|
+
return dict(
|
|
603
|
+
await asyncio.gather(
|
|
604
|
+
*(_get_plan_data(context, plan_id, lkos, task_id) for plan_id in plan_ids)
|
|
605
|
+
)
|
|
606
|
+
)
|
|
616
607
|
|
|
617
608
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
609
|
+
async def _get_plan_data(
|
|
610
|
+
context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
|
|
611
|
+
) -> tuple[str, _YnabPlanData]:
|
|
612
|
+
(
|
|
613
|
+
accounts,
|
|
614
|
+
categories,
|
|
615
|
+
payees,
|
|
616
|
+
transactions_serverknowledge,
|
|
617
|
+
scheduled_transactions,
|
|
618
|
+
) = await asyncio.gather(
|
|
619
|
+
_get_accounts(context, plan_id, lkos, task_id),
|
|
620
|
+
_get_categories(context, plan_id, lkos, task_id),
|
|
621
|
+
_get_payees(context, plan_id, lkos, task_id),
|
|
622
|
+
_get_transactions(context, plan_id, lkos, task_id),
|
|
623
|
+
_get_scheduled_transactions(context, plan_id, lkos, task_id),
|
|
624
|
+
)
|
|
625
|
+
transactions, server_knowledge = transactions_serverknowledge
|
|
626
|
+
return (
|
|
627
|
+
plan_id,
|
|
628
|
+
_YnabPlanData(
|
|
629
|
+
accounts=accounts,
|
|
630
|
+
category_groups=categories,
|
|
631
|
+
payees=payees,
|
|
632
|
+
transactions=transactions,
|
|
633
|
+
server_knowledge=server_knowledge,
|
|
634
|
+
scheduled_transactions=scheduled_transactions,
|
|
635
|
+
),
|
|
636
|
+
)
|
|
631
637
|
|
|
632
638
|
|
|
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
|
-
)
|
|
639
|
+
@retry(stop=stop_after_attempt(3))
|
|
640
|
+
async def _get_accounts(
|
|
641
|
+
context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
|
|
642
|
+
) -> list[Account]:
|
|
643
|
+
resp = await AccountsApi(context.api_client).get_accounts(
|
|
644
|
+
plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
|
|
645
|
+
)
|
|
646
|
+
context.progress.update(task_id, advance=1)
|
|
647
|
+
return resp.data.accounts
|
|
663
648
|
|
|
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
649
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
650
|
+
@retry(stop=stop_after_attempt(3))
|
|
651
|
+
async def _get_categories(
|
|
652
|
+
context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
|
|
653
|
+
) -> list[CategoryGroupWithCategories]:
|
|
654
|
+
resp = await CategoriesApi(context.api_client).get_categories(
|
|
655
|
+
plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
|
|
656
|
+
)
|
|
657
|
+
context.progress.update(task_id, advance=1)
|
|
658
|
+
return resp.data.category_groups
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
@retry(stop=stop_after_attempt(3))
|
|
662
|
+
async def _get_payees(
|
|
663
|
+
context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
|
|
664
|
+
) -> list[Payee]:
|
|
665
|
+
resp = await PayeesApi(context.api_client).get_payees(
|
|
666
|
+
plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
|
|
667
|
+
)
|
|
668
|
+
context.progress.update(task_id, advance=1)
|
|
669
|
+
return resp.data.payees
|
|
673
670
|
|
|
674
|
-
|
|
671
|
+
|
|
672
|
+
@retry(stop=stop_after_attempt(3))
|
|
673
|
+
async def _get_transactions(
|
|
674
|
+
context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
|
|
675
|
+
) -> tuple[list[TransactionDetail], int]:
|
|
676
|
+
resp = await TransactionsApi(context.api_client).get_transactions(
|
|
677
|
+
plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
|
|
678
|
+
)
|
|
679
|
+
context.progress.update(task_id, advance=1)
|
|
680
|
+
return resp.data.transactions, resp.data.server_knowledge
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
@retry(stop=stop_after_attempt(3))
|
|
684
|
+
async def _get_scheduled_transactions(
|
|
685
|
+
context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
|
|
686
|
+
) -> list[ScheduledTransactionDetail]:
|
|
687
|
+
resp = await ScheduledTransactionsApi(
|
|
688
|
+
context.api_client
|
|
689
|
+
).get_scheduled_transactions(
|
|
690
|
+
plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
|
|
691
|
+
)
|
|
692
|
+
context.progress.update(task_id, advance=1)
|
|
693
|
+
return resp.data.scheduled_transactions
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
# @retry(stop=stop_after_attempt(3))
|
|
697
|
+
# async def _get_ynab[T](
|
|
698
|
+
# context: _Context,
|
|
699
|
+
# getter: Callable[..., Awaitable[T]],
|
|
700
|
+
# task_id: TaskID,
|
|
701
|
+
# ) -> T:
|
|
702
|
+
# try:
|
|
703
|
+
# return await getter()
|
|
704
|
+
# finally:
|
|
705
|
+
# context.progress.update(task_id, advance=1)
|
|
675
706
|
|
|
676
707
|
|
|
677
708
|
def main(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlite_export_for_ynab
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.0
|
|
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,10 @@ 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
22
|
Requires-Dist: rich>=14
|
|
23
|
+
Requires-Dist: tenacity
|
|
22
24
|
Dynamic: license-file
|
|
23
25
|
|
|
24
26
|
# sqlite-export-for-ynab
|