sqlite-export-for-ynab 2.6.0__tar.gz → 2.7.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.
Files changed (26) hide show
  1. {sqlite_export_for_ynab-2.6.0/sqlite_export_for_ynab.egg-info → sqlite_export_for_ynab-2.7.1}/PKG-INFO +9 -2
  2. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/setup.cfg +12 -3
  3. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab/_main.py +236 -180
  4. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1/sqlite_export_for_ynab.egg-info}/PKG-INFO +9 -2
  5. sqlite_export_for_ynab-2.7.1/sqlite_export_for_ynab.egg-info/requires.txt +13 -0
  6. sqlite_export_for_ynab-2.7.1/testing/fixtures.py +620 -0
  7. sqlite_export_for_ynab-2.7.1/tests/_main_test.py +1094 -0
  8. sqlite_export_for_ynab-2.6.0/sqlite_export_for_ynab.egg-info/requires.txt +0 -5
  9. sqlite_export_for_ynab-2.6.0/testing/fixtures.py +0 -373
  10. sqlite_export_for_ynab-2.6.0/tests/_main_test.py +0 -920
  11. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/LICENSE +0 -0
  12. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/README.md +0 -0
  13. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/pyproject.toml +0 -0
  14. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/setup.py +0 -0
  15. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab/__init__.py +0 -0
  16. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab/__main__.py +0 -0
  17. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab/ddl/__init__.py +0 -0
  18. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab/ddl/create-relations.sql +0 -0
  19. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab/ddl/drop-relations.sql +0 -0
  20. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab/py.typed +0 -0
  21. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab.egg-info/SOURCES.txt +0 -0
  22. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab.egg-info/dependency_links.txt +0 -0
  23. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab.egg-info/entry_points.txt +0 -0
  24. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/sqlite_export_for_ynab.egg-info/top_level.txt +0 -0
  25. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/testing/__init__.py +0 -0
  26. {sqlite_export_for_ynab-2.6.0 → sqlite_export_for_ynab-2.7.1}/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.1
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,15 @@ 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
- Requires-Dist: rich>=14
22
+ Requires-Dist: rich>=12
23
+ Requires-Dist: tenacity
24
+ Provides-Extra: dev
25
+ Requires-Dist: covdefaults>=2.1.0; extra == "dev"
26
+ Requires-Dist: coverage; extra == "dev"
27
+ Requires-Dist: pytest; extra == "dev"
28
+ Requires-Dist: pytest-asyncio; extra == "dev"
22
29
  Dynamic: license-file
23
30
 
24
31
  # 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.1
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,14 +22,23 @@ install_requires =
22
22
  aiohttp>=3
23
23
  aiopathlib
24
24
  aiosqlite
25
+ asyncio-for-ynab
25
26
  fasteners
26
- rich>=14
27
+ rich>=12
28
+ tenacity
27
29
  python_requires = >=3.12
28
30
 
29
31
  [options.entry_points]
30
32
  console_scripts =
31
33
  sqlite-export-for-ynab = sqlite_export_for_ynab._main:main
32
34
 
35
+ [options.extras_require]
36
+ dev =
37
+ covdefaults>=2.1.0
38
+ coverage
39
+ pytest
40
+ pytest-asyncio
41
+
33
42
  [options.package_data]
34
43
  * = *.sql
35
44
  sqlite_export_for_ynab =
@@ -45,7 +54,7 @@ plugins = covdefaults
45
54
  envlist = py,pypy3,pre-commit
46
55
 
47
56
  [testenv]
48
- deps = -rrequirements-dev.txt
57
+ extras = dev
49
58
  commands =
50
59
  coverage erase
51
60
  coverage run -m pytest {posargs:tests}
@@ -2,40 +2,78 @@ 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
- from rich.progress import MofNCompleteColumn
30
38
  from rich.progress import Progress
31
39
  from rich.progress import TaskID
32
40
  from rich.progress import TextColumn
33
41
  from rich.progress import TimeElapsedColumn
42
+ from tenacity import retry
43
+ from tenacity import stop_after_attempt
34
44
 
35
45
  from sqlite_export_for_ynab import ddl
36
46
 
37
47
  if TYPE_CHECKING:
38
- from collections.abc import AsyncIterator, Awaitable, Iterator, Sequence
48
+ from collections.abc import AsyncIterator
49
+ from collections.abc import Iterator
50
+ from collections.abc import Sequence
51
+
52
+ try:
53
+ from rich.progress import MofNCompleteColumn
54
+ # https://github.com/benleb/surepy/issues/240
55
+ except ImportError: # pragma: no cover
56
+ from rich.progress import ProgressColumn
57
+ from rich.progress import Task
58
+ from rich.text import Text
59
+
60
+ if TYPE_CHECKING:
61
+ from rich.table import Column
62
+
63
+ class MofNCompleteColumn(ProgressColumn): # type:ignore[no-redef]
64
+ def __init__(self, separator: str = "/", table_column: Column | None = None):
65
+ self.separator = separator
66
+ super().__init__(table_column=table_column)
67
+
68
+ def render(self, task: Task) -> Text:
69
+ """Show completed/total."""
70
+ completed = int(task.completed)
71
+ total = int(task.total) if task.total is not None else "?"
72
+ total_width = len(str(total))
73
+ return Text(
74
+ f"{completed:{total_width}d}{self.separator}{total}",
75
+ style="progress.download",
76
+ )
39
77
 
40
78
 
41
79
  _EntryTable = (
@@ -83,7 +121,8 @@ def resolve_token(token_override: str | None = None) -> str:
83
121
  return token
84
122
 
85
123
  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"
124
+ f"Must set YNAB access token as {_ENV_TOKEN!r} environment variable or pass "
125
+ "token_override directly. See https://api.ynab.com/#personal-access-tokens"
87
126
  )
88
127
 
89
128
 
@@ -142,20 +181,44 @@ def _print(message: str, *, quiet: bool) -> None:
142
181
 
143
182
  @dataclass
144
183
  class _Context:
145
- session: aiohttp.ClientSession
146
184
  progress: Progress
147
185
  con: aiosqlite.Connection
148
186
  lock: fasteners.InterProcessLock
187
+ api_client: ApiClient
188
+
189
+
190
+ @dataclass
191
+ class _YnabPlanData:
192
+ accounts: list[Account]
193
+ category_groups: list[CategoryGroupWithCategories]
194
+ payees: list[Payee]
195
+ transactions: list[TransactionDetail]
196
+ server_knowledge: int
197
+ scheduled_transactions: list[ScheduledTransactionDetail]
198
+
199
+ def has_data(self) -> bool:
200
+ return any(
201
+ getattr(self, field.name)
202
+ for field in fields(self)
203
+ if field.name != "server_knowledge"
204
+ )
149
205
 
150
206
 
151
207
  @asynccontextmanager
152
208
  async def _context(
153
- db: Path, *, quiet: bool, timeout: float = _SYNC_LOCK_TIMEOUT
209
+ db: Path,
210
+ configuration: Configuration,
211
+ *,
212
+ quiet: bool,
213
+ timeout: float = _SYNC_LOCK_TIMEOUT,
154
214
  ) -> AsyncIterator[_Context]:
155
215
  progress = Progress(*_PROGRESS_COLUMNS, disable=quiet)
156
216
  lock_path = db.parent / f"{db.name}.lock"
157
217
  lock = fasteners.InterProcessLock(lock_path)
158
- async with aiohttp.ClientSession() as session, aiosqlite.connect(db) as con:
218
+ async with (
219
+ ApiClient(configuration) as api_client,
220
+ aiosqlite.connect(db) as con,
221
+ ):
159
222
  con.row_factory = aiosqlite.Row
160
223
  loop = asyncio.get_running_loop()
161
224
  deadline = loop.time() + timeout
@@ -172,7 +235,7 @@ async def _context(
172
235
  await asyncio.sleep(0.1)
173
236
 
174
237
  try:
175
- yield _Context(session, progress, con, lock)
238
+ yield _Context(progress, con, lock, api_client)
176
239
  finally:
177
240
  try:
178
241
  await asyncio.to_thread(lock.release)
@@ -196,10 +259,11 @@ async def sync(
196
259
  ) -> None:
197
260
  await AsyncPath(db).parent.mkdir(parents=True, exist_ok=True)
198
261
 
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]
262
+ configuration = Configuration(access_token=token)
263
+ async with _context(
264
+ db, configuration, quiet=quiet, timeout=_SYNC_LOCK_TIMEOUT
265
+ ) as context:
266
+ plans = await _get_plan_summaries(context.api_client)
203
267
 
204
268
  if full_refresh:
205
269
  _print("Dropping relations...", quiet=quiet)
@@ -220,63 +284,24 @@ async def sync(
220
284
  async with context.con.cursor() as cur:
221
285
  lkos = await get_last_knowledge_of_server(cur)
222
286
  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
- ),
287
+ task = context.progress.add_task(
288
+ "Plan Data", total=len(plans) * len(_ENDPOINTS)
229
289
  )
230
290
 
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
- )
291
+ all_data = await _get_all_ynab(
292
+ context, [str(plan.id) for plan in plans], lkos, task
242
293
  )
243
294
 
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
295
  _print("Done", quiet=quiet)
255
296
 
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:
297
+ if any(plan_data.has_data() for plan_data in all_data.values()):
265
298
  _print("Inserting plan data...", quiet=quiet)
266
299
  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
- )
300
+ await insert_plan_data(context, plans, all_data)
278
301
  await context.con.commit()
279
302
  _print("Done", quiet=quiet)
303
+ else:
304
+ _print("No new data fetched", quiet=quiet)
280
305
 
281
306
 
282
307
  async def contents(filename: str) -> str:
@@ -298,49 +323,40 @@ async def get_last_knowledge_of_server(cur: aiosqlite.Cursor) -> dict[str, int]:
298
323
 
299
324
 
300
325
  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],
326
+ context: _Context, plans: list[PlanSummary], all_data: dict[str, _YnabPlanData]
310
327
  ) -> None:
311
- await insert_plans(context, plans, new_lkos)
328
+ await insert_plans(
329
+ context,
330
+ plans,
331
+ {plan_id: d.server_knowledge for plan_id, d in all_data.items()},
332
+ )
312
333
  await asyncio.gather(
313
334
  *(
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)
335
+ asyncio.gather(
336
+ insert_accounts(context, plan_id, all_data[plan_id].accounts),
337
+ insert_category_groups(
338
+ context, plan_id, all_data[plan_id].category_groups
339
+ ),
340
+ insert_payees(context, plan_id, all_data[plan_id].payees),
341
+ )
342
+ for plan_id in all_data
324
343
  ),
325
344
  )
326
345
  await asyncio.gather(
327
346
  *(
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
347
+ asyncio.gather(
348
+ insert_transactions(context, plan_id, all_data[plan_id].transactions),
349
+ insert_scheduled_transactions(
350
+ context, plan_id, all_data[plan_id].scheduled_transactions
351
+ ),
337
352
  )
353
+ for plan_id in all_data
338
354
  ),
339
355
  )
340
356
 
341
357
 
342
358
  async def insert_plans(
343
- context: _Context, plans: list[dict[str, Any]], lkos: dict[str, int]
359
+ context: _Context, plans: list[PlanSummary], lkos: dict[str, int]
344
360
  ) -> None:
345
361
  async with context.con.cursor() as cur:
346
362
  for plan_batch in batched(plans, _BATCH_SIZE):
@@ -361,15 +377,15 @@ async def insert_plans(
361
377
  """,
362
378
  (
363
379
  (
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"],
380
+ plan_id := str(plan.id),
381
+ plan.name,
382
+ getattr(cf := plan.currency_format, "currency_symbol", None),
383
+ getattr(cf, "decimal_digits", None),
384
+ getattr(cf, "decimal_separator", None),
385
+ getattr(cf, "display_symbol", None),
386
+ getattr(cf, "group_separator", None),
387
+ getattr(cf, "iso_code", None),
388
+ getattr(cf, "symbol_first", None),
373
389
  lkos[plan_id],
374
390
  )
375
391
  for plan in plan_batch
@@ -385,7 +401,7 @@ _LOAN_ACCOUNT_PERIODIC_VALUES = frozenset(
385
401
  async def insert_accounts(
386
402
  context: _Context,
387
403
  plan_id: str,
388
- accounts: list[dict[str, Any]],
404
+ accounts: list[Account],
389
405
  ) -> None:
390
406
  # YNAB's LoanAccountPeriodValues are untyped dicts so we need to turn them into a more standard sub-entry view
391
407
  updated_accounts = [
@@ -402,7 +418,7 @@ async def insert_accounts(
402
418
  ]
403
419
  }
404
420
  | {k: v for k, v in account.items() if k not in _LOAN_ACCOUNT_PERIODIC_VALUES}
405
- for account in accounts
421
+ for account in (acc.model_dump(mode="json") for acc in accounts)
406
422
  ]
407
423
 
408
424
  await insert_nested_entries(
@@ -419,12 +435,12 @@ async def insert_accounts(
419
435
  async def insert_category_groups(
420
436
  context: _Context,
421
437
  plan_id: str,
422
- category_groups: list[dict[str, Any]],
438
+ category_groups: list[CategoryGroupWithCategories],
423
439
  ) -> None:
424
440
  await insert_nested_entries(
425
441
  context,
426
442
  plan_id,
427
- category_groups,
443
+ [cg.model_dump(mode="json") for cg in category_groups],
428
444
  "Categories",
429
445
  "category_groups",
430
446
  "categories",
@@ -435,24 +451,27 @@ async def insert_category_groups(
435
451
  async def insert_payees(
436
452
  context: _Context,
437
453
  plan_id: str,
438
- payees: list[dict[str, Any]],
454
+ payees: list[Payee],
439
455
  ) -> None:
440
456
  if not payees:
441
457
  return
442
458
 
443
459
  task_id = context.progress.add_task("Payees", total=len(payees))
444
- await insert_entries(context, "payees", plan_id, payees, task_id)
460
+ await insert_entries(
461
+ context, "payees", plan_id, [p.model_dump(mode="json") for p in payees], task_id
462
+ )
445
463
 
446
464
 
447
465
  async def insert_transactions(
448
466
  context: _Context,
449
467
  plan_id: str,
450
- transactions: list[dict[str, Any]],
468
+ transactions: list[TransactionDetail],
451
469
  ) -> None:
452
470
  await insert_nested_entries(
453
471
  context,
454
472
  plan_id,
455
- transactions,
473
+ # by_alias=True properly renames 'var_date' to 'date'
474
+ [t.model_dump(mode="json", by_alias=True) for t in transactions],
456
475
  "Transactions",
457
476
  "transactions",
458
477
  "subtransactions",
@@ -463,12 +482,12 @@ async def insert_transactions(
463
482
  async def insert_scheduled_transactions(
464
483
  context: _Context,
465
484
  plan_id: str,
466
- scheduled_transactions: list[dict[str, Any]],
485
+ scheduled_transactions: list[ScheduledTransactionDetail],
467
486
  ) -> None:
468
487
  await insert_nested_entries(
469
488
  context,
470
489
  plan_id,
471
- scheduled_transactions,
490
+ [st.model_dump(mode="json") for st in scheduled_transactions],
472
491
  "Scheduled Transactions",
473
492
  "scheduled_transactions",
474
493
  "subtransactions",
@@ -597,81 +616,118 @@ async def insert_entries(
597
616
  context.progress.update(task_id, advance=len(values_batch))
598
617
 
599
618
 
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
- ]
619
+ @retry(stop=stop_after_attempt(3))
620
+ async def _get_plan_summaries(api_client: ApiClient) -> list[PlanSummary]:
621
+ return (await PlansApi(api_client).get_plans()).data.plans
610
622
 
611
623
 
612
- class SupportsYnabClient(Protocol):
613
- async def __call__(
614
- self, path: str, last_knowledge_of_server: int | None = None
615
- ) -> dict[str, Any]: ...
624
+ async def _get_all_ynab(
625
+ context: _Context, plan_ids: list[str], lkos: dict[str, int], task_id: TaskID
626
+ ) -> dict[str, _YnabPlanData]:
627
+ return dict(
628
+ await asyncio.gather(
629
+ *(_get_plan_data(context, plan_id, lkos, task_id) for plan_id in plan_ids)
630
+ )
631
+ )
616
632
 
617
633
 
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)
634
+ async def _get_plan_data(
635
+ context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
636
+ ) -> tuple[str, _YnabPlanData]:
637
+ (
638
+ accounts,
639
+ categories,
640
+ payees,
641
+ transactions_serverknowledge,
642
+ scheduled_transactions,
643
+ ) = await asyncio.gather(
644
+ _get_accounts(context, plan_id, lkos, task_id),
645
+ _get_categories(context, plan_id, lkos, task_id),
646
+ _get_payees(context, plan_id, lkos, task_id),
647
+ _get_transactions(context, plan_id, lkos, task_id),
648
+ _get_scheduled_transactions(context, plan_id, lkos, task_id),
649
+ )
650
+ transactions, server_knowledge = transactions_serverknowledge
651
+ return (
652
+ plan_id,
653
+ _YnabPlanData(
654
+ accounts=accounts,
655
+ category_groups=categories,
656
+ payees=payees,
657
+ transactions=transactions,
658
+ server_knowledge=server_knowledge,
659
+ scheduled_transactions=scheduled_transactions,
660
+ ),
661
+ )
631
662
 
632
663
 
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
- )
664
+ @retry(stop=stop_after_attempt(3))
665
+ async def _get_accounts(
666
+ context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
667
+ ) -> list[Account]:
668
+ resp = await AccountsApi(context.api_client).get_accounts(
669
+ plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
670
+ )
671
+ context.progress.update(task_id, advance=1)
672
+ return resp.data.accounts
663
673
 
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
674
 
669
- return json.loads(body)["data"]
670
- except Exception:
671
- if i == 2:
672
- raise
675
+ @retry(stop=stop_after_attempt(3))
676
+ async def _get_categories(
677
+ context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
678
+ ) -> list[CategoryGroupWithCategories]:
679
+ resp = await CategoriesApi(context.api_client).get_categories(
680
+ plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
681
+ )
682
+ context.progress.update(task_id, advance=1)
683
+ return resp.data.category_groups
684
+
685
+
686
+ @retry(stop=stop_after_attempt(3))
687
+ async def _get_payees(
688
+ context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
689
+ ) -> list[Payee]:
690
+ resp = await PayeesApi(context.api_client).get_payees(
691
+ plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
692
+ )
693
+ context.progress.update(task_id, advance=1)
694
+ return resp.data.payees
673
695
 
674
- raise AssertionError("unreachable")
696
+
697
+ @retry(stop=stop_after_attempt(3))
698
+ async def _get_transactions(
699
+ context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
700
+ ) -> tuple[list[TransactionDetail], int]:
701
+ resp = await TransactionsApi(context.api_client).get_transactions(
702
+ plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
703
+ )
704
+ context.progress.update(task_id, advance=1)
705
+ return resp.data.transactions, resp.data.server_knowledge
706
+
707
+
708
+ @retry(stop=stop_after_attempt(3))
709
+ async def _get_scheduled_transactions(
710
+ context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
711
+ ) -> list[ScheduledTransactionDetail]:
712
+ resp = await ScheduledTransactionsApi(
713
+ context.api_client
714
+ ).get_scheduled_transactions(
715
+ plan_id=plan_id, last_knowledge_of_server=lkos.get(plan_id)
716
+ )
717
+ context.progress.update(task_id, advance=1)
718
+ return resp.data.scheduled_transactions
719
+
720
+
721
+ # @retry(stop=stop_after_attempt(3))
722
+ # async def _get_ynab[T](
723
+ # context: _Context,
724
+ # getter: Callable[..., Awaitable[T]],
725
+ # task_id: TaskID,
726
+ # ) -> T:
727
+ # try:
728
+ # return await getter()
729
+ # finally:
730
+ # context.progress.update(task_id, advance=1)
675
731
 
676
732
 
677
733
  def main(