sqlite-export-for-ynab 2.5.1__tar.gz → 2.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {sqlite_export_for_ynab-2.5.1/sqlite_export_for_ynab.egg-info → sqlite_export_for_ynab-2.7.0}/PKG-INFO +4 -1
  2. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/setup.cfg +4 -1
  3. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/_main.py +237 -179
  4. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0/sqlite_export_for_ynab.egg-info}/PKG-INFO +4 -1
  5. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/requires.txt +3 -0
  6. sqlite_export_for_ynab-2.7.0/testing/fixtures.py +620 -0
  7. sqlite_export_for_ynab-2.7.0/tests/_main_test.py +1094 -0
  8. sqlite_export_for_ynab-2.5.1/testing/fixtures.py +0 -373
  9. sqlite_export_for_ynab-2.5.1/tests/_main_test.py +0 -828
  10. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/LICENSE +0 -0
  11. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/README.md +0 -0
  12. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/pyproject.toml +0 -0
  13. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/setup.py +0 -0
  14. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/__init__.py +0 -0
  15. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/__main__.py +0 -0
  16. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/ddl/__init__.py +0 -0
  17. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/ddl/create-relations.sql +0 -0
  18. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/ddl/drop-relations.sql +0 -0
  19. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/py.typed +0 -0
  20. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/SOURCES.txt +0 -0
  21. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/dependency_links.txt +0 -0
  22. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/entry_points.txt +0 -0
  23. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/top_level.txt +0 -0
  24. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.0}/testing/__init__.py +0 -0
  25. {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.7.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.5.1
3
+ Version: 2.7.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
@@ -17,7 +17,10 @@ License-File: LICENSE
17
17
  Requires-Dist: aiohttp>=3
18
18
  Requires-Dist: aiopathlib
19
19
  Requires-Dist: aiosqlite
20
+ Requires-Dist: asyncio-for-ynab
21
+ Requires-Dist: fasteners
20
22
  Requires-Dist: rich>=14
23
+ Requires-Dist: tenacity
21
24
  Dynamic: license-file
22
25
 
23
26
  # sqlite-export-for-ynab
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = sqlite_export_for_ynab
3
- version = 2.5.1
3
+ version = 2.7.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
@@ -22,7 +22,10 @@ install_requires =
22
22
  aiohttp>=3
23
23
  aiopathlib
24
24
  aiosqlite
25
+ asyncio-for-ynab
26
+ fasteners
25
27
  rich>=14
28
+ tenacity
26
29
  python_requires = >=3.12
27
30
 
28
31
  [options.entry_points]
@@ -2,39 +2,53 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import asyncio
5
- import json
6
5
  import os
7
6
  from contextlib import asynccontextmanager
8
7
  from contextlib import contextmanager
9
8
  from dataclasses import dataclass
9
+ from dataclasses import fields
10
10
  from importlib import resources
11
11
  from importlib.metadata import version
12
12
  from itertools import batched
13
13
  from pathlib import Path
14
14
  from typing import Any
15
- from typing import ClassVar
16
15
  from typing import Literal
17
16
  from typing import overload
18
- from typing import Protocol
19
17
  from typing import TYPE_CHECKING
20
- from urllib.parse import urlencode
21
- from urllib.parse import urljoin
22
- from urllib.parse import urlunparse
23
18
 
24
- import aiohttp
25
19
  import aiosqlite
20
+ import asyncio_for_ynab # noqa: F401
21
+ import fasteners
26
22
  from aiopathlib import AsyncPath
23
+ from asyncio_for_ynab import Account
24
+ from asyncio_for_ynab import AccountsApi
25
+ from asyncio_for_ynab import ApiClient
26
+ from asyncio_for_ynab import CategoriesApi
27
+ from asyncio_for_ynab import CategoryGroupWithCategories
28
+ from asyncio_for_ynab import Configuration
29
+ from asyncio_for_ynab import Payee
30
+ from asyncio_for_ynab import PayeesApi
31
+ from asyncio_for_ynab import PlansApi
32
+ from asyncio_for_ynab import PlanSummary
33
+ from asyncio_for_ynab import ScheduledTransactionDetail
34
+ from asyncio_for_ynab import ScheduledTransactionsApi
35
+ from asyncio_for_ynab import TransactionDetail
36
+ from asyncio_for_ynab import TransactionsApi
27
37
  from rich.progress import BarColumn
28
38
  from rich.progress import MofNCompleteColumn
29
39
  from rich.progress import Progress
30
40
  from rich.progress import TaskID
31
41
  from rich.progress import TextColumn
32
42
  from rich.progress import TimeElapsedColumn
43
+ from tenacity import retry
44
+ from tenacity import stop_after_attempt
33
45
 
34
46
  from sqlite_export_for_ynab import ddl
35
47
 
36
48
  if TYPE_CHECKING:
37
- from collections.abc import AsyncIterator, Awaitable, Iterator, Sequence
49
+ from collections.abc import AsyncIterator
50
+ from collections.abc import Iterator
51
+ from collections.abc import Sequence
38
52
 
39
53
 
40
54
  _EntryTable = (
@@ -66,6 +80,7 @@ _ENV_TOKEN = "YNAB_PERSONAL_ACCESS_TOKEN"
66
80
  _PACKAGE = "sqlite-export-for-ynab"
67
81
 
68
82
  _BATCH_SIZE = 100
83
+ _SYNC_LOCK_TIMEOUT = 30.0
69
84
 
70
85
  _PROGRESS_COLUMNS = (
71
86
  TextColumn("[progress.description]{task.description}"),
@@ -81,7 +96,8 @@ def resolve_token(token_override: str | None = None) -> str:
81
96
  return token
82
97
 
83
98
  raise ValueError(
84
- f"Must set YNAB access token as {_ENV_TOKEN!r} environment variable or pass token_override directly. See https://api.ynab.com/#personal-access-tokens"
99
+ f"Must set YNAB access token as {_ENV_TOKEN!r} environment variable or pass "
100
+ "token_override directly. See https://api.ynab.com/#personal-access-tokens"
85
101
  )
86
102
 
87
103
 
@@ -140,17 +156,66 @@ def _print(message: str, *, quiet: bool) -> None:
140
156
 
141
157
  @dataclass
142
158
  class _Context:
143
- session: aiohttp.ClientSession
144
159
  progress: Progress
145
160
  con: aiosqlite.Connection
161
+ lock: fasteners.InterProcessLock
162
+ api_client: ApiClient
163
+
164
+
165
+ @dataclass
166
+ class _YnabPlanData:
167
+ accounts: list[Account]
168
+ category_groups: list[CategoryGroupWithCategories]
169
+ payees: list[Payee]
170
+ transactions: list[TransactionDetail]
171
+ server_knowledge: int
172
+ scheduled_transactions: list[ScheduledTransactionDetail]
173
+
174
+ def has_data(self) -> bool:
175
+ return any(
176
+ getattr(self, field.name)
177
+ for field in fields(self)
178
+ if field.name != "server_knowledge"
179
+ )
146
180
 
147
181
 
148
182
  @asynccontextmanager
149
- async def _context(db: Path, *, quiet: bool) -> AsyncIterator[_Context]:
183
+ async def _context(
184
+ db: Path,
185
+ configuration: Configuration,
186
+ *,
187
+ quiet: bool,
188
+ timeout: float = _SYNC_LOCK_TIMEOUT,
189
+ ) -> AsyncIterator[_Context]:
150
190
  progress = Progress(*_PROGRESS_COLUMNS, disable=quiet)
151
- async with aiohttp.ClientSession() as session, aiosqlite.connect(db) as con:
191
+ lock_path = db.parent / f"{db.name}.lock"
192
+ lock = fasteners.InterProcessLock(lock_path)
193
+ async with (
194
+ ApiClient(configuration) as api_client,
195
+ aiosqlite.connect(db) as con,
196
+ ):
152
197
  con.row_factory = aiosqlite.Row
153
- yield _Context(session, progress, con)
198
+ loop = asyncio.get_running_loop()
199
+ deadline = loop.time() + timeout
200
+ _print("Acquiring lock...", quiet=quiet)
201
+ while True:
202
+ acquired = await asyncio.to_thread(lock.acquire, False)
203
+ if acquired:
204
+ _print("Done", quiet=quiet)
205
+ break
206
+ if loop.time() >= deadline:
207
+ raise TimeoutError(
208
+ f"Timed out waiting {timeout} seconds for sync lock at {lock.path}"
209
+ )
210
+ await asyncio.sleep(0.1)
211
+
212
+ try:
213
+ yield _Context(progress, con, lock, api_client)
214
+ finally:
215
+ try:
216
+ await asyncio.to_thread(lock.release)
217
+ finally:
218
+ await AsyncPath(lock_path).unlink(missing_ok=True)
154
219
 
155
220
 
156
221
  @contextmanager
@@ -169,10 +234,11 @@ async def sync(
169
234
  ) -> None:
170
235
  await AsyncPath(db).parent.mkdir(parents=True, exist_ok=True)
171
236
 
172
- async with _context(db, quiet=quiet) as context:
173
- plans = (await YnabClient(token, context.session)("plans"))["plans"]
174
-
175
- plan_ids = [plan["id"] for plan in plans]
237
+ configuration = Configuration(access_token=token)
238
+ async with _context(
239
+ db, configuration, quiet=quiet, timeout=_SYNC_LOCK_TIMEOUT
240
+ ) as context:
241
+ plans = await _get_plan_summaries(context.api_client)
176
242
 
177
243
  if full_refresh:
178
244
  _print("Dropping relations...", quiet=quiet)
@@ -193,63 +259,24 @@ async def sync(
193
259
  async with context.con.cursor() as cur:
194
260
  lkos = await get_last_knowledge_of_server(cur)
195
261
  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
- ),
262
+ task = context.progress.add_task(
263
+ "Plan Data", total=len(plans) * len(_ENDPOINTS)
202
264
  )
203
265
 
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,
214
- )
266
+ all_data = await _get_all_ynab(
267
+ context, [str(plan.id) for plan in plans], lkos, task
215
268
  )
216
269
 
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
- }
227
270
  _print("Done", quiet=quiet)
228
271
 
229
- if (
230
- not any(t["accounts"] for t in all_account_data)
231
- and not any(t["category_groups"] for t in all_cat_data)
232
- and not any(p["payees"] for p in all_payee_data)
233
- and not any(t["transactions"] for t in all_txn_data)
234
- and not any(s["scheduled_transactions"] for s in all_sched_txn_data)
235
- ):
236
- _print("No new data fetched", quiet=quiet)
237
- else:
272
+ if any(plan_data.has_data() for plan_data in all_data.values()):
238
273
  _print("Inserting plan data...", quiet=quiet)
239
274
  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,
250
- )
275
+ await insert_plan_data(context, plans, all_data)
251
276
  await context.con.commit()
252
277
  _print("Done", quiet=quiet)
278
+ else:
279
+ _print("No new data fetched", quiet=quiet)
253
280
 
254
281
 
255
282
  async def contents(filename: str) -> str:
@@ -271,49 +298,40 @@ async def get_last_knowledge_of_server(cur: aiosqlite.Cursor) -> dict[str, int]:
271
298
 
272
299
 
273
300
  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],
301
+ context: _Context, plans: list[PlanSummary], all_data: dict[str, _YnabPlanData]
283
302
  ) -> None:
284
- await insert_plans(context, plans, new_lkos)
303
+ await insert_plans(
304
+ context,
305
+ plans,
306
+ {plan_id: d.server_knowledge for plan_id, d in all_data.items()},
307
+ )
285
308
  await asyncio.gather(
286
309
  *(
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)
310
+ asyncio.gather(
311
+ insert_accounts(context, plan_id, all_data[plan_id].accounts),
312
+ insert_category_groups(
313
+ context, plan_id, all_data[plan_id].category_groups
314
+ ),
315
+ insert_payees(context, plan_id, all_data[plan_id].payees),
316
+ )
317
+ for plan_id in all_data
297
318
  ),
298
319
  )
299
320
  await asyncio.gather(
300
321
  *(
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
322
+ asyncio.gather(
323
+ insert_transactions(context, plan_id, all_data[plan_id].transactions),
324
+ insert_scheduled_transactions(
325
+ context, plan_id, all_data[plan_id].scheduled_transactions
326
+ ),
310
327
  )
328
+ for plan_id in all_data
311
329
  ),
312
330
  )
313
331
 
314
332
 
315
333
  async def insert_plans(
316
- context: _Context, plans: list[dict[str, Any]], lkos: dict[str, int]
334
+ context: _Context, plans: list[PlanSummary], lkos: dict[str, int]
317
335
  ) -> None:
318
336
  async with context.con.cursor() as cur:
319
337
  for plan_batch in batched(plans, _BATCH_SIZE):
@@ -334,15 +352,15 @@ async def insert_plans(
334
352
  """,
335
353
  (
336
354
  (
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"],
355
+ plan_id := str(plan.id),
356
+ plan.name,
357
+ getattr(cf := plan.currency_format, "currency_symbol", None),
358
+ getattr(cf, "decimal_digits", None),
359
+ getattr(cf, "decimal_separator", None),
360
+ getattr(cf, "display_symbol", None),
361
+ getattr(cf, "group_separator", None),
362
+ getattr(cf, "iso_code", None),
363
+ getattr(cf, "symbol_first", None),
346
364
  lkos[plan_id],
347
365
  )
348
366
  for plan in plan_batch
@@ -358,7 +376,7 @@ _LOAN_ACCOUNT_PERIODIC_VALUES = frozenset(
358
376
  async def insert_accounts(
359
377
  context: _Context,
360
378
  plan_id: str,
361
- accounts: list[dict[str, Any]],
379
+ accounts: list[Account],
362
380
  ) -> None:
363
381
  # YNAB's LoanAccountPeriodValues are untyped dicts so we need to turn them into a more standard sub-entry view
364
382
  updated_accounts = [
@@ -375,7 +393,7 @@ async def insert_accounts(
375
393
  ]
376
394
  }
377
395
  | {k: v for k, v in account.items() if k not in _LOAN_ACCOUNT_PERIODIC_VALUES}
378
- for account in accounts
396
+ for account in (acc.model_dump(mode="json") for acc in accounts)
379
397
  ]
380
398
 
381
399
  await insert_nested_entries(
@@ -392,12 +410,12 @@ async def insert_accounts(
392
410
  async def insert_category_groups(
393
411
  context: _Context,
394
412
  plan_id: str,
395
- category_groups: list[dict[str, Any]],
413
+ category_groups: list[CategoryGroupWithCategories],
396
414
  ) -> None:
397
415
  await insert_nested_entries(
398
416
  context,
399
417
  plan_id,
400
- category_groups,
418
+ [cg.model_dump(mode="json") for cg in category_groups],
401
419
  "Categories",
402
420
  "category_groups",
403
421
  "categories",
@@ -408,24 +426,27 @@ async def insert_category_groups(
408
426
  async def insert_payees(
409
427
  context: _Context,
410
428
  plan_id: str,
411
- payees: list[dict[str, Any]],
429
+ payees: list[Payee],
412
430
  ) -> None:
413
431
  if not payees:
414
432
  return
415
433
 
416
434
  task_id = context.progress.add_task("Payees", total=len(payees))
417
- await insert_entries(context, "payees", plan_id, payees, task_id)
435
+ await insert_entries(
436
+ context, "payees", plan_id, [p.model_dump(mode="json") for p in payees], task_id
437
+ )
418
438
 
419
439
 
420
440
  async def insert_transactions(
421
441
  context: _Context,
422
442
  plan_id: str,
423
- transactions: list[dict[str, Any]],
443
+ transactions: list[TransactionDetail],
424
444
  ) -> None:
425
445
  await insert_nested_entries(
426
446
  context,
427
447
  plan_id,
428
- transactions,
448
+ # by_alias=True properly renames 'var_date' to 'date'
449
+ [t.model_dump(mode="json", by_alias=True) for t in transactions],
429
450
  "Transactions",
430
451
  "transactions",
431
452
  "subtransactions",
@@ -436,12 +457,12 @@ async def insert_transactions(
436
457
  async def insert_scheduled_transactions(
437
458
  context: _Context,
438
459
  plan_id: str,
439
- scheduled_transactions: list[dict[str, Any]],
460
+ scheduled_transactions: list[ScheduledTransactionDetail],
440
461
  ) -> None:
441
462
  await insert_nested_entries(
442
463
  context,
443
464
  plan_id,
444
- scheduled_transactions,
465
+ [st.model_dump(mode="json") for st in scheduled_transactions],
445
466
  "Scheduled Transactions",
446
467
  "scheduled_transactions",
447
468
  "subtransactions",
@@ -570,81 +591,118 @@ async def insert_entries(
570
591
  context.progress.update(task_id, advance=len(values_batch))
571
592
 
572
593
 
573
- def jobs(
574
- yc: SupportsYnabClient,
575
- endpoint: _Endpoint,
576
- plan_ids: list[str],
577
- lkos: dict[str, int],
578
- ) -> list[Awaitable[dict[str, Any]]]:
579
- return [
580
- yc(f"plans/{plan_id}/{endpoint}", last_knowledge_of_server=lkos.get(plan_id))
581
- for plan_id in plan_ids
582
- ]
594
+ @retry(stop=stop_after_attempt(3))
595
+ async def _get_plan_summaries(api_client: ApiClient) -> list[PlanSummary]:
596
+ return (await PlansApi(api_client).get_plans()).data.plans
583
597
 
584
598
 
585
- class SupportsYnabClient(Protocol):
586
- async def __call__(
587
- self, path: str, last_knowledge_of_server: int | None = None
588
- ) -> dict[str, Any]: ...
599
+ async def _get_all_ynab(
600
+ context: _Context, plan_ids: list[str], lkos: dict[str, int], task_id: TaskID
601
+ ) -> dict[str, _YnabPlanData]:
602
+ return dict(
603
+ await asyncio.gather(
604
+ *(_get_plan_data(context, plan_id, lkos, task_id) for plan_id in plan_ids)
605
+ )
606
+ )
589
607
 
590
608
 
591
- @dataclass
592
- class ProgressYnabClient:
593
- yc: YnabClient
594
- context: _Context
595
- task_id: TaskID
596
-
597
- async def __call__(
598
- self, path: str, last_knowledge_of_server: int | None = None
599
- ) -> dict[str, Any]:
600
- try:
601
- return await self.yc(path, last_knowledge_of_server)
602
- finally:
603
- self.context.progress.update(self.task_id, advance=1)
609
+ async def _get_plan_data(
610
+ context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
611
+ ) -> tuple[str, _YnabPlanData]:
612
+ (
613
+ accounts,
614
+ categories,
615
+ payees,
616
+ transactions_serverknowledge,
617
+ scheduled_transactions,
618
+ ) = await asyncio.gather(
619
+ _get_accounts(context, plan_id, lkos, task_id),
620
+ _get_categories(context, plan_id, lkos, task_id),
621
+ _get_payees(context, plan_id, lkos, task_id),
622
+ _get_transactions(context, plan_id, lkos, task_id),
623
+ _get_scheduled_transactions(context, plan_id, lkos, task_id),
624
+ )
625
+ transactions, server_knowledge = transactions_serverknowledge
626
+ return (
627
+ plan_id,
628
+ _YnabPlanData(
629
+ accounts=accounts,
630
+ category_groups=categories,
631
+ payees=payees,
632
+ transactions=transactions,
633
+ server_knowledge=server_knowledge,
634
+ scheduled_transactions=scheduled_transactions,
635
+ ),
636
+ )
604
637
 
605
638
 
606
- @dataclass
607
- class YnabClient:
608
- BASE_SCHEME: ClassVar[str] = "https"
609
- BASE_NETLOC: ClassVar[str] = "api.ynab.com"
610
- BASE_PATH: ClassVar[str] = "v1/"
611
-
612
- token: str
613
- session: aiohttp.ClientSession
614
-
615
- async def __call__(
616
- self, path: str, last_knowledge_of_server: int | None = None
617
- ) -> dict[str, Any]:
618
- headers = {
619
- "Authorization": f"Bearer {self.token}",
620
- "Content-Type": "application/json",
621
- }
622
- url = urlunparse(
623
- (
624
- self.BASE_SCHEME,
625
- self.BASE_NETLOC,
626
- urljoin(self.BASE_PATH, path),
627
- "",
628
- urlencode(
629
- {"last_knowledge_of_server": last_knowledge_of_server}
630
- if last_knowledge_of_server
631
- else {}
632
- ),
633
- "",
634
- )
635
- )
639
+ @retry(stop=stop_after_attempt(3))
640
+ async def _get_accounts(
641
+ context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
642
+ ) -> list[Account]:
643
+ resp = await AccountsApi(context.api_client).get_accounts(
644
+ plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
645
+ )
646
+ context.progress.update(task_id, advance=1)
647
+ return resp.data.accounts
636
648
 
637
- for i in range(3):
638
- try:
639
- async with self.session.get(url, headers=headers) as resp:
640
- body = await resp.text()
641
649
 
642
- return json.loads(body)["data"]
643
- except Exception:
644
- if i == 2:
645
- raise
650
+ @retry(stop=stop_after_attempt(3))
651
+ async def _get_categories(
652
+ context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
653
+ ) -> list[CategoryGroupWithCategories]:
654
+ resp = await CategoriesApi(context.api_client).get_categories(
655
+ plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
656
+ )
657
+ context.progress.update(task_id, advance=1)
658
+ return resp.data.category_groups
659
+
660
+
661
+ @retry(stop=stop_after_attempt(3))
662
+ async def _get_payees(
663
+ context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
664
+ ) -> list[Payee]:
665
+ resp = await PayeesApi(context.api_client).get_payees(
666
+ plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
667
+ )
668
+ context.progress.update(task_id, advance=1)
669
+ return resp.data.payees
646
670
 
647
- raise AssertionError("unreachable")
671
+
672
+ @retry(stop=stop_after_attempt(3))
673
+ async def _get_transactions(
674
+ context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
675
+ ) -> tuple[list[TransactionDetail], int]:
676
+ resp = await TransactionsApi(context.api_client).get_transactions(
677
+ plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
678
+ )
679
+ context.progress.update(task_id, advance=1)
680
+ return resp.data.transactions, resp.data.server_knowledge
681
+
682
+
683
+ @retry(stop=stop_after_attempt(3))
684
+ async def _get_scheduled_transactions(
685
+ context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
686
+ ) -> list[ScheduledTransactionDetail]:
687
+ resp = await ScheduledTransactionsApi(
688
+ context.api_client
689
+ ).get_scheduled_transactions(
690
+ plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
691
+ )
692
+ context.progress.update(task_id, advance=1)
693
+ return resp.data.scheduled_transactions
694
+
695
+
696
+ # @retry(stop=stop_after_attempt(3))
697
+ # async def _get_ynab[T](
698
+ # context: _Context,
699
+ # getter: Callable[..., Awaitable[T]],
700
+ # task_id: TaskID,
701
+ # ) -> T:
702
+ # try:
703
+ # return await getter()
704
+ # finally:
705
+ # context.progress.update(task_id, advance=1)
648
706
 
649
707
 
650
708
  def main(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlite_export_for_ynab
3
- Version: 2.5.1
3
+ Version: 2.7.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
@@ -17,7 +17,10 @@ License-File: LICENSE
17
17
  Requires-Dist: aiohttp>=3
18
18
  Requires-Dist: aiopathlib
19
19
  Requires-Dist: aiosqlite
20
+ Requires-Dist: asyncio-for-ynab
21
+ Requires-Dist: fasteners
20
22
  Requires-Dist: rich>=14
23
+ Requires-Dist: tenacity
21
24
  Dynamic: license-file
22
25
 
23
26
  # sqlite-export-for-ynab
@@ -1,4 +1,7 @@
1
1
  aiohttp>=3
2
2
  aiopathlib
3
3
  aiosqlite
4
+ asyncio-for-ynab
5
+ fasteners
4
6
  rich>=14
7
+ tenacity