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.
- {sqlite_export_for_ynab-2.3.0/sqlite_export_for_ynab.egg-info → sqlite_export_for_ynab-2.5.0}/PKG-INFO +4 -2
- {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/setup.cfg +4 -2
- {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab/_main.py +267 -202
- {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0/sqlite_export_for_ynab.egg-info}/PKG-INFO +4 -2
- sqlite_export_for_ynab-2.5.0/sqlite_export_for_ynab.egg-info/requires.txt +4 -0
- {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/testing/fixtures.py +31 -12
- {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/tests/_main_test.py +165 -81
- sqlite_export_for_ynab-2.3.0/sqlite_export_for_ynab.egg-info/requires.txt +0 -2
- {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/LICENSE +0 -0
- {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/README.md +0 -0
- {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/pyproject.toml +0 -0
- {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/setup.py +0 -0
- {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab/__init__.py +0 -0
- {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab/__main__.py +0 -0
- {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab/ddl/__init__.py +0 -0
- {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab/ddl/create-relations.sql +0 -0
- {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab/ddl/drop-relations.sql +0 -0
- {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab/py.typed +0 -0
- {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab.egg-info/SOURCES.txt +0 -0
- {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
- {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
- {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
- {sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/testing/__init__.py +0 -0
- {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
|
+
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:
|
|
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
|
+
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
|
-
|
|
23
|
+
aiopathlib
|
|
24
|
+
aiosqlite
|
|
25
|
+
rich
|
|
24
26
|
python_requires = >=3.12
|
|
25
27
|
|
|
26
28
|
[options.entry_points]
|
{sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab/_main.py
RENAMED
|
@@ -4,10 +4,12 @@ import argparse
|
|
|
4
4
|
import asyncio
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
plans = (await YnabClient(token, session)("plans"))["plans"]
|
|
170
|
+
await AsyncPath(db).parent.mkdir(parents=True, exist_ok=True)
|
|
121
171
|
|
|
122
|
-
|
|
172
|
+
async with _context(db, quiet=quiet) as context:
|
|
173
|
+
plans = (await YnabClient(token, context.session)("plans"))["plans"]
|
|
123
174
|
|
|
124
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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:
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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:
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
232
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
,
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
,
|
|
242
|
-
,
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
,
|
|
246
|
-
,
|
|
247
|
-
)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
470
|
-
total=sum(1 + len(e[subentries_name]) for e in entries)
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
|
489
|
-
|
|
550
|
+
async def insert_entries(
|
|
551
|
+
context: _Context,
|
|
490
552
|
table: _EntryTable,
|
|
491
553
|
plan_id: str,
|
|
492
|
-
|
|
554
|
+
entries: list[dict[str, Any]],
|
|
555
|
+
task_id: TaskID,
|
|
493
556
|
) -> None:
|
|
494
|
-
|
|
495
|
-
|
|
557
|
+
if not entries:
|
|
558
|
+
return
|
|
496
559
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
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:
|
|
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,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
|
-
|
|
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:
|
|
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
|
|
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.
|
|
96
|
-
def test_get_relations(
|
|
97
|
-
|
|
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.
|
|
101
|
-
def test_get_last_knowledge_of_server(
|
|
102
|
-
insert_plans(
|
|
103
|
-
|
|
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.
|
|
107
|
-
def test_insert_plans(
|
|
108
|
-
insert_plans(
|
|
109
|
-
|
|
110
|
-
|
|
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.
|
|
139
|
-
def test_insert_accounts(
|
|
140
|
-
insert_accounts(
|
|
141
|
-
assert not
|
|
142
|
-
assert not
|
|
143
|
-
|
|
144
|
-
insert_accounts(
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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.
|
|
193
|
-
def test_insert_category_groups(
|
|
194
|
-
insert_category_groups(
|
|
195
|
-
assert not
|
|
196
|
-
assert not
|
|
197
|
-
|
|
198
|
-
insert_category_groups(
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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.
|
|
280
|
-
def
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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.
|
|
301
|
-
def test_insert_transactions(
|
|
302
|
-
insert_transactions(
|
|
303
|
-
assert not
|
|
304
|
-
assert not
|
|
305
|
-
|
|
306
|
-
insert_category_groups(
|
|
307
|
-
insert_transactions(
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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.
|
|
408
|
-
def test_insert_scheduled_transactions(
|
|
409
|
-
insert_scheduled_transactions(
|
|
410
|
-
assert not
|
|
411
|
-
assert not
|
|
412
|
-
|
|
413
|
-
insert_category_groups(
|
|
414
|
-
insert_scheduled_transactions(
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
479
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
|
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
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab/__init__.py
RENAMED
|
File without changes
|
{sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab/__main__.py
RENAMED
|
File without changes
|
{sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab/ddl/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlite_export_for_ynab-2.3.0 → sqlite_export_for_ynab-2.5.0}/sqlite_export_for_ynab/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|