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.
- {sqlite_export_for_ynab-2.2.0/sqlite_export_for_ynab.egg-info → sqlite_export_for_ynab-2.4.0}/PKG-INFO +4 -2
- {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/README.md +2 -1
- {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/setup.cfg +2 -1
- {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/_main.py +206 -153
- {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/ddl/create-relations.sql +1 -1
- {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0/sqlite_export_for_ynab.egg-info}/PKG-INFO +4 -2
- {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab.egg-info/requires.txt +1 -0
- {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/testing/fixtures.py +44 -8
- {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/tests/_main_test.py +147 -86
- {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/LICENSE +0 -0
- {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/pyproject.toml +0 -0
- {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/setup.py +0 -0
- {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/__init__.py +0 -0
- {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/__main__.py +0 -0
- {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/ddl/__init__.py +0 -0
- {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/ddl/drop-relations.sql +0 -0
- {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/py.typed +0 -0
- {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab.egg-info/SOURCES.txt +0 -0
- {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
- {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
- {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
- {sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/testing/__init__.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
{sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/_main.py
RENAMED
|
@@ -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
|
|
128
|
-
con.row_factory =
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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(
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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:
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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:
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
275
|
+
async def insert_plans(
|
|
276
|
+
con: aiosqlite.Connection, plans: list[dict[str, Any]], lkos: dict[str, int]
|
|
233
277
|
) -> 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
{k: v for k, v in entry.items() if k != subentries_name}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
|
489
|
-
|
|
539
|
+
async def insert_entries(
|
|
540
|
+
con: aiosqlite.Connection,
|
|
490
541
|
table: _EntryTable,
|
|
491
542
|
plan_id: str,
|
|
492
|
-
|
|
543
|
+
entries: list[dict[str, Any]],
|
|
544
|
+
pbar: tldm[Never],
|
|
493
545
|
) -> None:
|
|
494
|
-
|
|
495
|
-
|
|
546
|
+
if not entries:
|
|
547
|
+
return
|
|
496
548
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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.
|
|
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
|
-
@
|
|
326
|
-
def
|
|
327
|
-
with
|
|
328
|
-
con.row_factory =
|
|
329
|
-
|
|
330
|
-
|
|
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:
|
|
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
|
-
|
|
97
|
-
|
|
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(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
assert [
|
|
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(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
assert not
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
assert [
|
|
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
|
-
|
|
174
|
-
|
|
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(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
assert not
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
assert [
|
|
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
|
-
|
|
214
|
-
|
|
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(
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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(
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
assert not
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
assert [
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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(
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
assert not
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
assert [
|
|
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
|
-
|
|
463
|
-
|
|
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
|
-
|
|
489
|
-
|
|
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
|
|
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
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/__init__.py
RENAMED
|
File without changes
|
{sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/__main__.py
RENAMED
|
File without changes
|
{sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.0}/sqlite_export_for_ynab/ddl/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{sqlite_export_for_ynab-2.2.0 → sqlite_export_for_ynab-2.4.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
|