sqlite-export-for-ynab 2.2.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.2.0/sqlite_export_for_ynab.egg-info → sqlite_export_for_ynab-2.4.0}/PKG-INFO +4 -2
  2. {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/README.md +2 -1
  3. {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/setup.cfg +2 -1
  4. {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/_main.py +206 -153
  5. {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/ddl/create-relations.sql +1 -1
  6. {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0/sqlite_export_for_ynab.egg-info}/PKG-INFO +4 -2
  7. {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab.egg-info/requires.txt +1 -0
  8. {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/testing/fixtures.py +44 -8
  9. {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/tests/_main_test.py +147 -86
  10. {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/LICENSE +0 -0
  11. {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/pyproject.toml +0 -0
  12. {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/setup.py +0 -0
  13. {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/__init__.py +0 -0
  14. {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/__main__.py +0 -0
  15. {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/ddl/__init__.py +0 -0
  16. {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/ddl/drop-relations.sql +0 -0
  17. {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/py.typed +0 -0
  18. {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab.egg-info/SOURCES.txt +0 -0
  19. {sqlite_export_for_ynab-2.2.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.2.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.2.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.2.0 → sqlite_export_for_ynab-2.4.0}/testing/__init__.py +0 -0
  23. {sqlite_export_for_ynab-2.2.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.2.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
 
@@ -82,7 +83,7 @@ The relations are defined in [create-relations.sql](sqlite_export_for_ynab/ddl/c
82
83
 
83
84
  1. Some objects are pulled out into their own tables so they can be more cleanly modeled in SQLite (ex: subtransactions, loan account periodic values).
84
85
  1. Foreign keys are added as needed (ex: plan ID, transaction ID) so data across plans remains separate.
85
- 1. Two new views called `flat_transactions` and `scheduled_flat_transactions`. These allow you to query split and non-split transactions easily, without needing to also query `subtransactions` and `scheduled_subtransactions` respectively. They also filter out deleted transactions/subtransactions and project payee/category fields to make querying more ergonomic.
86
+ 1. Two new views called `flat_transactions` and `scheduled_flat_transactions`. These allow you to query split and non-split transactions easily, without needing to also query `subtransactions` and `scheduled_subtransactions` respectively. They also filter out deleted/unapproved transactions/subtransactions and project payee/category fields to make querying more ergonomic.
86
87
 
87
88
  ## Querying
88
89
 
@@ -136,6 +137,7 @@ WITH used_payees AS (
136
137
  FROM transactions
137
138
  WHERE
138
139
  TRUE
140
+ AND approved
139
141
  AND payee_id IS NOT NULL
140
142
  AND NOT deleted
141
143
  UNION
@@ -62,7 +62,7 @@ The relations are defined in [create-relations.sql](sqlite_export_for_ynab/ddl/c
62
62
 
63
63
  1. Some objects are pulled out into their own tables so they can be more cleanly modeled in SQLite (ex: subtransactions, loan account periodic values).
64
64
  1. Foreign keys are added as needed (ex: plan ID, transaction ID) so data across plans remains separate.
65
- 1. Two new views called `flat_transactions` and `scheduled_flat_transactions`. These allow you to query split and non-split transactions easily, without needing to also query `subtransactions` and `scheduled_subtransactions` respectively. They also filter out deleted transactions/subtransactions and project payee/category fields to make querying more ergonomic.
65
+ 1. Two new views called `flat_transactions` and `scheduled_flat_transactions`. These allow you to query split and non-split transactions easily, without needing to also query `subtransactions` and `scheduled_subtransactions` respectively. They also filter out deleted/unapproved transactions/subtransactions and project payee/category fields to make querying more ergonomic.
66
66
 
67
67
  ## Querying
68
68
 
@@ -116,6 +116,7 @@ WITH used_payees AS (
116
116
  FROM transactions
117
117
  WHERE
118
118
  TRUE
119
+ AND approved
119
120
  AND payee_id IS NOT NULL
120
121
  AND NOT deleted
121
122
  UNION
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = sqlite_export_for_ynab
3
- version = 2.2.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]]]:
@@ -181,7 +181,6 @@ SELECT
181
181
  , t.plan_id
182
182
  , t.account_id
183
183
  , t.account_name
184
- , t.approved
185
184
  , t.cleared
186
185
  , t."date"
187
186
  , t.debt_transaction_type
@@ -227,6 +226,7 @@ INNER JOIN categories AS c
227
226
  )
228
227
  WHERE
229
228
  TRUE
229
+ AND t.approved
230
230
  AND NOT COALESCE(st.deleted, t.deleted)
231
231
  ;
232
232
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlite_export_for_ynab
3
- Version: 2.2.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
 
@@ -82,7 +83,7 @@ The relations are defined in [create-relations.sql](sqlite_export_for_ynab/ddl/c
82
83
 
83
84
  1. Some objects are pulled out into their own tables so they can be more cleanly modeled in SQLite (ex: subtransactions, loan account periodic values).
84
85
  1. Foreign keys are added as needed (ex: plan ID, transaction ID) so data across plans remains separate.
85
- 1. Two new views called `flat_transactions` and `scheduled_flat_transactions`. These allow you to query split and non-split transactions easily, without needing to also query `subtransactions` and `scheduled_subtransactions` respectively. They also filter out deleted transactions/subtransactions and project payee/category fields to make querying more ergonomic.
86
+ 1. Two new views called `flat_transactions` and `scheduled_flat_transactions`. These allow you to query split and non-split transactions easily, without needing to also query `subtransactions` and `scheduled_subtransactions` respectively. They also filter out deleted/unapproved transactions/subtransactions and project payee/category fields to make querying more ergonomic.
86
87
 
87
88
  ## Querying
88
89
 
@@ -136,6 +137,7 @@ WITH used_payees AS (
136
137
  FROM transactions
137
138
  WHERE
138
139
  TRUE
140
+ AND approved
139
141
  AND payee_id IS NOT NULL
140
142
  AND NOT deleted
141
143
  UNION
@@ -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
  },
@@ -207,6 +235,7 @@ TRANSACTIONS: list[dict[str, Any]] = [
207
235
  "amount": -10000,
208
236
  "amount_formatted": "$10.00",
209
237
  "amount_currency": 10.0,
238
+ "approved": True,
210
239
  "category_id": CATEGORY_ID_3,
211
240
  "category_name": CATEGORY_NAME_3,
212
241
  "deleted": False,
@@ -239,6 +268,7 @@ TRANSACTIONS: list[dict[str, Any]] = [
239
268
  "amount": -15000,
240
269
  "amount_formatted": "$15.00",
241
270
  "amount_currency": 15.0,
271
+ "approved": True,
242
272
  "category_id": CATEGORY_ID_2,
243
273
  "category_name": CATEGORY_NAME_2,
244
274
  "deleted": True,
@@ -250,6 +280,7 @@ TRANSACTIONS: list[dict[str, Any]] = [
250
280
  "amount": -19000,
251
281
  "amount_formatted": "$19.00",
252
282
  "amount_currency": 19.0,
283
+ "approved": False,
253
284
  "category_id": CATEGORY_ID_4,
254
285
  "category_name": CATEGORY_NAME_4,
255
286
  "deleted": False,
@@ -322,12 +353,17 @@ SCHEDULED_TRANSACTIONS: list[dict[str, Any]] = [
322
353
  ]
323
354
 
324
355
 
325
- @pytest.fixture
326
- def cur():
327
- with sqlite3.connect(":memory:") as con:
328
- con.row_factory = sqlite3.Row
329
- cursor = con.cursor()
330
- 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:
331
367
  yield cursor
332
368
 
333
369
 
@@ -337,7 +373,7 @@ def mock_aioresponses():
337
373
  yield m
338
374
 
339
375
 
340
- def strip_nones(d: sqlite3.Row) -> dict[str, Any]:
376
+ def strip_nones(d: aiosqlite.Row) -> dict[str, Any]:
341
377
  return {k: v for k, v in dict(d).items() if v is not None}
342
378
 
343
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,
@@ -314,6 +368,7 @@ def test_insert_transactions(cur):
314
368
  "amount": -10000,
315
369
  "amount_formatted": "$10.00",
316
370
  "amount_currency": 10.0,
371
+ "approved": 1,
317
372
  "category_id": CATEGORY_ID_3,
318
373
  "category_name": CATEGORY_NAME_3,
319
374
  "deleted": False,
@@ -325,6 +380,7 @@ def test_insert_transactions(cur):
325
380
  "amount": -15000,
326
381
  "amount_formatted": "$15.00",
327
382
  "amount_currency": 15.0,
383
+ "approved": 1,
328
384
  "category_id": CATEGORY_ID_2,
329
385
  "category_name": CATEGORY_NAME_2,
330
386
  "deleted": True,
@@ -336,14 +392,17 @@ def test_insert_transactions(cur):
336
392
  "amount": -19000,
337
393
  "amount_formatted": "$19.00",
338
394
  "amount_currency": 19.0,
395
+ "approved": 0,
339
396
  "category_id": CATEGORY_ID_4,
340
397
  "category_name": CATEGORY_NAME_4,
341
398
  "deleted": False,
342
399
  },
343
400
  ]
344
401
 
345
- cur.execute("SELECT * FROM subtransactions ORDER BY amount")
346
- 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
+ ] == [
347
406
  {
348
407
  "id": SUBTRANSACTION_ID_1,
349
408
  "transaction_id": TRANSACTION_ID_1,
@@ -368,21 +427,10 @@ def test_insert_transactions(cur):
368
427
  },
369
428
  ]
370
429
 
371
- cur.execute("SELECT * FROM flat_transactions ORDER BY amount")
372
- assert [strip_nones(d) for d in cur.fetchall()] == [
373
- {
374
- "transaction_id": TRANSACTION_ID_3,
375
- "plan_id": PLAN_ID_1,
376
- "date": "2024-03-01",
377
- "id": TRANSACTION_ID_3,
378
- "amount": -19000,
379
- "amount_formatted": "$19.00",
380
- "amount_currency": 19.0,
381
- "category_id": CATEGORY_ID_4,
382
- "category_name": CATEGORY_NAME_4,
383
- "category_group_id": CATEGORY_GROUP_ID_2,
384
- "category_group_name": CATEGORY_GROUP_NAME_2,
385
- },
430
+ assert [
431
+ strip_nones(d)
432
+ for d in await fetchall(con, "SELECT * FROM flat_transactions ORDER BY amount")
433
+ ] == [
386
434
  {
387
435
  "transaction_id": TRANSACTION_ID_1,
388
436
  "subtransaction_id": SUBTRANSACTION_ID_1,
@@ -414,16 +462,21 @@ def test_insert_transactions(cur):
414
462
  ]
415
463
 
416
464
 
417
- @pytest.mark.usefixtures(cur.__name__)
418
- def test_insert_scheduled_transactions(cur):
419
- insert_scheduled_transactions(cur, PLAN_ID_1, [])
420
- assert not cur.execute("SELECT * FROM scheduled_transactions").fetchall()
421
- assert not cur.execute("SELECT * FROM scheduled_subtransactions").fetchall()
422
-
423
- insert_category_groups(cur, PLAN_ID_1, CATEGORY_GROUPS)
424
- insert_scheduled_transactions(cur, PLAN_ID_1, SCHEDULED_TRANSACTIONS)
425
- cur.execute("SELECT * FROM scheduled_transactions ORDER BY amount")
426
- 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
+ ] == [
427
480
  {
428
481
  "id": SCHEDULED_TRANSACTION_ID_1,
429
482
  "plan_id": PLAN_ID_1,
@@ -459,8 +512,12 @@ def test_insert_scheduled_transactions(cur):
459
512
  },
460
513
  ]
461
514
 
462
- cur.execute("SELECT * FROM scheduled_subtransactions ORDER BY amount")
463
- 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
+ ] == [
464
521
  {
465
522
  "id": SCHEDULED_SUBTRANSACTION_ID_1,
466
523
  "scheduled_transaction_id": SCHEDULED_TRANSACTION_ID_1,
@@ -485,8 +542,12 @@ def test_insert_scheduled_transactions(cur):
485
542
  },
486
543
  ]
487
544
 
488
- cur.execute("SELECT * FROM scheduled_flat_transactions ORDER BY amount")
489
- 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
+ ] == [
490
551
  {
491
552
  "transaction_id": SCHEDULED_TRANSACTION_ID_3,
492
553
  "plan_id": PLAN_ID_1,
@@ -660,8 +721,8 @@ async def test_sync_no_data(tmp_path, mock_aioresponses):
660
721
 
661
722
  # create the db and tables to exercise all code branches
662
723
  db = tmp_path / "db.sqlite"
663
- with sqlite3.connect(db) as con:
664
- con.executescript(contents("create-relations.sql"))
724
+ async with aiosqlite.connect(db) as con:
725
+ await con.executescript(contents("create-relations.sql"))
665
726
 
666
727
  await sync(TOKEN, db, False)
667
728
 
@@ -704,8 +765,8 @@ async def test_sync_no_data_quiet(tmp_path, mock_aioresponses, capsys):
704
765
  )
705
766
 
706
767
  db = tmp_path / "db.sqlite"
707
- with sqlite3.connect(db) as con:
708
- con.executescript(contents("create-relations.sql"))
768
+ async with aiosqlite.connect(db) as con:
769
+ await con.executescript(contents("create-relations.sql"))
709
770
 
710
771
  await sync(TOKEN, db, False, quiet=True)
711
772