sqlite-export-for-ynab 2.9.1__py3-none-any.whl

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,6 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlite_export_for_ynab._main import default_db_path
4
+ from sqlite_export_for_ynab._main import sync
5
+
6
+ __all__ = ["default_db_path", "sync"]
@@ -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,687 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import os
6
+ from contextlib import asynccontextmanager
7
+ from contextlib import contextmanager
8
+ from dataclasses import dataclass
9
+ from dataclasses import fields
10
+ from importlib import resources
11
+ from importlib.metadata import version
12
+ from itertools import batched
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from typing import Literal
16
+ from typing import overload
17
+ from typing import TYPE_CHECKING
18
+
19
+ import aiosqlite
20
+ import asyncio_for_ynab # noqa: F401
21
+ import fasteners
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
37
+ from rich.progress import BarColumn
38
+ from rich.progress import Progress
39
+ from rich.progress import TaskID
40
+ from rich.progress import TextColumn
41
+ from rich.progress import TimeElapsedColumn
42
+ from tenacity import retry
43
+ from tenacity import stop_after_attempt
44
+
45
+ from sqlite_export_for_ynab import ddl
46
+
47
+ if TYPE_CHECKING:
48
+ from collections.abc import AsyncIterator
49
+ from collections.abc import Awaitable
50
+ from collections.abc import Callable
51
+ from collections.abc import Iterator
52
+ from collections.abc import Sequence
53
+
54
+ try:
55
+ from rich.progress import MofNCompleteColumn
56
+ # https://github.com/benleb/surepy/issues/240
57
+ except ImportError: # pragma: no cover
58
+ from rich.progress import ProgressColumn
59
+ from rich.progress import Task
60
+ from rich.text import Text
61
+
62
+ if TYPE_CHECKING:
63
+ from rich.table import Column
64
+
65
+ class MofNCompleteColumn(ProgressColumn): # type:ignore[no-redef]
66
+ def __init__(self, separator: str = "/", table_column: Column | None = None):
67
+ self.separator = separator
68
+ super().__init__(table_column=table_column)
69
+
70
+ def render(self, task: Task) -> Text:
71
+ """Show completed/total."""
72
+ completed = int(task.completed)
73
+ total = int(task.total) if task.total is not None else "?"
74
+ total_width = len(str(total))
75
+ return Text(
76
+ f"{completed:{total_width}d}{self.separator}{total}",
77
+ style="progress.download",
78
+ )
79
+
80
+
81
+ _EntryTable = (
82
+ Literal["accounts"]
83
+ | Literal["account_periodic_values"]
84
+ | Literal["category_groups"]
85
+ | Literal["categories"]
86
+ | Literal["payees"]
87
+ | Literal["transactions"]
88
+ | Literal["subtransactions"]
89
+ | Literal["scheduled_transactions"]
90
+ | Literal["scheduled_subtransactions"]
91
+ )
92
+ _Endpoint = (
93
+ Literal["accounts"]
94
+ | Literal["categories"]
95
+ | Literal["payees"]
96
+ | Literal["transactions"]
97
+ | Literal["scheduled_transactions"]
98
+ )
99
+ _ENDPOINTS = tuple(lit.__args__[0] for lit in _Endpoint.__args__)
100
+ _ALL_RELATIONS = frozenset(
101
+ ("plans", "flat_transactions", "scheduled_flat_transactions")
102
+ + tuple(lit.__args__[0] for lit in _EntryTable.__args__)
103
+ )
104
+
105
+ _ENV_TOKEN = "YNAB_PERSONAL_ACCESS_TOKEN"
106
+
107
+ _PACKAGE = "sqlite-export-for-ynab"
108
+
109
+ _BATCH_SIZE = 100
110
+ _SYNC_LOCK_TIMEOUT = 30.0
111
+
112
+ _PROGRESS_COLUMNS = (
113
+ TextColumn("[progress.description]{task.description}"),
114
+ BarColumn(),
115
+ MofNCompleteColumn(),
116
+ TimeElapsedColumn(),
117
+ )
118
+
119
+
120
+ def resolve_token(token_override: str | None = None) -> str:
121
+ token = token_override or os.environ.get(_ENV_TOKEN)
122
+ if token:
123
+ return token
124
+
125
+ raise ValueError(
126
+ f"Must set YNAB access token as {_ENV_TOKEN!r} environment variable or pass "
127
+ "token_override directly. See https://api.ynab.com/#personal-access-tokens"
128
+ )
129
+
130
+
131
+ async def async_main(
132
+ argv: Sequence[str] | None = None, *, token_override: str | None = None
133
+ ) -> int:
134
+ parser = argparse.ArgumentParser(prog=_PACKAGE)
135
+ parser.add_argument(
136
+ "--db",
137
+ help="The path to the SQLite database file.",
138
+ type=Path,
139
+ default=default_db_path(),
140
+ )
141
+ parser.add_argument(
142
+ "--full-refresh",
143
+ action="store_true",
144
+ help="**DROP ALL TABLES** and fetch all data again.",
145
+ )
146
+ parser.add_argument(
147
+ "--version", action="version", version=f"%(prog)s {version(_PACKAGE)}"
148
+ )
149
+ parser.add_argument(
150
+ "--quiet",
151
+ action="store_true",
152
+ help="Suppress all CLI output, including progress bars.",
153
+ )
154
+
155
+ args = parser.parse_args(argv)
156
+ db: Path = args.db
157
+ full_refresh: bool = args.full_refresh
158
+ quiet: bool = args.quiet
159
+
160
+ token = resolve_token(token_override)
161
+
162
+ await sync(token, db, full_refresh, quiet=quiet)
163
+
164
+ return 0
165
+
166
+
167
+ def default_db_path() -> Path:
168
+ return (
169
+ (
170
+ Path(xdg_data_home)
171
+ if (xdg_data_home := os.environ.get("XDG_DATA_HOME"))
172
+ else Path.home() / ".local" / "share"
173
+ )
174
+ / _PACKAGE
175
+ / "db.sqlite"
176
+ )
177
+
178
+
179
+ def _print(message: str, *, quiet: bool) -> None:
180
+ if not quiet:
181
+ print(message)
182
+
183
+
184
+ @dataclass
185
+ class _Context:
186
+ progress: Progress
187
+ con: aiosqlite.Connection
188
+ lock: fasteners.InterProcessLock
189
+ api_client: ApiClient
190
+
191
+
192
+ @dataclass
193
+ class _YnabPlanData:
194
+ accounts: list[Account]
195
+ category_groups: list[CategoryGroupWithCategories]
196
+ payees: list[Payee]
197
+ transactions: list[TransactionDetail]
198
+ server_knowledge: int
199
+ scheduled_transactions: list[ScheduledTransactionDetail]
200
+
201
+ def has_data(self) -> bool:
202
+ return any(
203
+ getattr(self, field.name)
204
+ for field in fields(self)
205
+ if field.name != "server_knowledge"
206
+ )
207
+
208
+
209
+ @asynccontextmanager
210
+ async def _context(
211
+ db: Path,
212
+ configuration: Configuration,
213
+ *,
214
+ quiet: bool,
215
+ timeout: float = _SYNC_LOCK_TIMEOUT,
216
+ ) -> AsyncIterator[_Context]:
217
+ progress = Progress(*_PROGRESS_COLUMNS, disable=quiet)
218
+ lock_path = db.parent / f"{db.name}.lock"
219
+ lock = fasteners.InterProcessLock(lock_path)
220
+ async with (
221
+ ApiClient(configuration) as api_client,
222
+ aiosqlite.connect(db) as con,
223
+ ):
224
+ con.row_factory = aiosqlite.Row
225
+ loop = asyncio.get_running_loop()
226
+ deadline = loop.time() + timeout
227
+ _print("Acquiring lock...", quiet=quiet)
228
+ while True:
229
+ acquired = await asyncio.to_thread(lock.acquire, False)
230
+ if acquired:
231
+ _print("Done", quiet=quiet)
232
+ break
233
+ if loop.time() >= deadline:
234
+ raise TimeoutError(
235
+ f"Timed out waiting {timeout} seconds for sync lock at {lock.path}"
236
+ )
237
+ await asyncio.sleep(0.1)
238
+
239
+ try:
240
+ yield _Context(progress, con, lock, api_client)
241
+ finally:
242
+ try:
243
+ await asyncio.to_thread(lock.release)
244
+ finally:
245
+ await AsyncPath(lock_path).unlink(missing_ok=True)
246
+
247
+
248
+ @contextmanager
249
+ def _progress(context: _Context) -> Iterator[None]:
250
+ context.progress.start()
251
+ try:
252
+ yield
253
+ finally:
254
+ context.progress.stop()
255
+ for task_id in context.progress.task_ids:
256
+ context.progress.remove_task(task_id)
257
+
258
+
259
+ async def sync(
260
+ token: str, db: Path, full_refresh: bool, *, quiet: bool = False
261
+ ) -> None:
262
+ await AsyncPath(db).parent.mkdir(parents=True, exist_ok=True)
263
+
264
+ configuration = Configuration(access_token=token)
265
+ async with _context(
266
+ db, configuration, quiet=quiet, timeout=_SYNC_LOCK_TIMEOUT
267
+ ) as context:
268
+ plans = await _get_plan_summaries(context.api_client)
269
+
270
+ if full_refresh:
271
+ _print("Dropping relations...", quiet=quiet)
272
+ async with context.con.cursor() as cur:
273
+ await cur.executescript(await contents("drop-relations.sql"))
274
+ await context.con.commit()
275
+ _print("Done", quiet=quiet)
276
+
277
+ async with context.con.cursor() as cur:
278
+ relations = await get_relations(cur)
279
+ if relations != _ALL_RELATIONS:
280
+ _print("Recreating relations...", quiet=quiet)
281
+ await cur.executescript(await contents("create-relations.sql"))
282
+ await context.con.commit()
283
+ _print("Done", quiet=quiet)
284
+
285
+ _print("Fetching plan data...", quiet=quiet)
286
+ async with context.con.cursor() as cur:
287
+ lkos = await get_last_knowledge_of_server(cur)
288
+ with _progress(context):
289
+ task = context.progress.add_task(
290
+ "Plan Data", total=len(plans) * len(_ENDPOINTS)
291
+ )
292
+
293
+ all_data = await _get_all_ynab(
294
+ context, [str(plan.id) for plan in plans], lkos, task
295
+ )
296
+
297
+ _print("Done", quiet=quiet)
298
+
299
+ if any(plan_data.has_data() for plan_data in all_data.values()):
300
+ _print("Inserting plan data...", quiet=quiet)
301
+ with _progress(context):
302
+ await insert_plan_data(context, plans, all_data)
303
+ await context.con.commit()
304
+ _print("Done", quiet=quiet)
305
+ else:
306
+ _print("No new data fetched", quiet=quiet)
307
+
308
+
309
+ async def contents(filename: str) -> str:
310
+ return await AsyncPath(str(resources.files(ddl) / filename)).read_text()
311
+
312
+
313
+ async def get_relations(cur: aiosqlite.Cursor) -> set[str]:
314
+ await cur.execute(
315
+ "SELECT name FROM sqlite_master WHERE type='table' OR type='view'"
316
+ )
317
+ return {t["name"] for t in await cur.fetchall()}
318
+
319
+
320
+ async def get_last_knowledge_of_server(cur: aiosqlite.Cursor) -> dict[str, int]:
321
+ await cur.execute(
322
+ "SELECT id, last_knowledge_of_server FROM plans",
323
+ )
324
+ return {r["id"]: r["last_knowledge_of_server"] for r in await cur.fetchall()}
325
+
326
+
327
+ async def insert_plan_data(
328
+ context: _Context, plans: list[PlanSummary], all_data: dict[str, _YnabPlanData]
329
+ ) -> None:
330
+ await insert_plans(
331
+ context,
332
+ plans,
333
+ {plan_id: d.server_knowledge for plan_id, d in all_data.items()},
334
+ )
335
+ await asyncio.gather(
336
+ *(
337
+ asyncio.gather(
338
+ insert_accounts(context, plan_id, all_data[plan_id].accounts),
339
+ insert_category_groups(
340
+ context, plan_id, all_data[plan_id].category_groups
341
+ ),
342
+ insert_payees(context, plan_id, all_data[plan_id].payees),
343
+ )
344
+ for plan_id in all_data
345
+ ),
346
+ )
347
+ await asyncio.gather(
348
+ *(
349
+ asyncio.gather(
350
+ insert_transactions(context, plan_id, all_data[plan_id].transactions),
351
+ insert_scheduled_transactions(
352
+ context, plan_id, all_data[plan_id].scheduled_transactions
353
+ ),
354
+ )
355
+ for plan_id in all_data
356
+ ),
357
+ )
358
+
359
+
360
+ async def insert_plans(
361
+ context: _Context, plans: list[PlanSummary], lkos: dict[str, int]
362
+ ) -> None:
363
+ async with context.con.cursor() as cur:
364
+ for plan_batch in batched(plans, _BATCH_SIZE):
365
+ await cur.executemany(
366
+ """
367
+ INSERT OR REPLACE INTO plans (
368
+ id
369
+ , name
370
+ , currency_format_currency_symbol
371
+ , currency_format_decimal_digits
372
+ , currency_format_decimal_separator
373
+ , currency_format_display_symbol
374
+ , currency_format_group_separator
375
+ , currency_format_iso_code
376
+ , currency_format_symbol_first
377
+ , last_knowledge_of_server
378
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
379
+ """,
380
+ (
381
+ (
382
+ plan_id := str(plan.id),
383
+ plan.name,
384
+ getattr(cf := plan.currency_format, "currency_symbol", None),
385
+ getattr(cf, "decimal_digits", None),
386
+ getattr(cf, "decimal_separator", None),
387
+ getattr(cf, "display_symbol", None),
388
+ getattr(cf, "group_separator", None),
389
+ getattr(cf, "iso_code", None),
390
+ getattr(cf, "symbol_first", None),
391
+ lkos[plan_id],
392
+ )
393
+ for plan in plan_batch
394
+ ),
395
+ )
396
+
397
+
398
+ _LOAN_ACCOUNT_PERIODIC_VALUES = frozenset(
399
+ ("debt_escrow_amounts", "debt_interest_rates", "debt_minimum_payments")
400
+ )
401
+
402
+
403
+ async def insert_accounts(
404
+ context: _Context,
405
+ plan_id: str,
406
+ accounts: list[Account],
407
+ ) -> None:
408
+ # YNAB's LoanAccountPeriodValues are untyped dicts so we need to turn them into a more standard sub-entry view
409
+ updated_accounts = [
410
+ {
411
+ "account_periodic_values": [
412
+ {
413
+ "name": key,
414
+ "account_id": account["id"],
415
+ "date": apvk,
416
+ "amount": apvv,
417
+ }
418
+ for key in _LOAN_ACCOUNT_PERIODIC_VALUES
419
+ for apvk, apvv in account[key].items()
420
+ ]
421
+ }
422
+ | {k: v for k, v in account.items() if k not in _LOAN_ACCOUNT_PERIODIC_VALUES}
423
+ for account in (acc.model_dump(mode="json") for acc in accounts)
424
+ ]
425
+
426
+ await insert_nested_entries(
427
+ context,
428
+ plan_id,
429
+ updated_accounts,
430
+ "Accounts",
431
+ "accounts",
432
+ "account_periodic_values",
433
+ "account_periodic_values",
434
+ )
435
+
436
+
437
+ async def insert_category_groups(
438
+ context: _Context,
439
+ plan_id: str,
440
+ category_groups: list[CategoryGroupWithCategories],
441
+ ) -> None:
442
+ await insert_nested_entries(
443
+ context,
444
+ plan_id,
445
+ [cg.model_dump(mode="json") for cg in category_groups],
446
+ "Categories",
447
+ "category_groups",
448
+ "categories",
449
+ "categories",
450
+ )
451
+
452
+
453
+ async def insert_payees(
454
+ context: _Context,
455
+ plan_id: str,
456
+ payees: list[Payee],
457
+ ) -> None:
458
+ if not payees:
459
+ return
460
+
461
+ task_id = context.progress.add_task("Payees", total=len(payees))
462
+ await insert_entries(
463
+ context, "payees", plan_id, [p.model_dump(mode="json") for p in payees], task_id
464
+ )
465
+
466
+
467
+ async def insert_transactions(
468
+ context: _Context,
469
+ plan_id: str,
470
+ transactions: list[TransactionDetail],
471
+ ) -> None:
472
+ await insert_nested_entries(
473
+ context,
474
+ plan_id,
475
+ # by_alias=True properly renames 'var_date' to 'date'
476
+ [t.model_dump(mode="json", by_alias=True) for t in transactions],
477
+ "Transactions",
478
+ "transactions",
479
+ "subtransactions",
480
+ "subtransactions",
481
+ )
482
+
483
+
484
+ async def insert_scheduled_transactions(
485
+ context: _Context,
486
+ plan_id: str,
487
+ scheduled_transactions: list[ScheduledTransactionDetail],
488
+ ) -> None:
489
+ await insert_nested_entries(
490
+ context,
491
+ plan_id,
492
+ [st.model_dump(mode="json") for st in scheduled_transactions],
493
+ "Scheduled Transactions",
494
+ "scheduled_transactions",
495
+ "subtransactions",
496
+ "scheduled_subtransactions",
497
+ )
498
+
499
+
500
+ @overload
501
+ async def insert_nested_entries(
502
+ context: _Context,
503
+ plan_id: str,
504
+ entries: list[dict[str, Any]],
505
+ desc: Literal["Accounts"],
506
+ entries_name: Literal["accounts"],
507
+ subentries_name: Literal["account_periodic_values"],
508
+ subentries_table_name: Literal["account_periodic_values"],
509
+ ) -> None: ...
510
+
511
+
512
+ @overload
513
+ async def insert_nested_entries(
514
+ context: _Context,
515
+ plan_id: str,
516
+ entries: list[dict[str, Any]],
517
+ desc: Literal["Categories"],
518
+ entries_name: Literal["category_groups"],
519
+ subentries_name: Literal["categories"],
520
+ subentries_table_name: Literal["categories"],
521
+ ) -> None: ...
522
+
523
+
524
+ @overload
525
+ async def insert_nested_entries(
526
+ context: _Context,
527
+ plan_id: str,
528
+ entries: list[dict[str, Any]],
529
+ desc: Literal["Transactions"],
530
+ entries_name: Literal["transactions"],
531
+ subentries_name: Literal["subtransactions"],
532
+ subentries_table_name: Literal["subtransactions"],
533
+ ) -> None: ...
534
+
535
+
536
+ @overload
537
+ async def insert_nested_entries(
538
+ context: _Context,
539
+ plan_id: str,
540
+ entries: list[dict[str, Any]],
541
+ desc: Literal["Scheduled Transactions"],
542
+ entries_name: Literal["scheduled_transactions"],
543
+ subentries_name: Literal["subtransactions"],
544
+ subentries_table_name: Literal["scheduled_subtransactions"],
545
+ ) -> None: ...
546
+
547
+
548
+ async def insert_nested_entries(
549
+ context: _Context,
550
+ plan_id: str,
551
+ entries: list[dict[str, Any]],
552
+ desc: (
553
+ Literal["Accounts"]
554
+ | Literal["Categories"]
555
+ | Literal["Transactions"]
556
+ | Literal["Scheduled Transactions"]
557
+ ),
558
+ entries_name: (
559
+ Literal["accounts"]
560
+ | Literal["category_groups"]
561
+ | Literal["transactions"]
562
+ | Literal["scheduled_transactions"]
563
+ ),
564
+ subentries_name: (
565
+ Literal["account_periodic_values"]
566
+ | Literal["categories"]
567
+ | Literal["subtransactions"]
568
+ ),
569
+ subentries_table_name: (
570
+ Literal["account_periodic_values"]
571
+ | Literal["categories"]
572
+ | Literal["subtransactions"]
573
+ | Literal["scheduled_subtransactions"]
574
+ ),
575
+ ) -> None:
576
+ if not entries:
577
+ return
578
+
579
+ task_id = context.progress.add_task(
580
+ desc, total=sum(1 + len(e[subentries_name]) for e in entries)
581
+ )
582
+ await insert_entries(
583
+ context,
584
+ entries_name,
585
+ plan_id,
586
+ [{k: v for k, v in entry.items() if k != subentries_name} for entry in entries],
587
+ task_id,
588
+ )
589
+ await insert_entries(
590
+ context,
591
+ subentries_table_name,
592
+ plan_id,
593
+ [subentry for entry in entries for subentry in entry[subentries_name]],
594
+ task_id,
595
+ )
596
+
597
+
598
+ async def insert_entries(
599
+ context: _Context,
600
+ table: _EntryTable,
601
+ plan_id: str,
602
+ entries: list[dict[str, Any]],
603
+ task_id: TaskID,
604
+ ) -> None:
605
+ if not entries:
606
+ return
607
+
608
+ async with context.con.cursor() as cur:
609
+ await cur.execute(f"PRAGMA table_info({table})")
610
+ table_columns = {row["name"] async for row in cur}
611
+
612
+ # Ignore any keys the YNAB API returns that aren't columns in the DDL so
613
+ # newly-added API fields don't break the insert.
614
+ entry_keys = tuple(k for k in entries[0] if k in table_columns)
615
+ sql = f"INSERT OR REPLACE INTO {table} ({', '.join(entry_keys + ('plan_id',))}) VALUES ({', '.join('?' * (len(entry_keys) + 1))})"
616
+
617
+ async with context.con.cursor() as cur:
618
+ for entry_batch in batched(entries, _BATCH_SIZE):
619
+ values_batch = [
620
+ tuple(entry[key] for key in entry_keys) + (plan_id,)
621
+ for entry in entry_batch
622
+ ]
623
+ await cur.executemany(sql, values_batch)
624
+ context.progress.update(task_id, advance=len(values_batch))
625
+
626
+
627
+ @retry(stop=stop_after_attempt(3))
628
+ async def _get_plan_summaries(api_client: ApiClient) -> list[PlanSummary]:
629
+ return (await PlansApi(api_client).get_plans()).data.plans
630
+
631
+
632
+ async def _get_all_ynab(
633
+ context: _Context, plan_ids: list[str], lkos: dict[str, int], task_id: TaskID
634
+ ) -> dict[str, _YnabPlanData]:
635
+ return dict(
636
+ await asyncio.gather(
637
+ *(_get_plan_data(context, plan_id, lkos, task_id) for plan_id in plan_ids)
638
+ )
639
+ )
640
+
641
+
642
+ async def _get_plan_data(
643
+ context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
644
+ ) -> tuple[str, _YnabPlanData]:
645
+ py = _ProgressYnab(context, plan_id, lkos, task_id)
646
+ accounts, categories, payees, transactions, scheduled = await asyncio.gather(
647
+ py.get(AccountsApi(context.api_client).get_accounts),
648
+ py.get(CategoriesApi(context.api_client).get_categories),
649
+ py.get(PayeesApi(context.api_client).get_payees),
650
+ py.get(TransactionsApi(context.api_client).get_transactions),
651
+ py.get(ScheduledTransactionsApi(context.api_client).get_scheduled_transactions),
652
+ )
653
+ return (
654
+ plan_id,
655
+ _YnabPlanData(
656
+ accounts=accounts.data.accounts,
657
+ category_groups=categories.data.category_groups,
658
+ payees=payees.data.payees,
659
+ transactions=transactions.data.transactions,
660
+ server_knowledge=transactions.data.server_knowledge,
661
+ scheduled_transactions=scheduled.data.scheduled_transactions,
662
+ ),
663
+ )
664
+
665
+
666
+ @dataclass(slots=True)
667
+ class _ProgressYnab:
668
+ context: _Context
669
+ plan_id: str
670
+ lkos: dict[str, int]
671
+ task_id: TaskID
672
+
673
+ @retry(stop=stop_after_attempt(3))
674
+ async def get[T](self, endpoint: Callable[..., Awaitable[T]]) -> T:
675
+ try:
676
+ return await endpoint(
677
+ plan_id=self.plan_id,
678
+ last_knowledge_of_server=self.lkos.get(self.plan_id),
679
+ )
680
+ finally:
681
+ self.context.progress.update(self.task_id, advance=1)
682
+
683
+
684
+ def main(
685
+ argv: Sequence[str] | None = None, *, token_override: str | None = None
686
+ ) -> int:
687
+ return asyncio.run(async_main(argv, token_override=token_override))
File without changes