sqlite-export-for-ynab 2.6.0__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.6.0/sqlite_export_for_ynab.egg-info → sqlite_export_for_ynab-2.7.0}/PKG-INFO +3 -1
  2. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/setup.cfg +3 -1
  3. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/_main.py +210 -179
  4. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0/sqlite_export_for_ynab.egg-info}/PKG-INFO +3 -1
  5. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/requires.txt +2 -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.6.0/testing/fixtures.py +0 -373
  9. sqlite_export_for_ynab-2.6.0/tests/_main_test.py +0 -920
  10. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/LICENSE +0 -0
  11. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/README.md +0 -0
  12. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/pyproject.toml +0 -0
  13. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/setup.py +0 -0
  14. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/__init__.py +0 -0
  15. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/__main__.py +0 -0
  16. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/ddl/__init__.py +0 -0
  17. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/ddl/create-relations.sql +0 -0
  18. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/ddl/drop-relations.sql +0 -0
  19. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab/py.typed +0 -0
  20. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/SOURCES.txt +0 -0
  21. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/dependency_links.txt +0 -0
  22. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/entry_points.txt +0 -0
  23. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/sqlite_export_for_ynab.egg-info/top_level.txt +0 -0
  24. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.0}/testing/__init__.py +0 -0
  25. {sqlite_export_for_ynab-2.6.0 → 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.6.0
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,8 +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
20
21
  Requires-Dist: fasteners
21
22
  Requires-Dist: rich>=14
23
+ Requires-Dist: tenacity
22
24
  Dynamic: license-file
23
25
 
24
26
  # sqlite-export-for-ynab
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = sqlite_export_for_ynab
3
- version = 2.6.0
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,8 +22,10 @@ install_requires =
22
22
  aiohttp>=3
23
23
  aiopathlib
24
24
  aiosqlite
25
+ asyncio-for-ynab
25
26
  fasteners
26
27
  rich>=14
28
+ tenacity
27
29
  python_requires = >=3.12
28
30
 
29
31
  [options.entry_points]
@@ -2,40 +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
26
21
  import fasteners
27
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
28
37
  from rich.progress import BarColumn
29
38
  from rich.progress import MofNCompleteColumn
30
39
  from rich.progress import Progress
31
40
  from rich.progress import TaskID
32
41
  from rich.progress import TextColumn
33
42
  from rich.progress import TimeElapsedColumn
43
+ from tenacity import retry
44
+ from tenacity import stop_after_attempt
34
45
 
35
46
  from sqlite_export_for_ynab import ddl
36
47
 
37
48
  if TYPE_CHECKING:
38
- 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
39
52
 
40
53
 
41
54
  _EntryTable = (
@@ -83,7 +96,8 @@ def resolve_token(token_override: str | None = None) -> str:
83
96
  return token
84
97
 
85
98
  raise ValueError(
86
- 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"
87
101
  )
88
102
 
89
103
 
@@ -142,20 +156,44 @@ def _print(message: str, *, quiet: bool) -> None:
142
156
 
143
157
  @dataclass
144
158
  class _Context:
145
- session: aiohttp.ClientSession
146
159
  progress: Progress
147
160
  con: aiosqlite.Connection
148
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
+ )
149
180
 
150
181
 
151
182
  @asynccontextmanager
152
183
  async def _context(
153
- db: Path, *, quiet: bool, timeout: float = _SYNC_LOCK_TIMEOUT
184
+ db: Path,
185
+ configuration: Configuration,
186
+ *,
187
+ quiet: bool,
188
+ timeout: float = _SYNC_LOCK_TIMEOUT,
154
189
  ) -> AsyncIterator[_Context]:
155
190
  progress = Progress(*_PROGRESS_COLUMNS, disable=quiet)
156
191
  lock_path = db.parent / f"{db.name}.lock"
157
192
  lock = fasteners.InterProcessLock(lock_path)
158
- async with aiohttp.ClientSession() as session, aiosqlite.connect(db) as con:
193
+ async with (
194
+ ApiClient(configuration) as api_client,
195
+ aiosqlite.connect(db) as con,
196
+ ):
159
197
  con.row_factory = aiosqlite.Row
160
198
  loop = asyncio.get_running_loop()
161
199
  deadline = loop.time() + timeout
@@ -172,7 +210,7 @@ async def _context(
172
210
  await asyncio.sleep(0.1)
173
211
 
174
212
  try:
175
- yield _Context(session, progress, con, lock)
213
+ yield _Context(progress, con, lock, api_client)
176
214
  finally:
177
215
  try:
178
216
  await asyncio.to_thread(lock.release)
@@ -196,10 +234,11 @@ async def sync(
196
234
  ) -> None:
197
235
  await AsyncPath(db).parent.mkdir(parents=True, exist_ok=True)
198
236
 
199
- async with _context(db, quiet=quiet, timeout=_SYNC_LOCK_TIMEOUT) as context:
200
- plans = (await YnabClient(token, context.session)("plans"))["plans"]
201
-
202
- 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)
203
242
 
204
243
  if full_refresh:
205
244
  _print("Dropping relations...", quiet=quiet)
@@ -220,63 +259,24 @@ async def sync(
220
259
  async with context.con.cursor() as cur:
221
260
  lkos = await get_last_knowledge_of_server(cur)
222
261
  with _progress(context):
223
- yc = ProgressYnabClient(
224
- YnabClient(token, context.session),
225
- context,
226
- context.progress.add_task(
227
- "Plan Data", total=len(plans) * len(_ENDPOINTS)
228
- ),
262
+ task = context.progress.add_task(
263
+ "Plan Data", total=len(plans) * len(_ENDPOINTS)
229
264
  )
230
265
 
231
- endpoint_data = dict(
232
- zip(
233
- _ENDPOINTS,
234
- await asyncio.gather(
235
- *(
236
- asyncio.gather(*jobs(yc, endpoint, plan_ids, lkos))
237
- for endpoint in _ENDPOINTS
238
- )
239
- ),
240
- strict=True,
241
- )
266
+ all_data = await _get_all_ynab(
267
+ context, [str(plan.id) for plan in plans], lkos, task
242
268
  )
243
269
 
244
- all_account_data = endpoint_data["accounts"]
245
- all_cat_data = endpoint_data["categories"]
246
- all_payee_data = endpoint_data["payees"]
247
- all_txn_data = endpoint_data["transactions"]
248
- all_sched_txn_data = endpoint_data["scheduled_transactions"]
249
-
250
- new_lkos = {
251
- plan_id: transaction_data["server_knowledge"]
252
- for plan_id, transaction_data in zip(plan_ids, all_txn_data, strict=True)
253
- }
254
270
  _print("Done", quiet=quiet)
255
271
 
256
- if (
257
- not any(t["accounts"] for t in all_account_data)
258
- and not any(t["category_groups"] for t in all_cat_data)
259
- and not any(p["payees"] for p in all_payee_data)
260
- and not any(t["transactions"] for t in all_txn_data)
261
- and not any(s["scheduled_transactions"] for s in all_sched_txn_data)
262
- ):
263
- _print("No new data fetched", quiet=quiet)
264
- else:
272
+ if any(plan_data.has_data() for plan_data in all_data.values()):
265
273
  _print("Inserting plan data...", quiet=quiet)
266
274
  with _progress(context):
267
- await insert_plan_data(
268
- context,
269
- plans,
270
- plan_ids,
271
- all_account_data,
272
- all_cat_data,
273
- all_payee_data,
274
- all_txn_data,
275
- all_sched_txn_data,
276
- new_lkos,
277
- )
275
+ await insert_plan_data(context, plans, all_data)
278
276
  await context.con.commit()
279
277
  _print("Done", quiet=quiet)
278
+ else:
279
+ _print("No new data fetched", quiet=quiet)
280
280
 
281
281
 
282
282
  async def contents(filename: str) -> str:
@@ -298,49 +298,40 @@ async def get_last_knowledge_of_server(cur: aiosqlite.Cursor) -> dict[str, int]:
298
298
 
299
299
 
300
300
  async def insert_plan_data(
301
- context: _Context,
302
- plans: list[dict[str, Any]],
303
- plan_ids: list[str],
304
- all_account_data: list[dict[str, Any]],
305
- all_cat_data: list[dict[str, Any]],
306
- all_payee_data: list[dict[str, Any]],
307
- all_txn_data: list[dict[str, Any]],
308
- all_sched_txn_data: list[dict[str, Any]],
309
- new_lkos: dict[str, int],
301
+ context: _Context, plans: list[PlanSummary], all_data: dict[str, _YnabPlanData]
310
302
  ) -> None:
311
- 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
+ )
312
308
  await asyncio.gather(
313
309
  *(
314
- insert_accounts(context, plan_id, account_data["accounts"])
315
- for plan_id, account_data in zip(plan_ids, all_account_data, strict=True)
316
- ),
317
- *(
318
- insert_category_groups(context, plan_id, cat_data["category_groups"])
319
- for plan_id, cat_data in zip(plan_ids, all_cat_data, strict=True)
320
- ),
321
- *(
322
- insert_payees(context, plan_id, payee_data["payees"])
323
- 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
324
318
  ),
325
319
  )
326
320
  await asyncio.gather(
327
321
  *(
328
- insert_transactions(context, plan_id, txn_data["transactions"])
329
- for plan_id, txn_data in zip(plan_ids, all_txn_data, strict=True)
330
- ),
331
- *(
332
- insert_scheduled_transactions(
333
- context, plan_id, sched_txn_data["scheduled_transactions"]
334
- )
335
- for plan_id, sched_txn_data in zip(
336
- 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
+ ),
337
327
  )
328
+ for plan_id in all_data
338
329
  ),
339
330
  )
340
331
 
341
332
 
342
333
  async def insert_plans(
343
- context: _Context, plans: list[dict[str, Any]], lkos: dict[str, int]
334
+ context: _Context, plans: list[PlanSummary], lkos: dict[str, int]
344
335
  ) -> None:
345
336
  async with context.con.cursor() as cur:
346
337
  for plan_batch in batched(plans, _BATCH_SIZE):
@@ -361,15 +352,15 @@ async def insert_plans(
361
352
  """,
362
353
  (
363
354
  (
364
- plan_id := plan["id"],
365
- plan["name"],
366
- plan["currency_format"]["currency_symbol"],
367
- plan["currency_format"]["decimal_digits"],
368
- plan["currency_format"]["decimal_separator"],
369
- plan["currency_format"]["display_symbol"],
370
- plan["currency_format"]["group_separator"],
371
- plan["currency_format"]["iso_code"],
372
- 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),
373
364
  lkos[plan_id],
374
365
  )
375
366
  for plan in plan_batch
@@ -385,7 +376,7 @@ _LOAN_ACCOUNT_PERIODIC_VALUES = frozenset(
385
376
  async def insert_accounts(
386
377
  context: _Context,
387
378
  plan_id: str,
388
- accounts: list[dict[str, Any]],
379
+ accounts: list[Account],
389
380
  ) -> None:
390
381
  # YNAB's LoanAccountPeriodValues are untyped dicts so we need to turn them into a more standard sub-entry view
391
382
  updated_accounts = [
@@ -402,7 +393,7 @@ async def insert_accounts(
402
393
  ]
403
394
  }
404
395
  | {k: v for k, v in account.items() if k not in _LOAN_ACCOUNT_PERIODIC_VALUES}
405
- for account in accounts
396
+ for account in (acc.model_dump(mode="json") for acc in accounts)
406
397
  ]
407
398
 
408
399
  await insert_nested_entries(
@@ -419,12 +410,12 @@ async def insert_accounts(
419
410
  async def insert_category_groups(
420
411
  context: _Context,
421
412
  plan_id: str,
422
- category_groups: list[dict[str, Any]],
413
+ category_groups: list[CategoryGroupWithCategories],
423
414
  ) -> None:
424
415
  await insert_nested_entries(
425
416
  context,
426
417
  plan_id,
427
- category_groups,
418
+ [cg.model_dump(mode="json") for cg in category_groups],
428
419
  "Categories",
429
420
  "category_groups",
430
421
  "categories",
@@ -435,24 +426,27 @@ async def insert_category_groups(
435
426
  async def insert_payees(
436
427
  context: _Context,
437
428
  plan_id: str,
438
- payees: list[dict[str, Any]],
429
+ payees: list[Payee],
439
430
  ) -> None:
440
431
  if not payees:
441
432
  return
442
433
 
443
434
  task_id = context.progress.add_task("Payees", total=len(payees))
444
- 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
+ )
445
438
 
446
439
 
447
440
  async def insert_transactions(
448
441
  context: _Context,
449
442
  plan_id: str,
450
- transactions: list[dict[str, Any]],
443
+ transactions: list[TransactionDetail],
451
444
  ) -> None:
452
445
  await insert_nested_entries(
453
446
  context,
454
447
  plan_id,
455
- transactions,
448
+ # by_alias=True properly renames 'var_date' to 'date'
449
+ [t.model_dump(mode="json", by_alias=True) for t in transactions],
456
450
  "Transactions",
457
451
  "transactions",
458
452
  "subtransactions",
@@ -463,12 +457,12 @@ async def insert_transactions(
463
457
  async def insert_scheduled_transactions(
464
458
  context: _Context,
465
459
  plan_id: str,
466
- scheduled_transactions: list[dict[str, Any]],
460
+ scheduled_transactions: list[ScheduledTransactionDetail],
467
461
  ) -> None:
468
462
  await insert_nested_entries(
469
463
  context,
470
464
  plan_id,
471
- scheduled_transactions,
465
+ [st.model_dump(mode="json") for st in scheduled_transactions],
472
466
  "Scheduled Transactions",
473
467
  "scheduled_transactions",
474
468
  "subtransactions",
@@ -597,81 +591,118 @@ async def insert_entries(
597
591
  context.progress.update(task_id, advance=len(values_batch))
598
592
 
599
593
 
600
- def jobs(
601
- yc: SupportsYnabClient,
602
- endpoint: _Endpoint,
603
- plan_ids: list[str],
604
- lkos: dict[str, int],
605
- ) -> list[Awaitable[dict[str, Any]]]:
606
- return [
607
- yc(f"plans/{plan_id}/{endpoint}", last_knowledge_of_server=lkos.get(plan_id))
608
- for plan_id in plan_ids
609
- ]
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
610
597
 
611
598
 
612
- class SupportsYnabClient(Protocol):
613
- async def __call__(
614
- self, path: str, last_knowledge_of_server: int | None = None
615
- ) -> 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
+ )
616
607
 
617
608
 
618
- @dataclass
619
- class ProgressYnabClient:
620
- yc: YnabClient
621
- context: _Context
622
- task_id: TaskID
623
-
624
- async def __call__(
625
- self, path: str, last_knowledge_of_server: int | None = None
626
- ) -> dict[str, Any]:
627
- try:
628
- return await self.yc(path, last_knowledge_of_server)
629
- finally:
630
- 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
+ )
631
637
 
632
638
 
633
- @dataclass
634
- class YnabClient:
635
- BASE_SCHEME: ClassVar[str] = "https"
636
- BASE_NETLOC: ClassVar[str] = "api.ynab.com"
637
- BASE_PATH: ClassVar[str] = "v1/"
638
-
639
- token: str
640
- session: aiohttp.ClientSession
641
-
642
- async def __call__(
643
- self, path: str, last_knowledge_of_server: int | None = None
644
- ) -> dict[str, Any]:
645
- headers = {
646
- "Authorization": f"Bearer {self.token}",
647
- "Content-Type": "application/json",
648
- }
649
- url = urlunparse(
650
- (
651
- self.BASE_SCHEME,
652
- self.BASE_NETLOC,
653
- urljoin(self.BASE_PATH, path),
654
- "",
655
- urlencode(
656
- {"last_knowledge_of_server": last_knowledge_of_server}
657
- if last_knowledge_of_server
658
- else {}
659
- ),
660
- "",
661
- )
662
- )
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
663
648
 
664
- for i in range(3):
665
- try:
666
- async with self.session.get(url, headers=headers) as resp:
667
- body = await resp.text()
668
649
 
669
- return json.loads(body)["data"]
670
- except Exception:
671
- if i == 2:
672
- 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
673
670
 
674
- 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)
675
706
 
676
707
 
677
708
  def main(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlite_export_for_ynab
3
- Version: 2.6.0
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,8 +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
20
21
  Requires-Dist: fasteners
21
22
  Requires-Dist: rich>=14
23
+ Requires-Dist: tenacity
22
24
  Dynamic: license-file
23
25
 
24
26
  # sqlite-export-for-ynab
@@ -1,5 +1,7 @@
1
1
  aiohttp>=3
2
2
  aiopathlib
3
3
  aiosqlite
4
+ asyncio-for-ynab
4
5
  fasteners
5
6
  rich>=14
7
+ tenacity