sqlite-export-for-ynab 0.0.1__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.
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.1
2
+ Name: sqlite_export_for_ynab
3
+ Version: 0.0.1
4
+ Summary: SQLite Export for YNAB - Export YNAB Budget data to SQLite
5
+ Home-page: https://github.com/mxr/sqlite-export-for-ynab
6
+ Author: Max R
7
+ Author-email: maxr@outlook.com
8
+ License: MIT
9
+ Keywords: ynab,sqlite,sql,budget,cli
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: Implementation :: CPython
14
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: aiohttp
18
+ Requires-Dist: tqdm
@@ -0,0 +1,20 @@
1
+ [tool.mypy]
2
+ check_untyped_defs = true
3
+ disallow_any_generics = true
4
+ disallow_incomplete_defs = true
5
+ disallow_untyped_defs = true
6
+ warn_redundant_casts = true
7
+ warn_unused_ignores = true
8
+
9
+ [tool.ruff]
10
+ target-version = "py312"
11
+
12
+ [tool.ruff.lint]
13
+ extend-select = [
14
+ "UP", # see pyupgrade
15
+ "B", # see flake8-bugbear
16
+ "A", # see flake8-builtins
17
+ "C4", # see flake8-comprehension
18
+ "SIM", # see flake8-simplify
19
+ "TCH" # see flake8-type-checking
20
+ ]
@@ -0,0 +1,40 @@
1
+ [metadata]
2
+ name = sqlite_export_for_ynab
3
+ version = 0.0.1
4
+ description = SQLite Export for YNAB - Export YNAB Budget data to SQLite
5
+ long_description = file: README.md
6
+ long_description_content_type = text/markdown
7
+ url = https://github.com/mxr/sqlite-export-for-ynab
8
+ author = Max R
9
+ author_email = maxr@outlook.com
10
+ license = MIT
11
+ license_files = LICENSE
12
+ classifiers =
13
+ License :: OSI Approved :: MIT License
14
+ Programming Language :: Python :: 3
15
+ Programming Language :: Python :: 3 :: Only
16
+ Programming Language :: Python :: Implementation :: CPython
17
+ Programming Language :: Python :: Implementation :: PyPy
18
+ keywords = ynab, sqlite, sql, budget, cli
19
+
20
+ [options]
21
+ packages = find:
22
+ install_requires =
23
+ aiohttp
24
+ tqdm
25
+ python_requires = >=3.12
26
+
27
+ [options.entry_points]
28
+ console_scripts =
29
+ sqlite-export-for-ynab = sqlite_export_for_ynab._main:main
30
+
31
+ [options.package_data]
32
+ * = *.sqlite
33
+
34
+ [bdist_wheel]
35
+ universal = True
36
+
37
+ [egg_info]
38
+ tag_build =
39
+ tag_date = 0
40
+
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from setuptools import setup
4
+
5
+ setup()
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlite_export_for_ynab._main import DEFAULT_DB
4
+ from sqlite_export_for_ynab._main import sync
5
+
6
+ __all__ = ["sync", "DEFAULT_DB"]
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlite_export_for_ynab._main import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,328 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import os
7
+ import sqlite3
8
+ from dataclasses import dataclass
9
+ from importlib import resources
10
+ from typing import Any
11
+ from typing import ClassVar
12
+ from typing import Literal
13
+ from typing import overload
14
+ from typing import Protocol
15
+ from typing import TYPE_CHECKING
16
+ from urllib.parse import urlencode
17
+ from urllib.parse import urljoin
18
+ from urllib.parse import urlunparse
19
+
20
+ import aiohttp
21
+ from tqdm import tqdm
22
+
23
+ from sqlite_export_for_ynab import ddl
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Awaitable
27
+ from typing import Never
28
+
29
+
30
+ _EntryTable = (
31
+ Literal["payees"]
32
+ | Literal["transactions"]
33
+ | Literal["subtransactions"]
34
+ | Literal["category_groups"]
35
+ | Literal["categories"]
36
+ )
37
+ _ALL_TABLES = frozenset(
38
+ ("budgets",) + tuple(lit.__args__[0] for lit in _EntryTable.__args__)
39
+ )
40
+
41
+ _ENV_TOKEN = "YNAB_PERSONAL_ACCESS_TOKEN"
42
+
43
+ DEFAULT_DB = "sqlite-export-for-ynab.db"
44
+
45
+
46
+ async def async_main() -> int:
47
+ parser = argparse.ArgumentParser()
48
+ parser.add_argument("--db", help="The SQLite database file.", default=DEFAULT_DB)
49
+ parser.add_argument(
50
+ "--full-refresh",
51
+ action="store_true",
52
+ help="Drop all tables and fetch the budget.",
53
+ )
54
+
55
+ args = parser.parse_args()
56
+ db: str = args.db
57
+ full_refresh: bool = args.full_refresh
58
+
59
+ token = os.environ.get(_ENV_TOKEN)
60
+ if not token:
61
+ raise ValueError(
62
+ f"Must set YNAB access token as {_ENV_TOKEN!r} "
63
+ "environment variable. See "
64
+ "https://api.ynab.com/#personal-access-tokens"
65
+ )
66
+
67
+ await sync(db, full_refresh, token)
68
+
69
+ return 0
70
+
71
+
72
+ async def sync(db: str, full_refresh: bool, token: str) -> None:
73
+ async with aiohttp.ClientSession() as session:
74
+ budgets = (await YnabClient(token, session)("budgets"))["budgets"]
75
+
76
+ budget_ids = [b["id"] for b in budgets]
77
+
78
+ with sqlite3.connect(db) as con:
79
+ con.row_factory = lambda c, row: dict(
80
+ zip([name for name, *_ in c.description], row, strict=True)
81
+ )
82
+ cur = con.cursor()
83
+
84
+ if full_refresh:
85
+ print("Dropping tables...")
86
+ cur.executescript(contents("drop-tables.sqlite"))
87
+ con.commit()
88
+ print("Done")
89
+
90
+ tables = get_tables(cur)
91
+ if tables != _ALL_TABLES:
92
+ print("Recreating tables...")
93
+ cur.executescript(contents("create-tables.sqlite"))
94
+ con.commit()
95
+ print("Done")
96
+
97
+ print("Fetching budget data...")
98
+ lkos = get_last_knowledge_of_server(cur)
99
+ async with aiohttp.ClientSession() as session:
100
+ with tqdm(desc="Budget Data", total=len(budgets) * 3) as pbar:
101
+ yc = ProgressYnabClient(
102
+ YnabClient(os.environ["YNAB_PERSONAL_ACCESS_TOKEN"], session), pbar
103
+ )
104
+
105
+ txn_jobs = jobs(yc, "transactions", budget_ids, lkos)
106
+ payee_jobs = jobs(yc, "payees", budget_ids, lkos)
107
+ cat_jobs = jobs(yc, "categories", budget_ids, lkos)
108
+
109
+ data = await asyncio.gather(*txn_jobs, *payee_jobs, *cat_jobs)
110
+
111
+ all_txn_data = data[: len(txn_jobs)]
112
+ all_payee_data = data[len(txn_jobs) : len(txn_jobs) + len(payee_jobs)]
113
+ all_cat_data = data[len(txn_jobs) + len(payee_jobs) :]
114
+
115
+ new_lkos = {
116
+ bid: t["server_knowledge"]
117
+ for bid, t in zip(budget_ids, all_txn_data, strict=True)
118
+ }
119
+ print("Done")
120
+
121
+ if (
122
+ not any(t["transactions"] for t in all_txn_data)
123
+ and not any(p["payees"] for p in all_payee_data)
124
+ and not any(c["category_groups"] for c in all_cat_data)
125
+ ):
126
+ print("No data fetched")
127
+ else:
128
+ print("Inserting budget data...")
129
+ insert_budgets(cur, budgets, new_lkos)
130
+ for bid, txn_data in zip(budget_ids, all_txn_data, strict=True):
131
+ insert_transactions(cur, bid, txn_data["transactions"])
132
+ for bid, payee_data in zip(budget_ids, all_payee_data, strict=True):
133
+ insert_payees(cur, bid, payee_data["payees"])
134
+ for bid, cat_data in zip(budget_ids, all_cat_data, strict=True):
135
+ insert_category_groups(cur, bid, cat_data["category_groups"])
136
+ print("Done")
137
+
138
+
139
+ def contents(filename: str) -> str:
140
+ return (resources.files(ddl) / filename).read_text()
141
+
142
+
143
+ def jobs(
144
+ yc: SupportsYnabClient,
145
+ endpoint: Literal["transactions"] | Literal["categories"] | Literal["payees"],
146
+ budget_ids: list[str],
147
+ lkos: dict[str, int],
148
+ ) -> list[Awaitable[dict[str, Any]]]:
149
+ return [
150
+ yc(f"budgets/{bid}/{endpoint}", last_knowledge_of_server=lkos.get(bid))
151
+ for bid in budget_ids
152
+ ]
153
+
154
+
155
+ def get_tables(cur: sqlite3.Cursor) -> set[str]:
156
+ return {
157
+ t["name"]
158
+ for t in cur.execute(
159
+ "SELECT name FROM sqlite_master WHERE type='table'"
160
+ ).fetchall()
161
+ }
162
+
163
+
164
+ def get_last_knowledge_of_server(cur: sqlite3.Cursor) -> dict[str, int]:
165
+ return {
166
+ r["id"]: r["last_knowledge_of_server"]
167
+ for r in cur.execute(
168
+ "SELECT id, last_knowledge_of_server FROM budgets",
169
+ ).fetchall()
170
+ }
171
+
172
+
173
+ def insert_budgets(
174
+ cur: sqlite3.Cursor, budgets: list[dict[str, Any]], lkos: dict[str, int]
175
+ ) -> None:
176
+ cur.executemany(
177
+ "INSERT OR REPLACE INTO budgets (id, name, last_knowledge_of_server) VALUES (?, ?, ?)",
178
+ ((bid := b["id"], b["name"], lkos[bid]) for b in budgets),
179
+ )
180
+
181
+
182
+ def insert_transactions(
183
+ cur: sqlite3.Cursor, budget_id: str, transactions: list[dict[str, Any]]
184
+ ) -> None:
185
+ return insert_nested_entries(
186
+ cur, budget_id, transactions, "Transactions", "transactions", "subtransactions"
187
+ )
188
+
189
+
190
+ @overload
191
+ def insert_nested_entries(
192
+ cur: sqlite3.Cursor,
193
+ budget_id: str,
194
+ entries: list[dict[str, Any]],
195
+ desc: Literal["Transactions"],
196
+ entries_name: Literal["transactions"],
197
+ subentries_name: Literal["subtransactions"],
198
+ ) -> None: ...
199
+
200
+
201
+ @overload
202
+ def insert_nested_entries(
203
+ cur: sqlite3.Cursor,
204
+ budget_id: str,
205
+ entries: list[dict[str, Any]],
206
+ desc: Literal["Categories"],
207
+ entries_name: Literal["category_groups"],
208
+ subentries_name: Literal["categories"],
209
+ ) -> None: ...
210
+
211
+
212
+ def insert_nested_entries(
213
+ cur: sqlite3.Cursor,
214
+ budget_id: str,
215
+ entries: list[dict[str, Any]],
216
+ desc: Literal["Transactions"] | Literal["Categories"],
217
+ entries_name: Literal["transactions"] | Literal["category_groups"],
218
+ subentries_name: Literal["subtransactions"] | Literal["categories"],
219
+ ) -> None:
220
+ if not entries:
221
+ return
222
+
223
+ with tqdm(
224
+ total=sum(1 + len(e[subentries_name]) for e in entries),
225
+ desc=desc,
226
+ ) as pbar:
227
+ for entry in entries:
228
+ subentries = entry.pop(subentries_name, [])
229
+ insert_entry(cur, entries_name, budget_id, entry)
230
+ pbar.update()
231
+
232
+ for subentry in subentries:
233
+ insert_entry(cur, subentries_name, budget_id, subentry)
234
+ pbar.update()
235
+
236
+
237
+ def insert_payees(
238
+ cur: sqlite3.Cursor, budget_id: str, payees: list[dict[str, Any]]
239
+ ) -> None:
240
+ if not payees:
241
+ return
242
+
243
+ for payee in tqdm(payees, desc="Payees"):
244
+ insert_entry(cur, "payees", budget_id, payee)
245
+
246
+
247
+ def insert_category_groups(
248
+ cur: sqlite3.Cursor, budget_id: str, category_groups: list[dict[str, Any]]
249
+ ) -> None:
250
+ return insert_nested_entries(
251
+ cur, budget_id, category_groups, "Categories", "category_groups", "categories"
252
+ )
253
+
254
+
255
+ def insert_entry(
256
+ cur: sqlite3.Cursor,
257
+ table: _EntryTable,
258
+ budget_id: str,
259
+ entry: dict[str, Any],
260
+ ) -> None:
261
+ ekeys, evalues = zip(*entry.items(), strict=True)
262
+ keys, values = ekeys + ("budget_id",), evalues + (budget_id,)
263
+
264
+ cur.execute(
265
+ f'INSERT OR REPLACE INTO {table} ({", ".join(keys)}) VALUES ({", ".join("?" * len(values))})',
266
+ values,
267
+ )
268
+
269
+
270
+ class SupportsYnabClient(Protocol):
271
+ async def __call__(
272
+ self, path: str, last_knowledge_of_server: int | None = None
273
+ ) -> dict[str, Any]: ...
274
+
275
+
276
+ @dataclass
277
+ class ProgressYnabClient:
278
+ yc: YnabClient
279
+ pbar: tqdm[Never]
280
+
281
+ async def __call__(
282
+ self, path: str, last_knowledge_of_server: int | None = None
283
+ ) -> dict[str, Any]:
284
+ try:
285
+ return await self.yc(path, last_knowledge_of_server)
286
+ finally:
287
+ self.pbar.update()
288
+
289
+
290
+ @dataclass
291
+ class YnabClient:
292
+ BASE_SCHEME: ClassVar[str] = "https"
293
+ BASE_NETLOC: ClassVar[str] = "api.ynab.com"
294
+ BASE_PATH: ClassVar[str] = "v1/"
295
+
296
+ token: str
297
+ session: aiohttp.ClientSession
298
+
299
+ async def __call__(
300
+ self, path: str, last_knowledge_of_server: int | None = None
301
+ ) -> dict[str, Any]:
302
+ headers = {
303
+ "Authorization": f"Bearer {self.token}",
304
+ "Content-Type": "application/json",
305
+ }
306
+ url = urlunparse(
307
+ (
308
+ self.BASE_SCHEME,
309
+ self.BASE_NETLOC,
310
+ urljoin(self.BASE_PATH, path),
311
+ "",
312
+ urlencode(
313
+ {"last_knowledge_of_server": last_knowledge_of_server}
314
+ if last_knowledge_of_server
315
+ else {}
316
+ ),
317
+ "",
318
+ )
319
+ )
320
+
321
+ async with self.session.get(url, headers=headers) as resp:
322
+ body = await resp.text()
323
+
324
+ return json.loads(body)["data"]
325
+
326
+
327
+ def main() -> int:
328
+ return asyncio.run(async_main())
@@ -0,0 +1,98 @@
1
+ CREATE TABLE IF NOT EXISTS budgets (id TEXT primary key, name TEXT, last_knowledge_of_server INT)
2
+ ;
3
+
4
+ CREATE TABLE IF NOT EXISTS payees (
5
+ id TEXT primary key,
6
+ budget_id TEXT,
7
+ name TEXT,
8
+ transfer_account_id TEXT,
9
+ deleted BOOLEAN,
10
+ foreign key (budget_id) references budgets (id)
11
+ )
12
+ ;
13
+
14
+ CREATE TABLE IF NOT EXISTS category_groups (
15
+ id TEXT primary key,
16
+ budget_id TEXT,
17
+ name TEXT,
18
+ hidden BOOLEAN,
19
+ deleted BOOLEAN,
20
+ foreign key (budget_id) references budgets (id)
21
+ )
22
+ ;
23
+
24
+ CREATE TABLE IF NOT EXISTS categories (
25
+ id TEXT primary key,
26
+ budget_id TEXT,
27
+ category_group_id TEXT,
28
+ category_group_name TEXT,
29
+ name TEXT,
30
+ hidden BOOLEAN,
31
+ original_category_group_id TEXT,
32
+ note TEXT,
33
+ budgeted INT,
34
+ activity INT,
35
+ balance INT,
36
+ goal_type TEXT,
37
+ goal_needs_whole_amount BOOLEAN,
38
+ goal_day INT,
39
+ goal_cadence INT,
40
+ goal_cadence_frequency INT,
41
+ goal_creation_month text,
42
+ goal_target INT,
43
+ goal_target_month text,
44
+ goal_percentage_complete INT,
45
+ goal_months_to_budget INT,
46
+ goal_under_funded INT,
47
+ goal_overall_funded INT,
48
+ goal_overall_left INT,
49
+ deleted BOOLEAN,
50
+ foreign key (budget_id) references budgets (id),
51
+ foreign key (category_group_id) references category_groups (id)
52
+ )
53
+ ;
54
+
55
+ CREATE TABLE IF NOT EXISTS transactions (
56
+ id TEXT primary key,
57
+ budget_id TEXT,
58
+ account_id TEXT,
59
+ account_name TEXT,
60
+ amount INT,
61
+ approved BOOLEAN,
62
+ category_id TEXT,
63
+ category_name TEXT,
64
+ cleared TEXT,
65
+ DATE TEXT,
66
+ debt_transaction_type TEXT,
67
+ deleted BOOLEAN,
68
+ flag_color TEXT,
69
+ flag_name TEXT,
70
+ import_id TEXT,
71
+ import_payee_name TEXT,
72
+ import_payee_name_original TEXT,
73
+ matched_transaction_id TEXT,
74
+ memo TEXT,
75
+ payee_id TEXT,
76
+ payee_name TEXT,
77
+ transfer_account_id TEXT,
78
+ transfer_transaction_id TEXT,
79
+ foreign key (budget_id) references budgets (id)
80
+ )
81
+ ;
82
+
83
+ CREATE TABLE IF NOT EXISTS subtransactions (
84
+ id TEXT primary key,
85
+ budget_id TEXT,
86
+ amount INT,
87
+ category_id TEXT,
88
+ category_name TEXT,
89
+ deleted BOOLEAN,
90
+ memo TEXT,
91
+ payee_id TEXT,
92
+ payee_name TEXT,
93
+ transaction_id TEXT,
94
+ transfer_account_id TEXT,
95
+ transfer_transaction_id TEXT,
96
+ foreign key (budget_id) references budget (id),
97
+ foreign key (transaction_id) references transaction_id (id)
98
+ )
@@ -0,0 +1,17 @@
1
+ DROP TABLE IF EXISTS budgets
2
+ ;
3
+
4
+ DROP TABLE IF EXISTS payees
5
+ ;
6
+
7
+ DROP TABLE IF EXISTS category_groups
8
+ ;
9
+
10
+ DROP TABLE IF EXISTS categories
11
+ ;
12
+
13
+ DROP TABLE IF EXISTS transactions
14
+ ;
15
+
16
+ DROP TABLE IF EXISTS subtransactions
17
+ ;
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.1
2
+ Name: sqlite_export_for_ynab
3
+ Version: 0.0.1
4
+ Summary: SQLite Export for YNAB - Export YNAB Budget data to SQLite
5
+ Home-page: https://github.com/mxr/sqlite-export-for-ynab
6
+ Author: Max R
7
+ Author-email: maxr@outlook.com
8
+ License: MIT
9
+ Keywords: ynab,sqlite,sql,budget,cli
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: Implementation :: CPython
14
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: aiohttp
18
+ Requires-Dist: tqdm
@@ -0,0 +1,15 @@
1
+ pyproject.toml
2
+ setup.cfg
3
+ setup.py
4
+ sqlite_export_for_ynab/__init__.py
5
+ sqlite_export_for_ynab/__main__.py
6
+ sqlite_export_for_ynab/_main.py
7
+ sqlite_export_for_ynab.egg-info/PKG-INFO
8
+ sqlite_export_for_ynab.egg-info/SOURCES.txt
9
+ sqlite_export_for_ynab.egg-info/dependency_links.txt
10
+ sqlite_export_for_ynab.egg-info/entry_points.txt
11
+ sqlite_export_for_ynab.egg-info/requires.txt
12
+ sqlite_export_for_ynab.egg-info/top_level.txt
13
+ sqlite_export_for_ynab/ddl/__init__.py
14
+ sqlite_export_for_ynab/ddl/create-tables.sqlite
15
+ sqlite_export_for_ynab/ddl/drop-tables.sqlite
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sqlite-export-for-ynab = sqlite_export_for_ynab._main:main
@@ -0,0 +1 @@
1
+ sqlite_export_for_ynab