sqlite-export-for-ynab 2.3.0__tar.gz → 2.4.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.
Files changed (23) hide show
  1. {sqlite_export_for_ynab-2.3.0/sqlite_export_for_ynab.egg-info → sqlite_export_for_ynab-2.4.0}/PKG-INFO +2 -1
  2. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/setup.cfg +2 -1
  3. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/_main.py +206 -153
  4. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0/sqlite_export_for_ynab.egg-info}/PKG-INFO +2 -1
  5. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab.egg-info/requires.txt +1 -0
  6. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/testing/fixtures.py +41 -8
  7. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/tests/_main_test.py +144 -73
  8. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/LICENSE +0 -0
  9. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/README.md +0 -0
  10. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/pyproject.toml +0 -0
  11. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/setup.py +0 -0
  12. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/__init__.py +0 -0
  13. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/__main__.py +0 -0
  14. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/ddl/__init__.py +0 -0
  15. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/ddl/create-relations.sql +0 -0
  16. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/ddl/drop-relations.sql +0 -0
  17. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/py.typed +0 -0
  18. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab.egg-info/SOURCES.txt +0 -0
  19. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab.egg-info/dependency_links.txt +0 -0
  20. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab.egg-info/entry_points.txt +0 -0
  21. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab.egg-info/top_level.txt +0 -0
  22. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.0}/testing/__init__.py +0 -0
  23. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.4.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.0
3
+ Version: 2.4.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
@@ -15,6 +15,7 @@ Requires-Python: >=3.12
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  Requires-Dist: aiohttp>=3
18
+ Requires-Dist: aiosqlite
18
19
  Requires-Dist: tldm
19
20
  Dynamic: license-file
20
21
 
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = sqlite_export_for_ynab
3
- version = 2.3.0
3
+ version = 2.4.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
@@ -20,6 +20,7 @@ keywords = ynab, sqlite, sql, budget, plan, cli
20
20
  packages = find:
21
21
  install_requires =
22
22
  aiohttp>=3
23
+ aiosqlite
23
24
  tldm
24
25
  python_requires = >=3.12
25
26
 
@@ -4,10 +4,10 @@ import argparse
4
4
  import asyncio
5
5
  import json
6
6
  import os
7
- import sqlite3
8
7
  from dataclasses import dataclass
9
8
  from importlib import resources
10
9
  from importlib.metadata import version
10
+ from itertools import batched
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
  from typing import ClassVar
@@ -20,6 +20,7 @@ from urllib.parse import urljoin
20
20
  from urllib.parse import urlunparse
21
21
 
22
22
  import aiohttp
23
+ import aiosqlite
23
24
  from tldm import tldm
24
25
 
25
26
  from sqlite_export_for_ynab import ddl
@@ -40,6 +41,14 @@ _EntryTable = (
40
41
  | Literal["scheduled_transactions"]
41
42
  | Literal["scheduled_subtransactions"]
42
43
  )
44
+ _Endpoint = (
45
+ Literal["accounts"]
46
+ | Literal["categories"]
47
+ | Literal["payees"]
48
+ | Literal["transactions"]
49
+ | Literal["scheduled_transactions"]
50
+ )
51
+ _ENDPOINTS = tuple(lit.__args__[0] for lit in _Endpoint.__args__)
43
52
  _ALL_RELATIONS = frozenset(
44
53
  ("plans", "flat_transactions", "scheduled_flat_transactions")
45
54
  + tuple(lit.__args__[0] for lit in _EntryTable.__args__)
@@ -49,6 +58,8 @@ _ENV_TOKEN = "YNAB_PERSONAL_ACCESS_TOKEN"
49
58
 
50
59
  _PACKAGE = "sqlite-export-for-ynab"
51
60
 
61
+ _BATCH_SIZE = 100
62
+
52
63
 
53
64
  def resolve_token(token_override: str | None = None) -> str:
54
65
  token = token_override or os.environ.get(_ENV_TOKEN)
@@ -124,49 +135,51 @@ async def sync(
124
135
  if not db.exists():
125
136
  db.parent.mkdir(parents=True, exist_ok=True)
126
137
 
127
- with sqlite3.connect(db) as con:
128
- con.row_factory = sqlite3.Row
129
- cur = con.cursor()
138
+ async with aiosqlite.connect(db) as con:
139
+ con.row_factory = aiosqlite.Row
130
140
 
131
141
  if full_refresh:
132
142
  _print("Dropping relations...", quiet=quiet)
133
- cur.executescript(contents("drop-relations.sql"))
134
- con.commit()
143
+ async with con.cursor() as cur:
144
+ await cur.executescript(contents("drop-relations.sql"))
145
+ await con.commit()
135
146
  _print("Done", quiet=quiet)
136
147
 
137
- relations = get_relations(cur)
138
- if relations != _ALL_RELATIONS:
139
- _print("Recreating relations...", quiet=quiet)
140
- cur.executescript(contents("create-relations.sql"))
141
- con.commit()
142
- _print("Done", quiet=quiet)
148
+ async with con.cursor() as cur:
149
+ relations = await get_relations(cur)
150
+ if relations != _ALL_RELATIONS:
151
+ _print("Recreating relations...", quiet=quiet)
152
+ await cur.executescript(contents("create-relations.sql"))
153
+ await con.commit()
154
+ _print("Done", quiet=quiet)
143
155
 
144
156
  _print("Fetching plan data...", quiet=quiet)
145
- lkos = get_last_knowledge_of_server(cur)
157
+ async with con.cursor() as cur:
158
+ lkos = await get_last_knowledge_of_server(cur)
146
159
  async with aiohttp.ClientSession() as session:
147
- with tldm(desc="Plan Data", total=len(plans) * 5, disable=quiet) as pbar:
160
+ with tldm(
161
+ desc="Plan Data", total=len(plans) * len(_ENDPOINTS), disable=quiet
162
+ ) as pbar:
148
163
  yc = ProgressYnabClient(YnabClient(token, session), pbar)
149
164
 
150
- account_jobs = jobs(yc, "accounts", plan_ids, lkos)
151
- cat_jobs = jobs(yc, "categories", plan_ids, lkos)
152
- payee_jobs = jobs(yc, "payees", plan_ids, lkos)
153
- txn_jobs = jobs(yc, "transactions", plan_ids, lkos)
154
- sched_txn_jobs = jobs(yc, "scheduled_transactions", plan_ids, lkos)
155
-
156
- data = await asyncio.gather(
157
- *account_jobs, *cat_jobs, *payee_jobs, *txn_jobs, *sched_txn_jobs
165
+ endpoint_data = dict(
166
+ zip(
167
+ _ENDPOINTS,
168
+ await asyncio.gather(
169
+ *(
170
+ asyncio.gather(*jobs(yc, endpoint, plan_ids, lkos))
171
+ for endpoint in _ENDPOINTS
172
+ )
173
+ ),
174
+ strict=True,
175
+ )
158
176
  )
159
177
 
160
- la = len(account_jobs)
161
- lc = len(cat_jobs)
162
- lp = len(payee_jobs)
163
- lt = len(txn_jobs)
164
-
165
- all_account_data = data[:la]
166
- all_cat_data = data[la : la + lc]
167
- all_payee_data = data[la + lc : la + lc + lp]
168
- all_txn_data = data[la + lc + lp : la + lc + lp + lt]
169
- all_sched_txn_data = data[la + lc + lp + lt :]
178
+ all_account_data = endpoint_data["accounts"]
179
+ all_cat_data = endpoint_data["categories"]
180
+ all_payee_data = endpoint_data["payees"]
181
+ all_txn_data = endpoint_data["transactions"]
182
+ all_sched_txn_data = endpoint_data["scheduled_transactions"]
170
183
 
171
184
  new_lkos = {
172
185
  plan_id: transaction_data["server_knowledge"]
@@ -186,23 +199,58 @@ async def sync(
186
199
  _print("No new data fetched", quiet=quiet)
187
200
  else:
188
201
  _print("Inserting plan data...", quiet=quiet)
189
- insert_plans(cur, plans, new_lkos)
190
- for plan_id, account_data in zip(plan_ids, all_account_data, strict=True):
191
- insert_accounts(cur, plan_id, account_data["accounts"], quiet=quiet)
192
- for plan_id, cat_data in zip(plan_ids, all_cat_data, strict=True):
193
- insert_category_groups(
194
- cur, plan_id, cat_data["category_groups"], quiet=quiet
195
- )
196
- for plan_id, payee_data in zip(plan_ids, all_payee_data, strict=True):
197
- insert_payees(cur, plan_id, payee_data["payees"], quiet=quiet)
198
- for plan_id, txn_data in zip(plan_ids, all_txn_data, strict=True):
199
- insert_transactions(cur, plan_id, txn_data["transactions"], quiet=quiet)
200
- for plan_id, sched_txn_data in zip(
201
- plan_ids, all_sched_txn_data, strict=True
202
- ):
203
- insert_scheduled_transactions(
204
- cur, plan_id, sched_txn_data["scheduled_transactions"], quiet=quiet
205
- )
202
+ await insert_plans(con, plans, new_lkos)
203
+ await asyncio.gather(
204
+ *(
205
+ insert_accounts(
206
+ con,
207
+ plan_id,
208
+ account_data["accounts"],
209
+ quiet=quiet,
210
+ )
211
+ for plan_id, account_data in zip(
212
+ plan_ids, all_account_data, strict=True
213
+ )
214
+ ),
215
+ *(
216
+ insert_category_groups(
217
+ con,
218
+ plan_id,
219
+ cat_data["category_groups"],
220
+ quiet=quiet,
221
+ )
222
+ for plan_id, cat_data in zip(plan_ids, all_cat_data, strict=True)
223
+ ),
224
+ *(
225
+ insert_payees(con, plan_id, payee_data["payees"], quiet=quiet)
226
+ for plan_id, payee_data in zip(
227
+ plan_ids, all_payee_data, strict=True
228
+ )
229
+ ),
230
+ )
231
+ await asyncio.gather(
232
+ *(
233
+ insert_transactions(
234
+ con,
235
+ plan_id,
236
+ txn_data["transactions"],
237
+ quiet=quiet,
238
+ )
239
+ for plan_id, txn_data in zip(plan_ids, all_txn_data, strict=True)
240
+ ),
241
+ *(
242
+ insert_scheduled_transactions(
243
+ con,
244
+ plan_id,
245
+ sched_txn_data["scheduled_transactions"],
246
+ quiet=quiet,
247
+ )
248
+ for plan_id, sched_txn_data in zip(
249
+ plan_ids, all_sched_txn_data, strict=True
250
+ )
251
+ ),
252
+ )
253
+ await con.commit()
206
254
  _print("Done", quiet=quiet)
207
255
 
208
256
 
@@ -210,58 +258,56 @@ def contents(filename: str) -> str:
210
258
  return (resources.files(ddl) / filename).read_text()
211
259
 
212
260
 
213
- def get_relations(cur: sqlite3.Cursor) -> set[str]:
214
- return {
215
- t["name"]
216
- for t in cur.execute(
217
- "SELECT name FROM sqlite_master WHERE type='table' OR type='view'"
218
- ).fetchall()
219
- }
261
+ async def get_relations(cur: aiosqlite.Cursor) -> set[str]:
262
+ await cur.execute(
263
+ "SELECT name FROM sqlite_master WHERE type='table' OR type='view'"
264
+ )
265
+ return {t["name"] for t in await cur.fetchall()}
220
266
 
221
267
 
222
- def get_last_knowledge_of_server(cur: sqlite3.Cursor) -> dict[str, int]:
223
- return {
224
- r["id"]: r["last_knowledge_of_server"]
225
- for r in cur.execute(
226
- "SELECT id, last_knowledge_of_server FROM plans",
227
- ).fetchall()
228
- }
268
+ async def get_last_knowledge_of_server(cur: aiosqlite.Cursor) -> dict[str, int]:
269
+ await cur.execute(
270
+ "SELECT id, last_knowledge_of_server FROM plans",
271
+ )
272
+ return {r["id"]: r["last_knowledge_of_server"] for r in await cur.fetchall()}
229
273
 
230
274
 
231
- def insert_plans(
232
- cur: sqlite3.Cursor, plans: list[dict[str, Any]], lkos: dict[str, int]
275
+ async def insert_plans(
276
+ con: aiosqlite.Connection, plans: list[dict[str, Any]], lkos: dict[str, int]
233
277
  ) -> None:
234
- cur.executemany(
235
- """
236
- INSERT OR REPLACE INTO plans (
237
- id
238
- , name
239
- , currency_format_currency_symbol
240
- , currency_format_decimal_digits
241
- , currency_format_decimal_separator
242
- , currency_format_display_symbol
243
- , currency_format_group_separator
244
- , currency_format_iso_code
245
- , currency_format_symbol_first
246
- , last_knowledge_of_server
247
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
248
- """,
249
- (
250
- (
251
- plan_id := plan["id"],
252
- plan["name"],
253
- plan["currency_format"]["currency_symbol"],
254
- plan["currency_format"]["decimal_digits"],
255
- plan["currency_format"]["decimal_separator"],
256
- plan["currency_format"]["display_symbol"],
257
- plan["currency_format"]["group_separator"],
258
- plan["currency_format"]["iso_code"],
259
- plan["currency_format"]["symbol_first"],
260
- lkos[plan_id],
278
+ async with con.cursor() as cur:
279
+ for plan_batch in batched(plans, _BATCH_SIZE):
280
+ await cur.executemany(
281
+ """
282
+ INSERT OR REPLACE INTO plans (
283
+ id
284
+ , name
285
+ , currency_format_currency_symbol
286
+ , currency_format_decimal_digits
287
+ , currency_format_decimal_separator
288
+ , currency_format_display_symbol
289
+ , currency_format_group_separator
290
+ , currency_format_iso_code
291
+ , currency_format_symbol_first
292
+ , last_knowledge_of_server
293
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
294
+ """,
295
+ (
296
+ (
297
+ plan_id := plan["id"],
298
+ plan["name"],
299
+ plan["currency_format"]["currency_symbol"],
300
+ plan["currency_format"]["decimal_digits"],
301
+ plan["currency_format"]["decimal_separator"],
302
+ plan["currency_format"]["display_symbol"],
303
+ plan["currency_format"]["group_separator"],
304
+ plan["currency_format"]["iso_code"],
305
+ plan["currency_format"]["symbol_first"],
306
+ lkos[plan_id],
307
+ )
308
+ for plan in plan_batch
309
+ ),
261
310
  )
262
- for plan in plans
263
- ),
264
- )
265
311
 
266
312
 
267
313
  _LOAN_ACCOUNT_PERIODIC_VALUES = frozenset(
@@ -269,8 +315,8 @@ _LOAN_ACCOUNT_PERIODIC_VALUES = frozenset(
269
315
  )
270
316
 
271
317
 
272
- def insert_accounts(
273
- cur: sqlite3.Cursor,
318
+ async def insert_accounts(
319
+ con: aiosqlite.Connection,
274
320
  plan_id: str,
275
321
  accounts: list[dict[str, Any]],
276
322
  *,
@@ -294,8 +340,8 @@ def insert_accounts(
294
340
  for account in accounts
295
341
  ]
296
342
 
297
- return insert_nested_entries(
298
- cur,
343
+ await insert_nested_entries(
344
+ con,
299
345
  plan_id,
300
346
  updated_accounts,
301
347
  "Accounts",
@@ -306,15 +352,15 @@ def insert_accounts(
306
352
  )
307
353
 
308
354
 
309
- def insert_category_groups(
310
- cur: sqlite3.Cursor,
355
+ async def insert_category_groups(
356
+ con: aiosqlite.Connection,
311
357
  plan_id: str,
312
358
  category_groups: list[dict[str, Any]],
313
359
  *,
314
360
  quiet: bool = False,
315
361
  ) -> None:
316
- return insert_nested_entries(
317
- cur,
362
+ await insert_nested_entries(
363
+ con,
318
364
  plan_id,
319
365
  category_groups,
320
366
  "Categories",
@@ -325,8 +371,8 @@ def insert_category_groups(
325
371
  )
326
372
 
327
373
 
328
- def insert_payees(
329
- cur: sqlite3.Cursor,
374
+ async def insert_payees(
375
+ con: aiosqlite.Connection,
330
376
  plan_id: str,
331
377
  payees: list[dict[str, Any]],
332
378
  *,
@@ -335,19 +381,19 @@ def insert_payees(
335
381
  if not payees:
336
382
  return
337
383
 
338
- for payee in tldm(payees, desc="Payees", disable=quiet):
339
- insert_entry(cur, "payees", plan_id, payee)
384
+ with tldm(total=len(payees), desc="Payees", disable=quiet) as pbar:
385
+ await insert_entries(con, "payees", plan_id, payees, pbar)
340
386
 
341
387
 
342
- def insert_transactions(
343
- cur: sqlite3.Cursor,
388
+ async def insert_transactions(
389
+ con: aiosqlite.Connection,
344
390
  plan_id: str,
345
391
  transactions: list[dict[str, Any]],
346
392
  *,
347
393
  quiet: bool = False,
348
394
  ) -> None:
349
- return insert_nested_entries(
350
- cur,
395
+ await insert_nested_entries(
396
+ con,
351
397
  plan_id,
352
398
  transactions,
353
399
  "Transactions",
@@ -358,15 +404,15 @@ def insert_transactions(
358
404
  )
359
405
 
360
406
 
361
- def insert_scheduled_transactions(
362
- cur: sqlite3.Cursor,
407
+ async def insert_scheduled_transactions(
408
+ con: aiosqlite.Connection,
363
409
  plan_id: str,
364
410
  scheduled_transactions: list[dict[str, Any]],
365
411
  *,
366
412
  quiet: bool = False,
367
413
  ) -> None:
368
- return insert_nested_entries(
369
- cur,
414
+ await insert_nested_entries(
415
+ con,
370
416
  plan_id,
371
417
  scheduled_transactions,
372
418
  "Scheduled Transactions",
@@ -378,8 +424,8 @@ def insert_scheduled_transactions(
378
424
 
379
425
 
380
426
  @overload
381
- def insert_nested_entries(
382
- cur: sqlite3.Cursor,
427
+ async def insert_nested_entries(
428
+ con: aiosqlite.Connection,
383
429
  plan_id: str,
384
430
  entries: list[dict[str, Any]],
385
431
  desc: Literal["Accounts"],
@@ -392,8 +438,8 @@ def insert_nested_entries(
392
438
 
393
439
 
394
440
  @overload
395
- def insert_nested_entries(
396
- cur: sqlite3.Cursor,
441
+ async def insert_nested_entries(
442
+ con: aiosqlite.Connection,
397
443
  plan_id: str,
398
444
  entries: list[dict[str, Any]],
399
445
  desc: Literal["Categories"],
@@ -406,8 +452,8 @@ def insert_nested_entries(
406
452
 
407
453
 
408
454
  @overload
409
- def insert_nested_entries(
410
- cur: sqlite3.Cursor,
455
+ async def insert_nested_entries(
456
+ con: aiosqlite.Connection,
411
457
  plan_id: str,
412
458
  entries: list[dict[str, Any]],
413
459
  desc: Literal["Transactions"],
@@ -420,8 +466,8 @@ def insert_nested_entries(
420
466
 
421
467
 
422
468
  @overload
423
- def insert_nested_entries(
424
- cur: sqlite3.Cursor,
469
+ async def insert_nested_entries(
470
+ con: aiosqlite.Connection,
425
471
  plan_id: str,
426
472
  entries: list[dict[str, Any]],
427
473
  desc: Literal["Scheduled Transactions"],
@@ -433,8 +479,8 @@ def insert_nested_entries(
433
479
  ) -> None: ...
434
480
 
435
481
 
436
- def insert_nested_entries(
437
- cur: sqlite3.Cursor,
482
+ async def insert_nested_entries(
483
+ con: aiosqlite.Connection,
438
484
  plan_id: str,
439
485
  entries: list[dict[str, Any]],
440
486
  desc: (
@@ -471,44 +517,51 @@ def insert_nested_entries(
471
517
  desc=desc,
472
518
  disable=quiet,
473
519
  ) as pbar:
474
- for entry in entries:
475
- insert_entry(
476
- cur,
477
- entries_name,
478
- plan_id,
479
- {k: v for k, v in entry.items() if k != subentries_name},
480
- )
481
- pbar.update()
482
-
483
- for subentry in entry[subentries_name]:
484
- insert_entry(cur, subentries_table_name, plan_id, subentry)
485
- pbar.update()
520
+ await insert_entries(
521
+ con,
522
+ entries_name,
523
+ plan_id,
524
+ [
525
+ {k: v for k, v in entry.items() if k != subentries_name}
526
+ for entry in entries
527
+ ],
528
+ pbar,
529
+ )
530
+ await insert_entries(
531
+ con,
532
+ subentries_table_name,
533
+ plan_id,
534
+ [subentry for entry in entries for subentry in entry[subentries_name]],
535
+ pbar,
536
+ )
486
537
 
487
538
 
488
- def insert_entry(
489
- cur: sqlite3.Cursor,
539
+ async def insert_entries(
540
+ con: aiosqlite.Connection,
490
541
  table: _EntryTable,
491
542
  plan_id: str,
492
- entry: dict[str, Any],
543
+ entries: list[dict[str, Any]],
544
+ pbar: tldm[Never],
493
545
  ) -> None:
494
- ekeys, evalues = zip(*entry.items(), strict=True)
495
- keys, values = ekeys + ("plan_id",), evalues + (plan_id,)
546
+ if not entries:
547
+ return
496
548
 
497
- cur.execute(
498
- f"INSERT OR REPLACE INTO {table} ({', '.join(keys)}) VALUES ({', '.join('?' * len(values))})",
499
- values,
500
- )
549
+ entry_keys = tuple(entries[0])
550
+ sql = f"INSERT OR REPLACE INTO {table} ({', '.join(entry_keys + ('plan_id',))}) VALUES ({', '.join('?' * (len(entry_keys) + 1))})"
551
+
552
+ async with con.cursor() as cur:
553
+ for entry_batch in batched(entries, _BATCH_SIZE):
554
+ values_batch = [
555
+ tuple(entry[key] for key in entry_keys) + (plan_id,)
556
+ for entry in entry_batch
557
+ ]
558
+ await cur.executemany(sql, values_batch)
559
+ pbar.update(len(values_batch))
501
560
 
502
561
 
503
562
  def jobs(
504
563
  yc: SupportsYnabClient,
505
- endpoint: (
506
- Literal["accounts"]
507
- | Literal["categories"]
508
- | Literal["payees"]
509
- | Literal["transactions"]
510
- | Literal["scheduled_transactions"]
511
- ),
564
+ endpoint: _Endpoint,
512
565
  plan_ids: list[str],
513
566
  lkos: dict[str, int],
514
567
  ) -> list[Awaitable[dict[str, Any]]]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlite_export_for_ynab
3
- Version: 2.3.0
3
+ Version: 2.4.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
@@ -15,6 +15,7 @@ Requires-Python: >=3.12
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  Requires-Dist: aiohttp>=3
18
+ Requires-Dist: aiosqlite
18
19
  Requires-Dist: tldm
19
20
  Dynamic: license-file
20
21
 
@@ -1,11 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import re
4
- import sqlite3
5
4
  from typing import Any
6
5
  from uuid import uuid4
7
6
 
7
+ import aiosqlite
8
8
  import pytest
9
+ import pytest_asyncio
9
10
  from aioresponses import aioresponses
10
11
 
11
12
  from sqlite_export_for_ynab._main import contents
@@ -144,6 +145,15 @@ CATEGORY_GROUPS: list[dict[str, Any]] = [
144
145
  "activity_currency": 1.0,
145
146
  "budgeted_formatted": "$10.25",
146
147
  "budgeted_currency": 10.25,
148
+ "goal_target_formatted": None,
149
+ "goal_target_currency": None,
150
+ "goal_under_funded_formatted": None,
151
+ "goal_under_funded_currency": None,
152
+ "goal_overall_funded_formatted": None,
153
+ "goal_overall_funded_currency": None,
154
+ "goal_overall_left_formatted": None,
155
+ "goal_overall_left_currency": None,
156
+ "goal_target_date": None,
147
157
  },
148
158
  ],
149
159
  },
@@ -162,6 +172,15 @@ CATEGORY_GROUPS: list[dict[str, Any]] = [
162
172
  "activity_currency": 7.5,
163
173
  "budgeted_formatted": "$15.00",
164
174
  "budgeted_currency": 15.0,
175
+ "goal_target_formatted": None,
176
+ "goal_target_currency": None,
177
+ "goal_under_funded_formatted": None,
178
+ "goal_under_funded_currency": None,
179
+ "goal_overall_funded_formatted": None,
180
+ "goal_overall_funded_currency": None,
181
+ "goal_overall_left_formatted": None,
182
+ "goal_overall_left_currency": None,
183
+ "goal_target_date": None,
165
184
  },
166
185
  {
167
186
  "id": CATEGORY_ID_4,
@@ -174,6 +193,15 @@ CATEGORY_GROUPS: list[dict[str, Any]] = [
174
193
  "activity_currency": 19.0,
175
194
  "budgeted_formatted": "$20.00",
176
195
  "budgeted_currency": 20.0,
196
+ "goal_target_formatted": None,
197
+ "goal_target_currency": None,
198
+ "goal_under_funded_formatted": None,
199
+ "goal_under_funded_currency": None,
200
+ "goal_overall_funded_formatted": None,
201
+ "goal_overall_funded_currency": None,
202
+ "goal_overall_left_formatted": None,
203
+ "goal_overall_left_currency": None,
204
+ "goal_target_date": None,
177
205
  },
178
206
  ],
179
207
  },
@@ -325,12 +353,17 @@ SCHEDULED_TRANSACTIONS: list[dict[str, Any]] = [
325
353
  ]
326
354
 
327
355
 
328
- @pytest.fixture
329
- def cur():
330
- with sqlite3.connect(":memory:") as con:
331
- con.row_factory = sqlite3.Row
332
- cursor = con.cursor()
333
- cursor.executescript(contents("create-relations.sql"))
356
+ @pytest_asyncio.fixture
357
+ async def con():
358
+ async with aiosqlite.connect(":memory:") as con:
359
+ con.row_factory = aiosqlite.Row
360
+ await con.executescript(contents("create-relations.sql"))
361
+ yield con
362
+
363
+
364
+ @pytest_asyncio.fixture
365
+ async def cur(con):
366
+ async with con.cursor() as cursor:
334
367
  yield cursor
335
368
 
336
369
 
@@ -340,7 +373,7 @@ def mock_aioresponses():
340
373
  yield m
341
374
 
342
375
 
343
- def strip_nones(d: sqlite3.Row) -> dict[str, Any]:
376
+ def strip_nones(d: aiosqlite.Row) -> dict[str, Any]:
344
377
  return {k: v for k, v in dict(d).items() if v is not None}
345
378
 
346
379
 
@@ -2,12 +2,12 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import json
5
- import sqlite3
6
5
  from configparser import ConfigParser
7
6
  from pathlib import Path
8
7
  from unittest.mock import patch
9
8
 
10
9
  import aiohttp
10
+ import aiosqlite
11
11
  import pytest
12
12
  from aiohttp.http_exceptions import HttpProcessingError
13
13
  from tldm import tldm
@@ -49,6 +49,7 @@ from testing.fixtures import CATEGORY_NAME_1
49
49
  from testing.fixtures import CATEGORY_NAME_2
50
50
  from testing.fixtures import CATEGORY_NAME_3
51
51
  from testing.fixtures import CATEGORY_NAME_4
52
+ from testing.fixtures import con
52
53
  from testing.fixtures import cur
53
54
  from testing.fixtures import EXAMPLE_ENDPOINT_RE
54
55
  from testing.fixtures import LKOS
@@ -80,6 +81,12 @@ from testing.fixtures import TRANSACTIONS
80
81
  from testing.fixtures import TRANSACTIONS_ENDPOINT_RE
81
82
 
82
83
 
84
+ async def fetchall(con, query):
85
+ async with con.cursor() as cur:
86
+ await cur.execute(query)
87
+ return await cur.fetchall()
88
+
89
+
83
90
  @pytest.mark.parametrize(
84
91
  ("xdg_data_home", "expected_prefix"),
85
92
  (
@@ -93,21 +100,26 @@ def test_default_db_path(monkeypatch, xdg_data_home, expected_prefix):
93
100
 
94
101
 
95
102
  @pytest.mark.usefixtures(cur.__name__)
96
- def test_get_relations(cur):
97
- assert get_relations(cur) == _ALL_RELATIONS
103
+ @pytest.mark.asyncio
104
+ async def test_get_relations(cur):
105
+ assert await get_relations(cur) == _ALL_RELATIONS
98
106
 
99
107
 
100
- @pytest.mark.usefixtures(cur.__name__)
101
- def test_get_last_knowledge_of_server(cur):
102
- insert_plans(cur, PLANS, LKOS)
103
- assert get_last_knowledge_of_server(cur) == LKOS
108
+ @pytest.mark.usefixtures(con.__name__)
109
+ @pytest.mark.asyncio
110
+ async def test_get_last_knowledge_of_server(con):
111
+ await insert_plans(con, PLANS, LKOS)
112
+ async with con.cursor() as cur:
113
+ assert await get_last_knowledge_of_server(cur) == LKOS
104
114
 
105
115
 
106
- @pytest.mark.usefixtures(cur.__name__)
107
- def test_insert_plans(cur):
108
- insert_plans(cur, PLANS, LKOS)
109
- cur.execute("SELECT * FROM plans ORDER BY name")
110
- assert [strip_nones(d) for d in cur.fetchall()] == [
116
+ @pytest.mark.usefixtures(con.__name__)
117
+ @pytest.mark.asyncio
118
+ async def test_insert_plans(con):
119
+ await insert_plans(con, PLANS, LKOS)
120
+ assert [
121
+ strip_nones(d) for d in await fetchall(con, "SELECT * FROM plans ORDER BY name")
122
+ ] == [
111
123
  {
112
124
  "id": PLAN_ID_1,
113
125
  "name": PLANS[0]["name"],
@@ -135,15 +147,18 @@ def test_insert_plans(cur):
135
147
  ]
136
148
 
137
149
 
138
- @pytest.mark.usefixtures(cur.__name__)
139
- def test_insert_accounts(cur):
140
- insert_accounts(cur, PLAN_ID_1, [])
141
- assert not cur.execute("SELECT * FROM accounts").fetchall()
142
- assert not cur.execute("SELECT * FROM account_periodic_values").fetchall()
143
-
144
- insert_accounts(cur, PLAN_ID_1, ACCOUNTS)
145
- cur.execute("SELECT * FROM accounts ORDER BY name")
146
- assert [strip_nones(d) for d in cur.fetchall()] == [
150
+ @pytest.mark.usefixtures(con.__name__)
151
+ @pytest.mark.asyncio
152
+ async def test_insert_accounts(con):
153
+ await insert_accounts(con, PLAN_ID_1, [])
154
+ assert not await fetchall(con, "SELECT * FROM accounts")
155
+ assert not await fetchall(con, "SELECT * FROM account_periodic_values")
156
+
157
+ await insert_accounts(con, PLAN_ID_1, ACCOUNTS)
158
+ assert [
159
+ strip_nones(d)
160
+ for d in await fetchall(con, "SELECT * FROM accounts ORDER BY name")
161
+ ] == [
147
162
  {
148
163
  "id": ACCOUNT_ID_1,
149
164
  "plan_id": PLAN_ID_1,
@@ -170,8 +185,12 @@ def test_insert_accounts(cur):
170
185
  },
171
186
  ]
172
187
 
173
- cur.execute("SELECT * FROM account_periodic_values ORDER BY name")
174
- assert [strip_nones(d) for d in cur.fetchall()] == [
188
+ assert [
189
+ strip_nones(d)
190
+ for d in await fetchall(
191
+ con, "SELECT * FROM account_periodic_values ORDER BY name"
192
+ )
193
+ ] == [
175
194
  {
176
195
  "account_id": ACCOUNT_ID_1,
177
196
  "plan_id": PLAN_ID_1,
@@ -189,15 +208,18 @@ def test_insert_accounts(cur):
189
208
  ]
190
209
 
191
210
 
192
- @pytest.mark.usefixtures(cur.__name__)
193
- def test_insert_category_groups(cur):
194
- insert_category_groups(cur, PLAN_ID_1, [])
195
- assert not cur.execute("SELECT * FROM category_groups").fetchall()
196
- assert not cur.execute("SELECT * FROM categories").fetchall()
197
-
198
- insert_category_groups(cur, PLAN_ID_1, CATEGORY_GROUPS)
199
- cur.execute("SELECT * FROM category_groups ORDER BY name")
200
- assert [strip_nones(d) for d in cur.fetchall()] == [
211
+ @pytest.mark.usefixtures(con.__name__)
212
+ @pytest.mark.asyncio
213
+ async def test_insert_category_groups(con):
214
+ await insert_category_groups(con, PLAN_ID_1, [])
215
+ assert not await fetchall(con, "SELECT * FROM category_groups")
216
+ assert not await fetchall(con, "SELECT * FROM categories")
217
+
218
+ await insert_category_groups(con, PLAN_ID_1, CATEGORY_GROUPS)
219
+ assert [
220
+ strip_nones(d)
221
+ for d in await fetchall(con, "SELECT * FROM category_groups ORDER BY name")
222
+ ] == [
201
223
  {
202
224
  "id": CATEGORY_GROUP_ID_1,
203
225
  "name": CATEGORY_GROUP_NAME_1,
@@ -210,8 +232,10 @@ def test_insert_category_groups(cur):
210
232
  },
211
233
  ]
212
234
 
213
- cur.execute("SELECT * FROM categories ORDER BY name")
214
- assert [strip_nones(d) for d in cur.fetchall()] == [
235
+ assert [
236
+ strip_nones(d)
237
+ for d in await fetchall(con, "SELECT * FROM categories ORDER BY name")
238
+ ] == [
215
239
  {
216
240
  "id": CATEGORY_ID_1,
217
241
  "category_group_id": CATEGORY_GROUP_ID_1,
@@ -276,14 +300,41 @@ def test_insert_category_groups(cur):
276
300
  ]
277
301
 
278
302
 
279
- @pytest.mark.usefixtures(cur.__name__)
280
- def test_insert_payees(cur):
281
- insert_payees(cur, PLAN_ID_1, [])
282
- assert not cur.execute("SELECT * FROM payees").fetchall()
303
+ @pytest.mark.usefixtures(con.__name__)
304
+ @pytest.mark.asyncio
305
+ async def test_insert_category_group_without_categories(con):
306
+ category_group = {
307
+ "id": CATEGORY_GROUP_ID_1,
308
+ "name": CATEGORY_GROUP_NAME_1,
309
+ "categories": [],
310
+ }
311
+
312
+ await insert_category_groups(con, PLAN_ID_1, [category_group])
313
+
314
+ assert [
315
+ strip_nones(d)
316
+ for d in await fetchall(con, "SELECT * FROM category_groups ORDER BY name")
317
+ ] == [
318
+ {
319
+ "id": CATEGORY_GROUP_ID_1,
320
+ "name": CATEGORY_GROUP_NAME_1,
321
+ "plan_id": PLAN_ID_1,
322
+ },
323
+ ]
324
+ assert not await fetchall(con, "SELECT * FROM categories")
283
325
 
284
- insert_payees(cur, PLAN_ID_1, PAYEES)
285
- cur.execute("SELECT * FROM payees ORDER BY name")
286
- assert [strip_nones(d) for d in cur.fetchall()] == [
326
+
327
+ @pytest.mark.usefixtures(con.__name__)
328
+ @pytest.mark.asyncio
329
+ async def test_insert_payees(con):
330
+ await insert_payees(con, PLAN_ID_1, [])
331
+ assert not await fetchall(con, "SELECT * FROM payees")
332
+
333
+ await insert_payees(con, PLAN_ID_1, PAYEES)
334
+ assert [
335
+ strip_nones(d)
336
+ for d in await fetchall(con, "SELECT * FROM payees ORDER BY name")
337
+ ] == [
287
338
  {
288
339
  "id": PAYEE_ID_1,
289
340
  "plan_id": PLAN_ID_1,
@@ -297,16 +348,19 @@ def test_insert_payees(cur):
297
348
  ]
298
349
 
299
350
 
300
- @pytest.mark.usefixtures(cur.__name__)
301
- def test_insert_transactions(cur):
302
- insert_transactions(cur, PLAN_ID_1, [])
303
- assert not cur.execute("SELECT * FROM transactions").fetchall()
304
- assert not cur.execute("SELECT * FROM subtransactions").fetchall()
305
-
306
- insert_category_groups(cur, PLAN_ID_1, CATEGORY_GROUPS)
307
- insert_transactions(cur, PLAN_ID_1, TRANSACTIONS)
308
- cur.execute("SELECT * FROM transactions ORDER BY date")
309
- assert [strip_nones(d) for d in cur.fetchall()] == [
351
+ @pytest.mark.usefixtures(con.__name__)
352
+ @pytest.mark.asyncio
353
+ async def test_insert_transactions(con):
354
+ await insert_transactions(con, PLAN_ID_1, [])
355
+ assert not await fetchall(con, "SELECT * FROM transactions")
356
+ assert not await fetchall(con, "SELECT * FROM subtransactions")
357
+
358
+ await insert_category_groups(con, PLAN_ID_1, CATEGORY_GROUPS)
359
+ await insert_transactions(con, PLAN_ID_1, TRANSACTIONS)
360
+ assert [
361
+ strip_nones(d)
362
+ for d in await fetchall(con, "SELECT * FROM transactions ORDER BY date")
363
+ ] == [
310
364
  {
311
365
  "id": TRANSACTION_ID_1,
312
366
  "plan_id": PLAN_ID_1,
@@ -345,8 +399,10 @@ def test_insert_transactions(cur):
345
399
  },
346
400
  ]
347
401
 
348
- cur.execute("SELECT * FROM subtransactions ORDER BY amount")
349
- assert [strip_nones(d) for d in cur.fetchall()] == [
402
+ assert [
403
+ strip_nones(d)
404
+ for d in await fetchall(con, "SELECT * FROM subtransactions ORDER BY amount")
405
+ ] == [
350
406
  {
351
407
  "id": SUBTRANSACTION_ID_1,
352
408
  "transaction_id": TRANSACTION_ID_1,
@@ -371,8 +427,10 @@ def test_insert_transactions(cur):
371
427
  },
372
428
  ]
373
429
 
374
- cur.execute("SELECT * FROM flat_transactions ORDER BY amount")
375
- assert [strip_nones(d) for d in cur.fetchall()] == [
430
+ assert [
431
+ strip_nones(d)
432
+ for d in await fetchall(con, "SELECT * FROM flat_transactions ORDER BY amount")
433
+ ] == [
376
434
  {
377
435
  "transaction_id": TRANSACTION_ID_1,
378
436
  "subtransaction_id": SUBTRANSACTION_ID_1,
@@ -404,16 +462,21 @@ def test_insert_transactions(cur):
404
462
  ]
405
463
 
406
464
 
407
- @pytest.mark.usefixtures(cur.__name__)
408
- def test_insert_scheduled_transactions(cur):
409
- insert_scheduled_transactions(cur, PLAN_ID_1, [])
410
- assert not cur.execute("SELECT * FROM scheduled_transactions").fetchall()
411
- assert not cur.execute("SELECT * FROM scheduled_subtransactions").fetchall()
412
-
413
- insert_category_groups(cur, PLAN_ID_1, CATEGORY_GROUPS)
414
- insert_scheduled_transactions(cur, PLAN_ID_1, SCHEDULED_TRANSACTIONS)
415
- cur.execute("SELECT * FROM scheduled_transactions ORDER BY amount")
416
- assert [strip_nones(d) for d in cur.fetchall()] == [
465
+ @pytest.mark.usefixtures(con.__name__)
466
+ @pytest.mark.asyncio
467
+ async def test_insert_scheduled_transactions(con):
468
+ await insert_scheduled_transactions(con, PLAN_ID_1, [])
469
+ assert not await fetchall(con, "SELECT * FROM scheduled_transactions")
470
+ assert not await fetchall(con, "SELECT * FROM scheduled_subtransactions")
471
+
472
+ await insert_category_groups(con, PLAN_ID_1, CATEGORY_GROUPS)
473
+ await insert_scheduled_transactions(con, PLAN_ID_1, SCHEDULED_TRANSACTIONS)
474
+ assert [
475
+ strip_nones(d)
476
+ for d in await fetchall(
477
+ con, "SELECT * FROM scheduled_transactions ORDER BY amount"
478
+ )
479
+ ] == [
417
480
  {
418
481
  "id": SCHEDULED_TRANSACTION_ID_1,
419
482
  "plan_id": PLAN_ID_1,
@@ -449,8 +512,12 @@ def test_insert_scheduled_transactions(cur):
449
512
  },
450
513
  ]
451
514
 
452
- cur.execute("SELECT * FROM scheduled_subtransactions ORDER BY amount")
453
- assert [strip_nones(d) for d in cur.fetchall()] == [
515
+ assert [
516
+ strip_nones(d)
517
+ for d in await fetchall(
518
+ con, "SELECT * FROM scheduled_subtransactions ORDER BY amount"
519
+ )
520
+ ] == [
454
521
  {
455
522
  "id": SCHEDULED_SUBTRANSACTION_ID_1,
456
523
  "scheduled_transaction_id": SCHEDULED_TRANSACTION_ID_1,
@@ -475,8 +542,12 @@ def test_insert_scheduled_transactions(cur):
475
542
  },
476
543
  ]
477
544
 
478
- cur.execute("SELECT * FROM scheduled_flat_transactions ORDER BY amount")
479
- assert [strip_nones(d) for d in cur.fetchall()] == [
545
+ assert [
546
+ strip_nones(d)
547
+ for d in await fetchall(
548
+ con, "SELECT * FROM scheduled_flat_transactions ORDER BY amount"
549
+ )
550
+ ] == [
480
551
  {
481
552
  "transaction_id": SCHEDULED_TRANSACTION_ID_3,
482
553
  "plan_id": PLAN_ID_1,
@@ -650,8 +721,8 @@ async def test_sync_no_data(tmp_path, mock_aioresponses):
650
721
 
651
722
  # create the db and tables to exercise all code branches
652
723
  db = tmp_path / "db.sqlite"
653
- with sqlite3.connect(db) as con:
654
- con.executescript(contents("create-relations.sql"))
724
+ async with aiosqlite.connect(db) as con:
725
+ await con.executescript(contents("create-relations.sql"))
655
726
 
656
727
  await sync(TOKEN, db, False)
657
728
 
@@ -694,8 +765,8 @@ async def test_sync_no_data_quiet(tmp_path, mock_aioresponses, capsys):
694
765
  )
695
766
 
696
767
  db = tmp_path / "db.sqlite"
697
- with sqlite3.connect(db) as con:
698
- con.executescript(contents("create-relations.sql"))
768
+ async with aiosqlite.connect(db) as con:
769
+ await con.executescript(contents("create-relations.sql"))
699
770
 
700
771
  await sync(TOKEN, db, False, quiet=True)
701
772