sqlite-export-for-ynab 2.5.1__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.5.1/sqlite_export_for_ynab.egg-info → sqlite_export_for_ynab-2.7.0}/PKG-INFO +4 -1
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/setup.cfg +4 -1
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/_main.py +237 -179
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0/sqlite_export_for_ynab.egg-info}/PKG-INFO +4 -1
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/requires.txt +3 -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.5.1/testing/fixtures.py +0 -373
- sqlite_export_for_ynab-2.5.1/tests/_main_test.py +0 -828
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/LICENSE +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/README.md +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/pyproject.toml +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/setup.py +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/__init__.py +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/__main__.py +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/ddl/__init__.py +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/ddl/create-relations.sql +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/ddl/drop-relations.sql +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/py.typed +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/SOURCES.txt +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/dependency_links.txt +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/entry_points.txt +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/top_level.txt +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/testing/__init__.py +0 -0
- {sqlite_export_for_ynab-2.5.1 → 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,7 +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
|
|
21
|
+
Requires-Dist: fasteners
|
|
20
22
|
Requires-Dist: rich>=14
|
|
23
|
+
Requires-Dist: tenacity
|
|
21
24
|
Dynamic: license-file
|
|
22
25
|
|
|
23
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,7 +22,10 @@ install_requires =
|
|
|
22
22
|
aiohttp>=3
|
|
23
23
|
aiopathlib
|
|
24
24
|
aiosqlite
|
|
25
|
+
asyncio-for-ynab
|
|
26
|
+
fasteners
|
|
25
27
|
rich>=14
|
|
28
|
+
tenacity
|
|
26
29
|
python_requires = >=3.12
|
|
27
30
|
|
|
28
31
|
[options.entry_points]
|
{sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/_main.py
RENAMED
|
@@ -2,39 +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
|
|
21
|
+
import fasteners
|
|
26
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
|
|
27
37
|
from rich.progress import BarColumn
|
|
28
38
|
from rich.progress import MofNCompleteColumn
|
|
29
39
|
from rich.progress import Progress
|
|
30
40
|
from rich.progress import TaskID
|
|
31
41
|
from rich.progress import TextColumn
|
|
32
42
|
from rich.progress import TimeElapsedColumn
|
|
43
|
+
from tenacity import retry
|
|
44
|
+
from tenacity import stop_after_attempt
|
|
33
45
|
|
|
34
46
|
from sqlite_export_for_ynab import ddl
|
|
35
47
|
|
|
36
48
|
if TYPE_CHECKING:
|
|
37
|
-
from collections.abc import AsyncIterator
|
|
49
|
+
from collections.abc import AsyncIterator
|
|
50
|
+
from collections.abc import Iterator
|
|
51
|
+
from collections.abc import Sequence
|
|
38
52
|
|
|
39
53
|
|
|
40
54
|
_EntryTable = (
|
|
@@ -66,6 +80,7 @@ _ENV_TOKEN = "YNAB_PERSONAL_ACCESS_TOKEN"
|
|
|
66
80
|
_PACKAGE = "sqlite-export-for-ynab"
|
|
67
81
|
|
|
68
82
|
_BATCH_SIZE = 100
|
|
83
|
+
_SYNC_LOCK_TIMEOUT = 30.0
|
|
69
84
|
|
|
70
85
|
_PROGRESS_COLUMNS = (
|
|
71
86
|
TextColumn("[progress.description]{task.description}"),
|
|
@@ -81,7 +96,8 @@ def resolve_token(token_override: str | None = None) -> str:
|
|
|
81
96
|
return token
|
|
82
97
|
|
|
83
98
|
raise ValueError(
|
|
84
|
-
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"
|
|
85
101
|
)
|
|
86
102
|
|
|
87
103
|
|
|
@@ -140,17 +156,66 @@ def _print(message: str, *, quiet: bool) -> None:
|
|
|
140
156
|
|
|
141
157
|
@dataclass
|
|
142
158
|
class _Context:
|
|
143
|
-
session: aiohttp.ClientSession
|
|
144
159
|
progress: Progress
|
|
145
160
|
con: aiosqlite.Connection
|
|
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
|
+
)
|
|
146
180
|
|
|
147
181
|
|
|
148
182
|
@asynccontextmanager
|
|
149
|
-
async def _context(
|
|
183
|
+
async def _context(
|
|
184
|
+
db: Path,
|
|
185
|
+
configuration: Configuration,
|
|
186
|
+
*,
|
|
187
|
+
quiet: bool,
|
|
188
|
+
timeout: float = _SYNC_LOCK_TIMEOUT,
|
|
189
|
+
) -> AsyncIterator[_Context]:
|
|
150
190
|
progress = Progress(*_PROGRESS_COLUMNS, disable=quiet)
|
|
151
|
-
|
|
191
|
+
lock_path = db.parent / f"{db.name}.lock"
|
|
192
|
+
lock = fasteners.InterProcessLock(lock_path)
|
|
193
|
+
async with (
|
|
194
|
+
ApiClient(configuration) as api_client,
|
|
195
|
+
aiosqlite.connect(db) as con,
|
|
196
|
+
):
|
|
152
197
|
con.row_factory = aiosqlite.Row
|
|
153
|
-
|
|
198
|
+
loop = asyncio.get_running_loop()
|
|
199
|
+
deadline = loop.time() + timeout
|
|
200
|
+
_print("Acquiring lock...", quiet=quiet)
|
|
201
|
+
while True:
|
|
202
|
+
acquired = await asyncio.to_thread(lock.acquire, False)
|
|
203
|
+
if acquired:
|
|
204
|
+
_print("Done", quiet=quiet)
|
|
205
|
+
break
|
|
206
|
+
if loop.time() >= deadline:
|
|
207
|
+
raise TimeoutError(
|
|
208
|
+
f"Timed out waiting {timeout} seconds for sync lock at {lock.path}"
|
|
209
|
+
)
|
|
210
|
+
await asyncio.sleep(0.1)
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
yield _Context(progress, con, lock, api_client)
|
|
214
|
+
finally:
|
|
215
|
+
try:
|
|
216
|
+
await asyncio.to_thread(lock.release)
|
|
217
|
+
finally:
|
|
218
|
+
await AsyncPath(lock_path).unlink(missing_ok=True)
|
|
154
219
|
|
|
155
220
|
|
|
156
221
|
@contextmanager
|
|
@@ -169,10 +234,11 @@ async def sync(
|
|
|
169
234
|
) -> None:
|
|
170
235
|
await AsyncPath(db).parent.mkdir(parents=True, exist_ok=True)
|
|
171
236
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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)
|
|
176
242
|
|
|
177
243
|
if full_refresh:
|
|
178
244
|
_print("Dropping relations...", quiet=quiet)
|
|
@@ -193,63 +259,24 @@ async def sync(
|
|
|
193
259
|
async with context.con.cursor() as cur:
|
|
194
260
|
lkos = await get_last_knowledge_of_server(cur)
|
|
195
261
|
with _progress(context):
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
context,
|
|
199
|
-
context.progress.add_task(
|
|
200
|
-
"Plan Data", total=len(plans) * len(_ENDPOINTS)
|
|
201
|
-
),
|
|
262
|
+
task = context.progress.add_task(
|
|
263
|
+
"Plan Data", total=len(plans) * len(_ENDPOINTS)
|
|
202
264
|
)
|
|
203
265
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
_ENDPOINTS,
|
|
207
|
-
await asyncio.gather(
|
|
208
|
-
*(
|
|
209
|
-
asyncio.gather(*jobs(yc, endpoint, plan_ids, lkos))
|
|
210
|
-
for endpoint in _ENDPOINTS
|
|
211
|
-
)
|
|
212
|
-
),
|
|
213
|
-
strict=True,
|
|
214
|
-
)
|
|
266
|
+
all_data = await _get_all_ynab(
|
|
267
|
+
context, [str(plan.id) for plan in plans], lkos, task
|
|
215
268
|
)
|
|
216
269
|
|
|
217
|
-
all_account_data = endpoint_data["accounts"]
|
|
218
|
-
all_cat_data = endpoint_data["categories"]
|
|
219
|
-
all_payee_data = endpoint_data["payees"]
|
|
220
|
-
all_txn_data = endpoint_data["transactions"]
|
|
221
|
-
all_sched_txn_data = endpoint_data["scheduled_transactions"]
|
|
222
|
-
|
|
223
|
-
new_lkos = {
|
|
224
|
-
plan_id: transaction_data["server_knowledge"]
|
|
225
|
-
for plan_id, transaction_data in zip(plan_ids, all_txn_data, strict=True)
|
|
226
|
-
}
|
|
227
270
|
_print("Done", quiet=quiet)
|
|
228
271
|
|
|
229
|
-
if (
|
|
230
|
-
not any(t["accounts"] for t in all_account_data)
|
|
231
|
-
and not any(t["category_groups"] for t in all_cat_data)
|
|
232
|
-
and not any(p["payees"] for p in all_payee_data)
|
|
233
|
-
and not any(t["transactions"] for t in all_txn_data)
|
|
234
|
-
and not any(s["scheduled_transactions"] for s in all_sched_txn_data)
|
|
235
|
-
):
|
|
236
|
-
_print("No new data fetched", quiet=quiet)
|
|
237
|
-
else:
|
|
272
|
+
if any(plan_data.has_data() for plan_data in all_data.values()):
|
|
238
273
|
_print("Inserting plan data...", quiet=quiet)
|
|
239
274
|
with _progress(context):
|
|
240
|
-
await insert_plan_data(
|
|
241
|
-
context,
|
|
242
|
-
plans,
|
|
243
|
-
plan_ids,
|
|
244
|
-
all_account_data,
|
|
245
|
-
all_cat_data,
|
|
246
|
-
all_payee_data,
|
|
247
|
-
all_txn_data,
|
|
248
|
-
all_sched_txn_data,
|
|
249
|
-
new_lkos,
|
|
250
|
-
)
|
|
275
|
+
await insert_plan_data(context, plans, all_data)
|
|
251
276
|
await context.con.commit()
|
|
252
277
|
_print("Done", quiet=quiet)
|
|
278
|
+
else:
|
|
279
|
+
_print("No new data fetched", quiet=quiet)
|
|
253
280
|
|
|
254
281
|
|
|
255
282
|
async def contents(filename: str) -> str:
|
|
@@ -271,49 +298,40 @@ async def get_last_knowledge_of_server(cur: aiosqlite.Cursor) -> dict[str, int]:
|
|
|
271
298
|
|
|
272
299
|
|
|
273
300
|
async def insert_plan_data(
|
|
274
|
-
context: _Context,
|
|
275
|
-
plans: list[dict[str, Any]],
|
|
276
|
-
plan_ids: list[str],
|
|
277
|
-
all_account_data: list[dict[str, Any]],
|
|
278
|
-
all_cat_data: list[dict[str, Any]],
|
|
279
|
-
all_payee_data: list[dict[str, Any]],
|
|
280
|
-
all_txn_data: list[dict[str, Any]],
|
|
281
|
-
all_sched_txn_data: list[dict[str, Any]],
|
|
282
|
-
new_lkos: dict[str, int],
|
|
301
|
+
context: _Context, plans: list[PlanSummary], all_data: dict[str, _YnabPlanData]
|
|
283
302
|
) -> None:
|
|
284
|
-
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
|
+
)
|
|
285
308
|
await asyncio.gather(
|
|
286
309
|
*(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
insert_payees(context, plan_id, payee_data["payees"])
|
|
296
|
-
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
|
|
297
318
|
),
|
|
298
319
|
)
|
|
299
320
|
await asyncio.gather(
|
|
300
321
|
*(
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
context, plan_id, sched_txn_data["scheduled_transactions"]
|
|
307
|
-
)
|
|
308
|
-
for plan_id, sched_txn_data in zip(
|
|
309
|
-
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
|
+
),
|
|
310
327
|
)
|
|
328
|
+
for plan_id in all_data
|
|
311
329
|
),
|
|
312
330
|
)
|
|
313
331
|
|
|
314
332
|
|
|
315
333
|
async def insert_plans(
|
|
316
|
-
context: _Context, plans: list[
|
|
334
|
+
context: _Context, plans: list[PlanSummary], lkos: dict[str, int]
|
|
317
335
|
) -> None:
|
|
318
336
|
async with context.con.cursor() as cur:
|
|
319
337
|
for plan_batch in batched(plans, _BATCH_SIZE):
|
|
@@ -334,15 +352,15 @@ async def insert_plans(
|
|
|
334
352
|
""",
|
|
335
353
|
(
|
|
336
354
|
(
|
|
337
|
-
plan_id := plan
|
|
338
|
-
plan
|
|
339
|
-
plan
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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),
|
|
346
364
|
lkos[plan_id],
|
|
347
365
|
)
|
|
348
366
|
for plan in plan_batch
|
|
@@ -358,7 +376,7 @@ _LOAN_ACCOUNT_PERIODIC_VALUES = frozenset(
|
|
|
358
376
|
async def insert_accounts(
|
|
359
377
|
context: _Context,
|
|
360
378
|
plan_id: str,
|
|
361
|
-
accounts: list[
|
|
379
|
+
accounts: list[Account],
|
|
362
380
|
) -> None:
|
|
363
381
|
# YNAB's LoanAccountPeriodValues are untyped dicts so we need to turn them into a more standard sub-entry view
|
|
364
382
|
updated_accounts = [
|
|
@@ -375,7 +393,7 @@ async def insert_accounts(
|
|
|
375
393
|
]
|
|
376
394
|
}
|
|
377
395
|
| {k: v for k, v in account.items() if k not in _LOAN_ACCOUNT_PERIODIC_VALUES}
|
|
378
|
-
for account in accounts
|
|
396
|
+
for account in (acc.model_dump(mode="json") for acc in accounts)
|
|
379
397
|
]
|
|
380
398
|
|
|
381
399
|
await insert_nested_entries(
|
|
@@ -392,12 +410,12 @@ async def insert_accounts(
|
|
|
392
410
|
async def insert_category_groups(
|
|
393
411
|
context: _Context,
|
|
394
412
|
plan_id: str,
|
|
395
|
-
category_groups: list[
|
|
413
|
+
category_groups: list[CategoryGroupWithCategories],
|
|
396
414
|
) -> None:
|
|
397
415
|
await insert_nested_entries(
|
|
398
416
|
context,
|
|
399
417
|
plan_id,
|
|
400
|
-
category_groups,
|
|
418
|
+
[cg.model_dump(mode="json") for cg in category_groups],
|
|
401
419
|
"Categories",
|
|
402
420
|
"category_groups",
|
|
403
421
|
"categories",
|
|
@@ -408,24 +426,27 @@ async def insert_category_groups(
|
|
|
408
426
|
async def insert_payees(
|
|
409
427
|
context: _Context,
|
|
410
428
|
plan_id: str,
|
|
411
|
-
payees: list[
|
|
429
|
+
payees: list[Payee],
|
|
412
430
|
) -> None:
|
|
413
431
|
if not payees:
|
|
414
432
|
return
|
|
415
433
|
|
|
416
434
|
task_id = context.progress.add_task("Payees", total=len(payees))
|
|
417
|
-
await insert_entries(
|
|
435
|
+
await insert_entries(
|
|
436
|
+
context, "payees", plan_id, [p.model_dump(mode="json") for p in payees], task_id
|
|
437
|
+
)
|
|
418
438
|
|
|
419
439
|
|
|
420
440
|
async def insert_transactions(
|
|
421
441
|
context: _Context,
|
|
422
442
|
plan_id: str,
|
|
423
|
-
transactions: list[
|
|
443
|
+
transactions: list[TransactionDetail],
|
|
424
444
|
) -> None:
|
|
425
445
|
await insert_nested_entries(
|
|
426
446
|
context,
|
|
427
447
|
plan_id,
|
|
428
|
-
|
|
448
|
+
# by_alias=True properly renames 'var_date' to 'date'
|
|
449
|
+
[t.model_dump(mode="json", by_alias=True) for t in transactions],
|
|
429
450
|
"Transactions",
|
|
430
451
|
"transactions",
|
|
431
452
|
"subtransactions",
|
|
@@ -436,12 +457,12 @@ async def insert_transactions(
|
|
|
436
457
|
async def insert_scheduled_transactions(
|
|
437
458
|
context: _Context,
|
|
438
459
|
plan_id: str,
|
|
439
|
-
scheduled_transactions: list[
|
|
460
|
+
scheduled_transactions: list[ScheduledTransactionDetail],
|
|
440
461
|
) -> None:
|
|
441
462
|
await insert_nested_entries(
|
|
442
463
|
context,
|
|
443
464
|
plan_id,
|
|
444
|
-
scheduled_transactions,
|
|
465
|
+
[st.model_dump(mode="json") for st in scheduled_transactions],
|
|
445
466
|
"Scheduled Transactions",
|
|
446
467
|
"scheduled_transactions",
|
|
447
468
|
"subtransactions",
|
|
@@ -570,81 +591,118 @@ async def insert_entries(
|
|
|
570
591
|
context.progress.update(task_id, advance=len(values_batch))
|
|
571
592
|
|
|
572
593
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
plan_ids: list[str],
|
|
577
|
-
lkos: dict[str, int],
|
|
578
|
-
) -> list[Awaitable[dict[str, Any]]]:
|
|
579
|
-
return [
|
|
580
|
-
yc(f"plans/{plan_id}/{endpoint}", last_knowledge_of_server=lkos.get(plan_id))
|
|
581
|
-
for plan_id in plan_ids
|
|
582
|
-
]
|
|
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
|
|
583
597
|
|
|
584
598
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
+
)
|
|
589
607
|
|
|
590
608
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
+
)
|
|
604
637
|
|
|
605
638
|
|
|
606
|
-
@
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
async def __call__(
|
|
616
|
-
self, path: str, last_knowledge_of_server: int | None = None
|
|
617
|
-
) -> dict[str, Any]:
|
|
618
|
-
headers = {
|
|
619
|
-
"Authorization": f"Bearer {self.token}",
|
|
620
|
-
"Content-Type": "application/json",
|
|
621
|
-
}
|
|
622
|
-
url = urlunparse(
|
|
623
|
-
(
|
|
624
|
-
self.BASE_SCHEME,
|
|
625
|
-
self.BASE_NETLOC,
|
|
626
|
-
urljoin(self.BASE_PATH, path),
|
|
627
|
-
"",
|
|
628
|
-
urlencode(
|
|
629
|
-
{"last_knowledge_of_server": last_knowledge_of_server}
|
|
630
|
-
if last_knowledge_of_server
|
|
631
|
-
else {}
|
|
632
|
-
),
|
|
633
|
-
"",
|
|
634
|
-
)
|
|
635
|
-
)
|
|
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
|
|
636
648
|
|
|
637
|
-
for i in range(3):
|
|
638
|
-
try:
|
|
639
|
-
async with self.session.get(url, headers=headers) as resp:
|
|
640
|
-
body = await resp.text()
|
|
641
649
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
|
646
670
|
|
|
647
|
-
|
|
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)
|
|
648
706
|
|
|
649
707
|
|
|
650
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,7 +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
|
|
21
|
+
Requires-Dist: fasteners
|
|
20
22
|
Requires-Dist: rich>=14
|
|
23
|
+
Requires-Dist: tenacity
|
|
21
24
|
Dynamic: license-file
|
|
22
25
|
|
|
23
26
|
# sqlite-export-for-ynab
|