sqlite-export-for-ynab 2.3.0__tar.gz → 2.5.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 (24) hide show
  1. {sqlite_export_for_ynab-2.3.0/sqlite_export_for_ynab.egg-info → sqlite_export_for_ynab-2.5.0}/PKG-INFO +4 -2
  2. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/setup.cfg +4 -2
  3. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab/_main.py +267 -202
  4. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0/sqlite_export_for_ynab.egg-info}/PKG-INFO +4 -2
  5. sqlite_export_for_ynab-2.5.0/sqlite_export_for_ynab.egg-info/requires.txt +4 -0
  6. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/testing/fixtures.py +31 -12
  7. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/tests/_main_test.py +165 -81
  8. sqlite_export_for_ynab-2.3.0/sqlite_export_for_ynab.egg-info/requires.txt +0 -2
  9. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/LICENSE +0 -0
  10. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/README.md +0 -0
  11. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/pyproject.toml +0 -0
  12. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/setup.py +0 -0
  13. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab/__init__.py +0 -0
  14. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab/__main__.py +0 -0
  15. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab/ddl/__init__.py +0 -0
  16. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab/ddl/create-relations.sql +0 -0
  17. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab/ddl/drop-relations.sql +0 -0
  18. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab/py.typed +0 -0
  19. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab.egg-info/SOURCES.txt +0 -0
  20. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab.egg-info/dependency_links.txt +0 -0
  21. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab.egg-info/entry_points.txt +0 -0
  22. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab.egg-info/top_level.txt +0 -0
  23. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/testing/__init__.py +0 -0
  24. {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.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.5.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,7 +15,9 @@ 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: tldm
18
+ Requires-Dist: aiopathlib
19
+ Requires-Dist: aiosqlite
20
+ Requires-Dist: rich
19
21
  Dynamic: license-file
20
22
 
21
23
  # sqlite-export-for-ynab
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = sqlite_export_for_ynab
3
- version = 2.3.0
3
+ version = 2.5.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,7 +20,9 @@ keywords = ynab, sqlite, sql, budget, plan, cli
20
20
  packages = find:
21
21
  install_requires =
22
22
  aiohttp>=3
23
- tldm
23
+ aiopathlib
24
+ aiosqlite
25
+ rich
24
26
  python_requires = >=3.12
25
27
 
26
28
  [options.entry_points]
@@ -4,10 +4,12 @@ import argparse
4
4
  import asyncio
5
5
  import json
6
6
  import os
7
- import sqlite3
7
+ from contextlib import asynccontextmanager
8
+ from contextlib import contextmanager
8
9
  from dataclasses import dataclass
9
10
  from importlib import resources
10
11
  from importlib.metadata import version
12
+ from itertools import batched
11
13
  from pathlib import Path
12
14
  from typing import Any
13
15
  from typing import ClassVar
@@ -20,13 +22,19 @@ from urllib.parse import urljoin
20
22
  from urllib.parse import urlunparse
21
23
 
22
24
  import aiohttp
23
- from tldm import tldm
25
+ import aiosqlite
26
+ from aiopathlib import AsyncPath
27
+ from rich.progress import BarColumn
28
+ from rich.progress import MofNCompleteColumn
29
+ from rich.progress import Progress
30
+ from rich.progress import TaskID
31
+ from rich.progress import TextColumn
32
+ from rich.progress import TimeElapsedColumn
24
33
 
25
34
  from sqlite_export_for_ynab import ddl
26
35
 
27
36
  if TYPE_CHECKING:
28
- from collections.abc import Awaitable, Sequence
29
- from typing import Never
37
+ from collections.abc import AsyncIterator, Awaitable, Iterator, Sequence
30
38
 
31
39
 
32
40
  _EntryTable = (
@@ -40,6 +48,14 @@ _EntryTable = (
40
48
  | Literal["scheduled_transactions"]
41
49
  | Literal["scheduled_subtransactions"]
42
50
  )
51
+ _Endpoint = (
52
+ Literal["accounts"]
53
+ | Literal["categories"]
54
+ | Literal["payees"]
55
+ | Literal["transactions"]
56
+ | Literal["scheduled_transactions"]
57
+ )
58
+ _ENDPOINTS = tuple(lit.__args__[0] for lit in _Endpoint.__args__)
43
59
  _ALL_RELATIONS = frozenset(
44
60
  ("plans", "flat_transactions", "scheduled_flat_transactions")
45
61
  + tuple(lit.__args__[0] for lit in _EntryTable.__args__)
@@ -49,6 +65,15 @@ _ENV_TOKEN = "YNAB_PERSONAL_ACCESS_TOKEN"
49
65
 
50
66
  _PACKAGE = "sqlite-export-for-ynab"
51
67
 
68
+ _BATCH_SIZE = 100
69
+
70
+ _PROGRESS_COLUMNS = (
71
+ TextColumn("[progress.description]{task.description}"),
72
+ BarColumn(),
73
+ MofNCompleteColumn(),
74
+ TimeElapsedColumn(),
75
+ )
76
+
52
77
 
53
78
  def resolve_token(token_override: str | None = None) -> str:
54
79
  token = token_override or os.environ.get(_ENV_TOKEN)
@@ -113,67 +138,92 @@ def _print(message: str, *, quiet: bool) -> None:
113
138
  print(message)
114
139
 
115
140
 
141
+ @dataclass
142
+ class _Context:
143
+ session: aiohttp.ClientSession
144
+ progress: Progress
145
+ con: aiosqlite.Connection
146
+
147
+
148
+ @asynccontextmanager
149
+ async def _context(db: Path, *, quiet: bool) -> AsyncIterator[_Context]:
150
+ progress = Progress(*_PROGRESS_COLUMNS, disable=quiet)
151
+ async with aiohttp.ClientSession() as session, aiosqlite.connect(db) as con:
152
+ con.row_factory = aiosqlite.Row
153
+ yield _Context(session, progress, con)
154
+
155
+
156
+ @contextmanager
157
+ def _progress(context: _Context) -> Iterator[None]:
158
+ context.progress.start()
159
+ try:
160
+ yield
161
+ finally:
162
+ context.progress.stop()
163
+ for task_id in context.progress.task_ids:
164
+ context.progress.remove_task(task_id)
165
+
166
+
116
167
  async def sync(
117
168
  token: str, db: Path, full_refresh: bool, *, quiet: bool = False
118
169
  ) -> None:
119
- async with aiohttp.ClientSession() as session:
120
- plans = (await YnabClient(token, session)("plans"))["plans"]
170
+ await AsyncPath(db).parent.mkdir(parents=True, exist_ok=True)
121
171
 
122
- plan_ids = [plan["id"] for plan in plans]
172
+ async with _context(db, quiet=quiet) as context:
173
+ plans = (await YnabClient(token, context.session)("plans"))["plans"]
123
174
 
124
- if not db.exists():
125
- db.parent.mkdir(parents=True, exist_ok=True)
126
-
127
- with sqlite3.connect(db) as con:
128
- con.row_factory = sqlite3.Row
129
- cur = con.cursor()
175
+ plan_ids = [plan["id"] for plan in plans]
130
176
 
131
177
  if full_refresh:
132
178
  _print("Dropping relations...", quiet=quiet)
133
- cur.executescript(contents("drop-relations.sql"))
134
- con.commit()
179
+ async with context.con.cursor() as cur:
180
+ await cur.executescript(await contents("drop-relations.sql"))
181
+ await context.con.commit()
135
182
  _print("Done", quiet=quiet)
136
183
 
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)
184
+ async with context.con.cursor() as cur:
185
+ relations = await get_relations(cur)
186
+ if relations != _ALL_RELATIONS:
187
+ _print("Recreating relations...", quiet=quiet)
188
+ await cur.executescript(await contents("create-relations.sql"))
189
+ await context.con.commit()
190
+ _print("Done", quiet=quiet)
143
191
 
144
192
  _print("Fetching plan data...", quiet=quiet)
145
- lkos = get_last_knowledge_of_server(cur)
146
- async with aiohttp.ClientSession() as session:
147
- with tldm(desc="Plan Data", total=len(plans) * 5, disable=quiet) as pbar:
148
- yc = ProgressYnabClient(YnabClient(token, session), pbar)
149
-
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
158
- )
193
+ async with context.con.cursor() as cur:
194
+ lkos = await get_last_knowledge_of_server(cur)
195
+ with _progress(context):
196
+ yc = ProgressYnabClient(
197
+ YnabClient(token, context.session),
198
+ context,
199
+ context.progress.add_task(
200
+ "Plan Data", total=len(plans) * len(_ENDPOINTS)
201
+ ),
202
+ )
159
203
 
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 :]
170
-
171
- new_lkos = {
172
- plan_id: transaction_data["server_knowledge"]
173
- for plan_id, transaction_data in zip(
174
- plan_ids, all_txn_data, strict=True
204
+ endpoint_data = dict(
205
+ zip(
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,
175
214
  )
176
- }
215
+ )
216
+
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
+ }
177
227
  _print("Done", quiet=quiet)
178
228
 
179
229
  if (
@@ -186,95 +236,129 @@ async def sync(
186
236
  _print("No new data fetched", quiet=quiet)
187
237
  else:
188
238
  _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
239
+ 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,
205
250
  )
251
+ await context.con.commit()
206
252
  _print("Done", quiet=quiet)
207
253
 
208
254
 
209
- def contents(filename: str) -> str:
210
- return (resources.files(ddl) / filename).read_text()
255
+ async def contents(filename: str) -> str:
256
+ return await AsyncPath(resources.files(ddl) / filename).read_text()
211
257
 
212
258
 
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
- }
259
+ async def get_relations(cur: aiosqlite.Cursor) -> set[str]:
260
+ await cur.execute(
261
+ "SELECT name FROM sqlite_master WHERE type='table' OR type='view'"
262
+ )
263
+ return {t["name"] for t in await cur.fetchall()}
220
264
 
221
265
 
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
- }
266
+ async def get_last_knowledge_of_server(cur: aiosqlite.Cursor) -> dict[str, int]:
267
+ await cur.execute(
268
+ "SELECT id, last_knowledge_of_server FROM plans",
269
+ )
270
+ return {r["id"]: r["last_knowledge_of_server"] for r in await cur.fetchall()}
229
271
 
230
272
 
231
- def insert_plans(
232
- cur: sqlite3.Cursor, plans: list[dict[str, Any]], lkos: dict[str, int]
273
+ 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],
233
283
  ) -> 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],
284
+ await insert_plans(context, plans, new_lkos)
285
+ await asyncio.gather(
286
+ *(
287
+ insert_accounts(context, plan_id, account_data["accounts"])
288
+ for plan_id, account_data in zip(plan_ids, all_account_data, strict=True)
289
+ ),
290
+ *(
291
+ insert_category_groups(context, plan_id, cat_data["category_groups"])
292
+ for plan_id, cat_data in zip(plan_ids, all_cat_data, strict=True)
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)
297
+ ),
298
+ )
299
+ await asyncio.gather(
300
+ *(
301
+ insert_transactions(context, plan_id, txn_data["transactions"])
302
+ for plan_id, txn_data in zip(plan_ids, all_txn_data, strict=True)
303
+ ),
304
+ *(
305
+ insert_scheduled_transactions(
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
261
310
  )
262
- for plan in plans
263
311
  ),
264
312
  )
265
313
 
266
314
 
315
+ async def insert_plans(
316
+ context: _Context, plans: list[dict[str, Any]], lkos: dict[str, int]
317
+ ) -> None:
318
+ async with context.con.cursor() as cur:
319
+ for plan_batch in batched(plans, _BATCH_SIZE):
320
+ await cur.executemany(
321
+ """
322
+ INSERT OR REPLACE INTO plans (
323
+ id
324
+ , name
325
+ , currency_format_currency_symbol
326
+ , currency_format_decimal_digits
327
+ , currency_format_decimal_separator
328
+ , currency_format_display_symbol
329
+ , currency_format_group_separator
330
+ , currency_format_iso_code
331
+ , currency_format_symbol_first
332
+ , last_knowledge_of_server
333
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
334
+ """,
335
+ (
336
+ (
337
+ plan_id := plan["id"],
338
+ plan["name"],
339
+ plan["currency_format"]["currency_symbol"],
340
+ plan["currency_format"]["decimal_digits"],
341
+ plan["currency_format"]["decimal_separator"],
342
+ plan["currency_format"]["display_symbol"],
343
+ plan["currency_format"]["group_separator"],
344
+ plan["currency_format"]["iso_code"],
345
+ plan["currency_format"]["symbol_first"],
346
+ lkos[plan_id],
347
+ )
348
+ for plan in plan_batch
349
+ ),
350
+ )
351
+
352
+
267
353
  _LOAN_ACCOUNT_PERIODIC_VALUES = frozenset(
268
354
  ("debt_escrow_amounts", "debt_interest_rates", "debt_minimum_payments")
269
355
  )
270
356
 
271
357
 
272
- def insert_accounts(
273
- cur: sqlite3.Cursor,
358
+ async def insert_accounts(
359
+ context: _Context,
274
360
  plan_id: str,
275
361
  accounts: list[dict[str, Any]],
276
- *,
277
- quiet: bool = False,
278
362
  ) -> None:
279
363
  # YNAB's LoanAccountPeriodValues are untyped dicts so we need to turn them into a more standard sub-entry view
280
364
  updated_accounts = [
@@ -294,147 +378,127 @@ def insert_accounts(
294
378
  for account in accounts
295
379
  ]
296
380
 
297
- return insert_nested_entries(
298
- cur,
381
+ await insert_nested_entries(
382
+ context,
299
383
  plan_id,
300
384
  updated_accounts,
301
385
  "Accounts",
302
386
  "accounts",
303
387
  "account_periodic_values",
304
388
  "account_periodic_values",
305
- quiet=quiet,
306
389
  )
307
390
 
308
391
 
309
- def insert_category_groups(
310
- cur: sqlite3.Cursor,
392
+ async def insert_category_groups(
393
+ context: _Context,
311
394
  plan_id: str,
312
395
  category_groups: list[dict[str, Any]],
313
- *,
314
- quiet: bool = False,
315
396
  ) -> None:
316
- return insert_nested_entries(
317
- cur,
397
+ await insert_nested_entries(
398
+ context,
318
399
  plan_id,
319
400
  category_groups,
320
401
  "Categories",
321
402
  "category_groups",
322
403
  "categories",
323
404
  "categories",
324
- quiet=quiet,
325
405
  )
326
406
 
327
407
 
328
- def insert_payees(
329
- cur: sqlite3.Cursor,
408
+ async def insert_payees(
409
+ context: _Context,
330
410
  plan_id: str,
331
411
  payees: list[dict[str, Any]],
332
- *,
333
- quiet: bool = False,
334
412
  ) -> None:
335
413
  if not payees:
336
414
  return
337
415
 
338
- for payee in tldm(payees, desc="Payees", disable=quiet):
339
- insert_entry(cur, "payees", plan_id, payee)
416
+ task_id = context.progress.add_task("Payees", total=len(payees))
417
+ await insert_entries(context, "payees", plan_id, payees, task_id)
340
418
 
341
419
 
342
- def insert_transactions(
343
- cur: sqlite3.Cursor,
420
+ async def insert_transactions(
421
+ context: _Context,
344
422
  plan_id: str,
345
423
  transactions: list[dict[str, Any]],
346
- *,
347
- quiet: bool = False,
348
424
  ) -> None:
349
- return insert_nested_entries(
350
- cur,
425
+ await insert_nested_entries(
426
+ context,
351
427
  plan_id,
352
428
  transactions,
353
429
  "Transactions",
354
430
  "transactions",
355
431
  "subtransactions",
356
432
  "subtransactions",
357
- quiet=quiet,
358
433
  )
359
434
 
360
435
 
361
- def insert_scheduled_transactions(
362
- cur: sqlite3.Cursor,
436
+ async def insert_scheduled_transactions(
437
+ context: _Context,
363
438
  plan_id: str,
364
439
  scheduled_transactions: list[dict[str, Any]],
365
- *,
366
- quiet: bool = False,
367
440
  ) -> None:
368
- return insert_nested_entries(
369
- cur,
441
+ await insert_nested_entries(
442
+ context,
370
443
  plan_id,
371
444
  scheduled_transactions,
372
445
  "Scheduled Transactions",
373
446
  "scheduled_transactions",
374
447
  "subtransactions",
375
448
  "scheduled_subtransactions",
376
- quiet=quiet,
377
449
  )
378
450
 
379
451
 
380
452
  @overload
381
- def insert_nested_entries(
382
- cur: sqlite3.Cursor,
453
+ async def insert_nested_entries(
454
+ context: _Context,
383
455
  plan_id: str,
384
456
  entries: list[dict[str, Any]],
385
457
  desc: Literal["Accounts"],
386
458
  entries_name: Literal["accounts"],
387
459
  subentries_name: Literal["account_periodic_values"],
388
460
  subentries_table_name: Literal["account_periodic_values"],
389
- *,
390
- quiet: bool = False,
391
461
  ) -> None: ...
392
462
 
393
463
 
394
464
  @overload
395
- def insert_nested_entries(
396
- cur: sqlite3.Cursor,
465
+ async def insert_nested_entries(
466
+ context: _Context,
397
467
  plan_id: str,
398
468
  entries: list[dict[str, Any]],
399
469
  desc: Literal["Categories"],
400
470
  entries_name: Literal["category_groups"],
401
471
  subentries_name: Literal["categories"],
402
472
  subentries_table_name: Literal["categories"],
403
- *,
404
- quiet: bool = False,
405
473
  ) -> None: ...
406
474
 
407
475
 
408
476
  @overload
409
- def insert_nested_entries(
410
- cur: sqlite3.Cursor,
477
+ async def insert_nested_entries(
478
+ context: _Context,
411
479
  plan_id: str,
412
480
  entries: list[dict[str, Any]],
413
481
  desc: Literal["Transactions"],
414
482
  entries_name: Literal["transactions"],
415
483
  subentries_name: Literal["subtransactions"],
416
484
  subentries_table_name: Literal["subtransactions"],
417
- *,
418
- quiet: bool = False,
419
485
  ) -> None: ...
420
486
 
421
487
 
422
488
  @overload
423
- def insert_nested_entries(
424
- cur: sqlite3.Cursor,
489
+ async def insert_nested_entries(
490
+ context: _Context,
425
491
  plan_id: str,
426
492
  entries: list[dict[str, Any]],
427
493
  desc: Literal["Scheduled Transactions"],
428
494
  entries_name: Literal["scheduled_transactions"],
429
495
  subentries_name: Literal["subtransactions"],
430
496
  subentries_table_name: Literal["scheduled_subtransactions"],
431
- *,
432
- quiet: bool = False,
433
497
  ) -> None: ...
434
498
 
435
499
 
436
- def insert_nested_entries(
437
- cur: sqlite3.Cursor,
500
+ async def insert_nested_entries(
501
+ context: _Context,
438
502
  plan_id: str,
439
503
  entries: list[dict[str, Any]],
440
504
  desc: (
@@ -460,55 +524,55 @@ def insert_nested_entries(
460
524
  | Literal["subtransactions"]
461
525
  | Literal["scheduled_subtransactions"]
462
526
  ),
463
- *,
464
- quiet: bool = False,
465
527
  ) -> None:
466
528
  if not entries:
467
529
  return
468
530
 
469
- with tldm(
470
- total=sum(1 + len(e[subentries_name]) for e in entries),
471
- desc=desc,
472
- disable=quiet,
473
- ) 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()
531
+ task_id = context.progress.add_task(
532
+ desc, total=sum(1 + len(e[subentries_name]) for e in entries)
533
+ )
534
+ await insert_entries(
535
+ context,
536
+ entries_name,
537
+ plan_id,
538
+ [{k: v for k, v in entry.items() if k != subentries_name} for entry in entries],
539
+ task_id,
540
+ )
541
+ await insert_entries(
542
+ context,
543
+ subentries_table_name,
544
+ plan_id,
545
+ [subentry for entry in entries for subentry in entry[subentries_name]],
546
+ task_id,
547
+ )
486
548
 
487
549
 
488
- def insert_entry(
489
- cur: sqlite3.Cursor,
550
+ async def insert_entries(
551
+ context: _Context,
490
552
  table: _EntryTable,
491
553
  plan_id: str,
492
- entry: dict[str, Any],
554
+ entries: list[dict[str, Any]],
555
+ task_id: TaskID,
493
556
  ) -> None:
494
- ekeys, evalues = zip(*entry.items(), strict=True)
495
- keys, values = ekeys + ("plan_id",), evalues + (plan_id,)
557
+ if not entries:
558
+ return
496
559
 
497
- cur.execute(
498
- f"INSERT OR REPLACE INTO {table} ({', '.join(keys)}) VALUES ({', '.join('?' * len(values))})",
499
- values,
500
- )
560
+ entry_keys = tuple(entries[0])
561
+ sql = f"INSERT OR REPLACE INTO {table} ({', '.join(entry_keys + ('plan_id',))}) VALUES ({', '.join('?' * (len(entry_keys) + 1))})"
562
+
563
+ async with context.con.cursor() as cur:
564
+ for entry_batch in batched(entries, _BATCH_SIZE):
565
+ values_batch = [
566
+ tuple(entry[key] for key in entry_keys) + (plan_id,)
567
+ for entry in entry_batch
568
+ ]
569
+ await cur.executemany(sql, values_batch)
570
+ context.progress.update(task_id, advance=len(values_batch))
501
571
 
502
572
 
503
573
  def jobs(
504
574
  yc: SupportsYnabClient,
505
- endpoint: (
506
- Literal["accounts"]
507
- | Literal["categories"]
508
- | Literal["payees"]
509
- | Literal["transactions"]
510
- | Literal["scheduled_transactions"]
511
- ),
575
+ endpoint: _Endpoint,
512
576
  plan_ids: list[str],
513
577
  lkos: dict[str, int],
514
578
  ) -> list[Awaitable[dict[str, Any]]]:
@@ -527,7 +591,8 @@ class SupportsYnabClient(Protocol):
527
591
  @dataclass
528
592
  class ProgressYnabClient:
529
593
  yc: YnabClient
530
- pbar: tldm[Never]
594
+ context: _Context
595
+ task_id: TaskID
531
596
 
532
597
  async def __call__(
533
598
  self, path: str, last_knowledge_of_server: int | None = None
@@ -535,7 +600,7 @@ class ProgressYnabClient:
535
600
  try:
536
601
  return await self.yc(path, last_knowledge_of_server)
537
602
  finally:
538
- self.pbar.update()
603
+ self.context.progress.update(self.task_id, advance=1)
539
604
 
540
605
 
541
606
  @dataclass
@@ -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.5.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,7 +15,9 @@ 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: tldm
18
+ Requires-Dist: aiopathlib
19
+ Requires-Dist: aiosqlite
20
+ Requires-Dist: rich
19
21
  Dynamic: license-file
20
22
 
21
23
  # sqlite-export-for-ynab
@@ -0,0 +1,4 @@
1
+ aiohttp>=3
2
+ aiopathlib
3
+ aiosqlite
4
+ rich
@@ -1,14 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import re
4
- import sqlite3
5
4
  from typing import Any
5
+ from typing import TYPE_CHECKING
6
6
  from uuid import uuid4
7
7
 
8
8
  import pytest
9
9
  from aioresponses import aioresponses
10
10
 
11
- from sqlite_export_for_ynab._main import contents
11
+ if TYPE_CHECKING:
12
+ import aiosqlite
12
13
 
13
14
  PLAN_ID_1 = str(uuid4())
14
15
  PLAN_ID_2 = str(uuid4())
@@ -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,22 +353,13 @@ 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"))
334
- yield cursor
335
-
336
-
337
356
  @pytest.fixture
338
357
  def mock_aioresponses():
339
358
  with aioresponses() as m:
340
359
  yield m
341
360
 
342
361
 
343
- def strip_nones(d: sqlite3.Row) -> dict[str, Any]:
362
+ def strip_nones(d: aiosqlite.Row) -> dict[str, Any]:
344
363
  return {k: v for k, v in dict(d).items() if v is not None}
345
364
 
346
365
 
@@ -2,20 +2,23 @@ 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
+ import pytest_asyncio
12
13
  from aiohttp.http_exceptions import HttpProcessingError
13
- from tldm import tldm
14
+ from rich.progress import Progress
14
15
 
15
16
  from sqlite_export_for_ynab import default_db_path
16
17
  from sqlite_export_for_ynab._main import _ALL_RELATIONS
18
+ from sqlite_export_for_ynab._main import _Context
17
19
  from sqlite_export_for_ynab._main import _ENV_TOKEN
18
20
  from sqlite_export_for_ynab._main import _PACKAGE
21
+ from sqlite_export_for_ynab._main import _PROGRESS_COLUMNS
19
22
  from sqlite_export_for_ynab._main import contents
20
23
  from sqlite_export_for_ynab._main import get_last_knowledge_of_server
21
24
  from sqlite_export_for_ynab._main import get_relations
@@ -49,7 +52,6 @@ from testing.fixtures import CATEGORY_NAME_1
49
52
  from testing.fixtures import CATEGORY_NAME_2
50
53
  from testing.fixtures import CATEGORY_NAME_3
51
54
  from testing.fixtures import CATEGORY_NAME_4
52
- from testing.fixtures import cur
53
55
  from testing.fixtures import EXAMPLE_ENDPOINT_RE
54
56
  from testing.fixtures import LKOS
55
57
  from testing.fixtures import mock_aioresponses
@@ -80,6 +82,24 @@ from testing.fixtures import TRANSACTIONS
80
82
  from testing.fixtures import TRANSACTIONS_ENDPOINT_RE
81
83
 
82
84
 
85
+ async def fetchall(con, query):
86
+ async with con.cursor() as cur:
87
+ await cur.execute(query)
88
+ return await cur.fetchall()
89
+
90
+
91
+ @pytest_asyncio.fixture
92
+ async def context():
93
+ with Progress(*_PROGRESS_COLUMNS, disable=True) as progress:
94
+ async with (
95
+ aiohttp.ClientSession(loop=asyncio.get_event_loop()) as session,
96
+ aiosqlite.connect(":memory:") as con,
97
+ ):
98
+ con.row_factory = aiosqlite.Row
99
+ await con.executescript(await contents("create-relations.sql"))
100
+ yield _Context(session, progress, con)
101
+
102
+
83
103
  @pytest.mark.parametrize(
84
104
  ("xdg_data_home", "expected_prefix"),
85
105
  (
@@ -92,22 +112,26 @@ def test_default_db_path(monkeypatch, xdg_data_home, expected_prefix):
92
112
  assert default_db_path() == expected_prefix / "sqlite-export-for-ynab" / "db.sqlite"
93
113
 
94
114
 
95
- @pytest.mark.usefixtures(cur.__name__)
96
- def test_get_relations(cur):
97
- assert get_relations(cur) == _ALL_RELATIONS
115
+ @pytest.mark.asyncio
116
+ async def test_get_relations(context):
117
+ async with context.con.cursor() as cur:
118
+ assert await get_relations(cur) == _ALL_RELATIONS
98
119
 
99
120
 
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
121
+ @pytest.mark.asyncio
122
+ async def test_get_last_knowledge_of_server(context):
123
+ await insert_plans(context, PLANS, LKOS)
124
+ async with context.con.cursor() as cur:
125
+ assert await get_last_knowledge_of_server(cur) == LKOS
104
126
 
105
127
 
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()] == [
128
+ @pytest.mark.asyncio
129
+ async def test_insert_plans(context):
130
+ await insert_plans(context, PLANS, LKOS)
131
+ assert [
132
+ strip_nones(d)
133
+ for d in await fetchall(context.con, "SELECT * FROM plans ORDER BY name")
134
+ ] == [
111
135
  {
112
136
  "id": PLAN_ID_1,
113
137
  "name": PLANS[0]["name"],
@@ -135,15 +159,17 @@ def test_insert_plans(cur):
135
159
  ]
136
160
 
137
161
 
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()] == [
162
+ @pytest.mark.asyncio
163
+ async def test_insert_accounts(context):
164
+ await insert_accounts(context, PLAN_ID_1, [])
165
+ assert not await fetchall(context.con, "SELECT * FROM accounts")
166
+ assert not await fetchall(context.con, "SELECT * FROM account_periodic_values")
167
+
168
+ await insert_accounts(context, PLAN_ID_1, ACCOUNTS)
169
+ assert [
170
+ strip_nones(d)
171
+ for d in await fetchall(context.con, "SELECT * FROM accounts ORDER BY name")
172
+ ] == [
147
173
  {
148
174
  "id": ACCOUNT_ID_1,
149
175
  "plan_id": PLAN_ID_1,
@@ -170,8 +196,12 @@ def test_insert_accounts(cur):
170
196
  },
171
197
  ]
172
198
 
173
- cur.execute("SELECT * FROM account_periodic_values ORDER BY name")
174
- assert [strip_nones(d) for d in cur.fetchall()] == [
199
+ assert [
200
+ strip_nones(d)
201
+ for d in await fetchall(
202
+ context.con, "SELECT * FROM account_periodic_values ORDER BY name"
203
+ )
204
+ ] == [
175
205
  {
176
206
  "account_id": ACCOUNT_ID_1,
177
207
  "plan_id": PLAN_ID_1,
@@ -189,15 +219,19 @@ def test_insert_accounts(cur):
189
219
  ]
190
220
 
191
221
 
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()] == [
222
+ @pytest.mark.asyncio
223
+ async def test_insert_category_groups(context):
224
+ await insert_category_groups(context, PLAN_ID_1, [])
225
+ assert not await fetchall(context.con, "SELECT * FROM category_groups")
226
+ assert not await fetchall(context.con, "SELECT * FROM categories")
227
+
228
+ await insert_category_groups(context, PLAN_ID_1, CATEGORY_GROUPS)
229
+ assert [
230
+ strip_nones(d)
231
+ for d in await fetchall(
232
+ context.con, "SELECT * FROM category_groups ORDER BY name"
233
+ )
234
+ ] == [
201
235
  {
202
236
  "id": CATEGORY_GROUP_ID_1,
203
237
  "name": CATEGORY_GROUP_NAME_1,
@@ -210,8 +244,10 @@ def test_insert_category_groups(cur):
210
244
  },
211
245
  ]
212
246
 
213
- cur.execute("SELECT * FROM categories ORDER BY name")
214
- assert [strip_nones(d) for d in cur.fetchall()] == [
247
+ assert [
248
+ strip_nones(d)
249
+ for d in await fetchall(context.con, "SELECT * FROM categories ORDER BY name")
250
+ ] == [
215
251
  {
216
252
  "id": CATEGORY_ID_1,
217
253
  "category_group_id": CATEGORY_GROUP_ID_1,
@@ -276,14 +312,41 @@ def test_insert_category_groups(cur):
276
312
  ]
277
313
 
278
314
 
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()
315
+ @pytest.mark.asyncio
316
+ async def test_insert_category_group_without_categories(context):
317
+ category_group = {
318
+ "id": CATEGORY_GROUP_ID_1,
319
+ "name": CATEGORY_GROUP_NAME_1,
320
+ "categories": [],
321
+ }
322
+
323
+ await insert_category_groups(context, PLAN_ID_1, [category_group])
324
+
325
+ assert [
326
+ strip_nones(d)
327
+ for d in await fetchall(
328
+ context.con, "SELECT * FROM category_groups ORDER BY name"
329
+ )
330
+ ] == [
331
+ {
332
+ "id": CATEGORY_GROUP_ID_1,
333
+ "name": CATEGORY_GROUP_NAME_1,
334
+ "plan_id": PLAN_ID_1,
335
+ },
336
+ ]
337
+ assert not await fetchall(context.con, "SELECT * FROM categories")
338
+
283
339
 
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()] == [
340
+ @pytest.mark.asyncio
341
+ async def test_insert_payees(context):
342
+ await insert_payees(context, PLAN_ID_1, [])
343
+ assert not await fetchall(context.con, "SELECT * FROM payees")
344
+
345
+ await insert_payees(context, PLAN_ID_1, PAYEES)
346
+ assert [
347
+ strip_nones(d)
348
+ for d in await fetchall(context.con, "SELECT * FROM payees ORDER BY name")
349
+ ] == [
287
350
  {
288
351
  "id": PAYEE_ID_1,
289
352
  "plan_id": PLAN_ID_1,
@@ -297,16 +360,18 @@ def test_insert_payees(cur):
297
360
  ]
298
361
 
299
362
 
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()] == [
363
+ @pytest.mark.asyncio
364
+ async def test_insert_transactions(context):
365
+ await insert_transactions(context, PLAN_ID_1, [])
366
+ assert not await fetchall(context.con, "SELECT * FROM transactions")
367
+ assert not await fetchall(context.con, "SELECT * FROM subtransactions")
368
+
369
+ await insert_category_groups(context, PLAN_ID_1, CATEGORY_GROUPS)
370
+ await insert_transactions(context, PLAN_ID_1, TRANSACTIONS)
371
+ assert [
372
+ strip_nones(d)
373
+ for d in await fetchall(context.con, "SELECT * FROM transactions ORDER BY date")
374
+ ] == [
310
375
  {
311
376
  "id": TRANSACTION_ID_1,
312
377
  "plan_id": PLAN_ID_1,
@@ -345,8 +410,12 @@ def test_insert_transactions(cur):
345
410
  },
346
411
  ]
347
412
 
348
- cur.execute("SELECT * FROM subtransactions ORDER BY amount")
349
- assert [strip_nones(d) for d in cur.fetchall()] == [
413
+ assert [
414
+ strip_nones(d)
415
+ for d in await fetchall(
416
+ context.con, "SELECT * FROM subtransactions ORDER BY amount"
417
+ )
418
+ ] == [
350
419
  {
351
420
  "id": SUBTRANSACTION_ID_1,
352
421
  "transaction_id": TRANSACTION_ID_1,
@@ -371,8 +440,12 @@ def test_insert_transactions(cur):
371
440
  },
372
441
  ]
373
442
 
374
- cur.execute("SELECT * FROM flat_transactions ORDER BY amount")
375
- assert [strip_nones(d) for d in cur.fetchall()] == [
443
+ assert [
444
+ strip_nones(d)
445
+ for d in await fetchall(
446
+ context.con, "SELECT * FROM flat_transactions ORDER BY amount"
447
+ )
448
+ ] == [
376
449
  {
377
450
  "transaction_id": TRANSACTION_ID_1,
378
451
  "subtransaction_id": SUBTRANSACTION_ID_1,
@@ -404,16 +477,20 @@ def test_insert_transactions(cur):
404
477
  ]
405
478
 
406
479
 
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()] == [
480
+ @pytest.mark.asyncio
481
+ async def test_insert_scheduled_transactions(context):
482
+ await insert_scheduled_transactions(context, PLAN_ID_1, [])
483
+ assert not await fetchall(context.con, "SELECT * FROM scheduled_transactions")
484
+ assert not await fetchall(context.con, "SELECT * FROM scheduled_subtransactions")
485
+
486
+ await insert_category_groups(context, PLAN_ID_1, CATEGORY_GROUPS)
487
+ await insert_scheduled_transactions(context, PLAN_ID_1, SCHEDULED_TRANSACTIONS)
488
+ assert [
489
+ strip_nones(d)
490
+ for d in await fetchall(
491
+ context.con, "SELECT * FROM scheduled_transactions ORDER BY amount"
492
+ )
493
+ ] == [
417
494
  {
418
495
  "id": SCHEDULED_TRANSACTION_ID_1,
419
496
  "plan_id": PLAN_ID_1,
@@ -449,8 +526,12 @@ def test_insert_scheduled_transactions(cur):
449
526
  },
450
527
  ]
451
528
 
452
- cur.execute("SELECT * FROM scheduled_subtransactions ORDER BY amount")
453
- assert [strip_nones(d) for d in cur.fetchall()] == [
529
+ assert [
530
+ strip_nones(d)
531
+ for d in await fetchall(
532
+ context.con, "SELECT * FROM scheduled_subtransactions ORDER BY amount"
533
+ )
534
+ ] == [
454
535
  {
455
536
  "id": SCHEDULED_SUBTRANSACTION_ID_1,
456
537
  "scheduled_transaction_id": SCHEDULED_TRANSACTION_ID_1,
@@ -475,8 +556,12 @@ def test_insert_scheduled_transactions(cur):
475
556
  },
476
557
  ]
477
558
 
478
- cur.execute("SELECT * FROM scheduled_flat_transactions ORDER BY amount")
479
- assert [strip_nones(d) for d in cur.fetchall()] == [
559
+ assert [
560
+ strip_nones(d)
561
+ for d in await fetchall(
562
+ context.con, "SELECT * FROM scheduled_flat_transactions ORDER BY amount"
563
+ )
564
+ ] == [
480
565
  {
481
566
  "transaction_id": SCHEDULED_TRANSACTION_ID_3,
482
567
  "plan_id": PLAN_ID_1,
@@ -523,14 +608,13 @@ def test_insert_scheduled_transactions(cur):
523
608
 
524
609
  @pytest.mark.asyncio
525
610
  @pytest.mark.usefixtures(mock_aioresponses.__name__)
526
- async def test_progress_ynab_client_ok(mock_aioresponses):
611
+ async def test_progress_ynab_client_ok(context, mock_aioresponses):
527
612
  expected = {"example": [{"id": 1, "value": 2}, {"id": 3, "value": 4}]}
528
613
  mock_aioresponses.get(EXAMPLE_ENDPOINT_RE, body=json.dumps({"data": expected}))
529
614
 
530
- with tldm(disable=True) as pbar:
531
- async with aiohttp.ClientSession(loop=asyncio.get_event_loop()) as session:
532
- pyc = ProgressYnabClient(YnabClient(TOKEN, session), pbar)
533
- entries = await pyc("example")
615
+ task_id = context.progress.add_task("Example", total=1)
616
+ pyc = ProgressYnabClient(YnabClient(TOKEN, context.session), context, task_id)
617
+ entries = await pyc("example")
534
618
 
535
619
  assert entries == expected
536
620
 
@@ -650,8 +734,8 @@ async def test_sync_no_data(tmp_path, mock_aioresponses):
650
734
 
651
735
  # create the db and tables to exercise all code branches
652
736
  db = tmp_path / "db.sqlite"
653
- with sqlite3.connect(db) as con:
654
- con.executescript(contents("create-relations.sql"))
737
+ async with aiosqlite.connect(db) as con:
738
+ await con.executescript(await contents("create-relations.sql"))
655
739
 
656
740
  await sync(TOKEN, db, False)
657
741
 
@@ -694,8 +778,8 @@ async def test_sync_no_data_quiet(tmp_path, mock_aioresponses, capsys):
694
778
  )
695
779
 
696
780
  db = tmp_path / "db.sqlite"
697
- with sqlite3.connect(db) as con:
698
- con.executescript(contents("create-relations.sql"))
781
+ async with aiosqlite.connect(db) as con:
782
+ await con.executescript(await contents("create-relations.sql"))
699
783
 
700
784
  await sync(TOKEN, db, False, quiet=True)
701
785