sqlite-export-for-ynab 2.9.3__tar.gz → 2.9.5__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 (22) hide show
  1. {sqlite_export_for_ynab-2.9.3/sqlite_export_for_ynab.egg-info → sqlite_export_for_ynab-2.9.5}/PKG-INFO +2 -2
  2. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/pyproject.toml +2 -2
  3. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/sqlite_export_for_ynab/_main.py +79 -7
  4. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5/sqlite_export_for_ynab.egg-info}/PKG-INFO +2 -2
  5. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/sqlite_export_for_ynab.egg-info/requires.txt +1 -1
  6. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/testing/fixtures.py +7 -0
  7. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/tests/_main_test.py +92 -1
  8. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/LICENSE +0 -0
  9. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/README.md +0 -0
  10. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/setup.cfg +0 -0
  11. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/sqlite_export_for_ynab/__init__.py +0 -0
  12. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/sqlite_export_for_ynab/__main__.py +0 -0
  13. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/sqlite_export_for_ynab/ddl/__init__.py +0 -0
  14. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/sqlite_export_for_ynab/ddl/create-relations.sql +0 -0
  15. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/sqlite_export_for_ynab/ddl/drop-relations.sql +0 -0
  16. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/sqlite_export_for_ynab/py.typed +0 -0
  17. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/sqlite_export_for_ynab.egg-info/SOURCES.txt +0 -0
  18. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/sqlite_export_for_ynab.egg-info/dependency_links.txt +0 -0
  19. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/sqlite_export_for_ynab.egg-info/entry_points.txt +0 -0
  20. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/sqlite_export_for_ynab.egg-info/top_level.txt +0 -0
  21. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/testing/__init__.py +0 -0
  22. {sqlite_export_for_ynab-2.9.3 → sqlite_export_for_ynab-2.9.5}/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.9.3
3
+ Version: 2.9.5
4
4
  Summary: SQLite Export for YNAB - Export YNAB Data to SQLite
5
5
  Author-email: Max R <mxr@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -16,7 +16,7 @@ License-File: LICENSE
16
16
  Requires-Dist: aiohttp>=3
17
17
  Requires-Dist: aiopathlib
18
18
  Requires-Dist: aiosqlite
19
- Requires-Dist: asyncio-for-ynab~=1.84.0
19
+ Requires-Dist: asyncio-for-ynab~=1.86.0
20
20
  Requires-Dist: fasteners
21
21
  Requires-Dist: rich>=10
22
22
  Requires-Dist: tenacity
@@ -22,7 +22,7 @@ dependencies = [
22
22
  "aiohttp>=3",
23
23
  "aiopathlib",
24
24
  "aiosqlite",
25
- "asyncio-for-ynab~=1.84.0",
25
+ "asyncio-for-ynab~=1.86.0",
26
26
  "fasteners",
27
27
  "rich>=10",
28
28
  "tenacity",
@@ -34,7 +34,7 @@ license-files = ["LICENSE"]
34
34
  name = "sqlite_export_for_ynab"
35
35
  readme = "README.md"
36
36
  requires-python = ">=3.12"
37
- version = "2.9.3"
37
+ version = "2.9.5"
38
38
 
39
39
  [project.scripts]
40
40
  sqlite-export-for-ynab = "sqlite_export_for_ynab._main:main"
@@ -7,6 +7,8 @@ from contextlib import asynccontextmanager
7
7
  from contextlib import contextmanager
8
8
  from dataclasses import dataclass
9
9
  from dataclasses import fields
10
+ from datetime import date
11
+ from datetime import timedelta
10
12
  from importlib import resources
11
13
  from importlib.metadata import version
12
14
  from itertools import batched
@@ -35,6 +37,8 @@ from asyncio_for_ynab import ScheduledTransactionDetail
35
37
  from asyncio_for_ynab import ScheduledTransactionsApi
36
38
  from asyncio_for_ynab import TransactionDetail
37
39
  from asyncio_for_ynab import TransactionsApi
40
+ from asyncio_for_ynab import TransactionsResponse
41
+ from asyncio_for_ynab import TransactionsResponseData
38
42
  from rich.progress import BarColumn
39
43
  from rich.progress import Progress
40
44
  from rich.progress import TaskID
@@ -295,9 +299,7 @@ async def sync(
295
299
  "Plan Data", total=len(plans) * len(_ENDPOINTS)
296
300
  )
297
301
 
298
- all_data = await _get_all_ynab(
299
- context, [str(plan.id) for plan in plans], lkos, task
300
- )
302
+ all_data = await _get_all_ynab(context, plans, lkos, task)
301
303
 
302
304
  _print("Done", quiet=quiet)
303
305
 
@@ -635,24 +637,30 @@ async def _get_plan_summaries(api_client: ApiClient) -> list[PlanSummary]:
635
637
 
636
638
 
637
639
  async def _get_all_ynab(
638
- context: _Context, plan_ids: list[str], lkos: dict[str, int], task_id: TaskID
640
+ context: _Context, plans: list[PlanSummary], lkos: dict[str, int], task_id: TaskID
639
641
  ) -> dict[str, _YnabPlanData]:
640
642
  return dict(
641
643
  await asyncio.gather(
642
- *(_get_plan_data(context, plan_id, lkos, task_id) for plan_id in plan_ids)
644
+ *(_get_plan_data(context, plan, lkos, task_id) for plan in plans)
643
645
  )
644
646
  )
645
647
 
646
648
 
647
649
  async def _get_plan_data(
648
- context: _Context, plan_id: str, lkos: dict[str, int], task_id: TaskID
650
+ context: _Context, plan: PlanSummary, lkos: dict[str, int], task_id: TaskID
649
651
  ) -> tuple[str, _YnabPlanData]:
652
+ plan_id = str(plan.id)
653
+ assert plan.first_month is not None
650
654
  py = _ProgressYnab(context, plan_id, lkos, task_id)
651
655
  accounts, categories, payees, transactions, scheduled = await asyncio.gather(
652
656
  py.get(AccountsApi(context.api_client).get_accounts),
653
657
  py.get(CategoriesApi(context.api_client).get_categories),
654
658
  py.get(PayeesApi(context.api_client).get_payees),
655
- py.get(TransactionsApi(context.api_client).get_transactions),
659
+ py.get(
660
+ ChunkedTransactionsApi(
661
+ context.api_client, plan.first_month
662
+ ).get_transactions
663
+ ),
656
664
  py.get(ScheduledTransactionsApi(context.api_client).get_scheduled_transactions),
657
665
  )
658
666
  return (
@@ -686,6 +694,70 @@ class _ProgressYnab:
686
694
  self.context.progress.update(self.task_id, advance=1)
687
695
 
688
696
 
697
+ @dataclass(slots=True, frozen=True)
698
+ class ChunkedTransactionsApi:
699
+ api_client: ApiClient
700
+ first_month: date
701
+
702
+ async def get_transactions(
703
+ self, *, plan_id: str, last_knowledge_of_server: int | None
704
+ ) -> TransactionsResponse:
705
+ transactions_api = TransactionsApi(self.api_client)
706
+ if last_knowledge_of_server is not None:
707
+ return await transactions_api.get_transactions(
708
+ plan_id=plan_id,
709
+ last_knowledge_of_server=last_knowledge_of_server,
710
+ )
711
+ return await self._get(transactions_api, plan_id)
712
+
713
+ async def _get(
714
+ self, transactions_api: TransactionsApi, plan_id: str
715
+ ) -> TransactionsResponse:
716
+ today = date.today()
717
+ responses = await asyncio.gather(
718
+ *(
719
+ self._chunk(transactions_api, plan_id, sd, ud)
720
+ for sd, ud in _quarterly(self.first_month, today)
721
+ )
722
+ )
723
+ transactions = [t for r in responses for t in r.data.transactions]
724
+ ids = [t.id for t in transactions]
725
+ assert len(set(ids)) == len(ids) # paranoid check for no overlap across chunks
726
+ return TransactionsResponse(
727
+ data=TransactionsResponseData(
728
+ transactions=transactions,
729
+ server_knowledge=max(r.data.server_knowledge for r in responses),
730
+ )
731
+ )
732
+
733
+ @retry(stop=stop_after_attempt(3))
734
+ async def _chunk(
735
+ self,
736
+ transactions_api: TransactionsApi,
737
+ plan_id: str,
738
+ since_date: date,
739
+ until_date: date,
740
+ ) -> TransactionsResponse:
741
+ return await transactions_api.get_transactions(
742
+ plan_id=plan_id,
743
+ since_date=since_date,
744
+ until_date=until_date,
745
+ )
746
+
747
+
748
+ def _add_months(d: date, months: int) -> date:
749
+ month_index = d.month - 1 + months
750
+ return date(d.year + month_index // 12, month_index % 12 + 1, 1)
751
+
752
+
753
+ def _quarterly(first_month: date, today: date) -> Iterator[tuple[date, date]]:
754
+ since_date = first_month
755
+ while since_date <= today:
756
+ until_date = min(_add_months(since_date, 3) - timedelta(days=1), today)
757
+ yield since_date, until_date
758
+ since_date = until_date + timedelta(days=1)
759
+
760
+
689
761
  def main(
690
762
  argv: Sequence[str] | None = None, *, token_override: str | None = None
691
763
  ) -> int:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlite_export_for_ynab
3
- Version: 2.9.3
3
+ Version: 2.9.5
4
4
  Summary: SQLite Export for YNAB - Export YNAB Data to SQLite
5
5
  Author-email: Max R <mxr@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -16,7 +16,7 @@ License-File: LICENSE
16
16
  Requires-Dist: aiohttp>=3
17
17
  Requires-Dist: aiopathlib
18
18
  Requires-Dist: aiosqlite
19
- Requires-Dist: asyncio-for-ynab~=1.84.0
19
+ Requires-Dist: asyncio-for-ynab~=1.86.0
20
20
  Requires-Dist: fasteners
21
21
  Requires-Dist: rich>=10
22
22
  Requires-Dist: tenacity
@@ -1,7 +1,7 @@
1
1
  aiohttp>=3
2
2
  aiopathlib
3
3
  aiosqlite
4
- asyncio-for-ynab~=1.84.0
4
+ asyncio-for-ynab~=1.86.0
5
5
  fasteners
6
6
  rich>=10
7
7
  tenacity
@@ -37,6 +37,7 @@ PLANS: list[PlanSummary] = [
37
37
  PlanSummary(
38
38
  id=UUID(PLAN_ID_1),
39
39
  name="Plan 1",
40
+ first_month=date.fromisoformat("2024-01-01"),
40
41
  currency_format=CurrencyFormat(
41
42
  currency_symbol="$",
42
43
  decimal_digits=2,
@@ -51,6 +52,7 @@ PLANS: list[PlanSummary] = [
51
52
  PlanSummary(
52
53
  id=UUID(PLAN_ID_2),
53
54
  name="Plan 2",
55
+ first_month=date.fromisoformat("2024-01-01"),
54
56
  currency_format=CurrencyFormat(
55
57
  currency_symbol="$",
56
58
  decimal_digits=2,
@@ -67,6 +69,11 @@ PLANS: list[PlanSummary] = [
67
69
  SERVER_KNOWLEDGE_1 = 107667
68
70
  SERVER_KNOWLEDGE_2 = 107668
69
71
 
72
+ TRANSACTION_CHUNKS = [
73
+ (date(2021, 1, 1), date(2021, 3, 31)),
74
+ (date(2021, 4, 1), date(2021, 6, 30)),
75
+ ]
76
+
70
77
  LKOS = {
71
78
  PLAN_ID_1: SERVER_KNOWLEDGE_1,
72
79
  PLAN_ID_2: SERVER_KNOWLEDGE_2,
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import tomllib
4
+ from datetime import date
4
5
  from pathlib import Path
5
6
  from unittest.mock import AsyncMock
6
7
  from unittest.mock import Mock
@@ -20,8 +21,10 @@ from sqlite_export_for_ynab._main import _ENV_TOKEN
20
21
  from sqlite_export_for_ynab._main import _get_plan_summaries
21
22
  from sqlite_export_for_ynab._main import _PACKAGE
22
23
  from sqlite_export_for_ynab._main import _PROGRESS_COLUMNS
24
+ from sqlite_export_for_ynab._main import _quarterly
23
25
  from sqlite_export_for_ynab._main import async_main
24
26
  from sqlite_export_for_ynab._main import asyncio_for_ynab
27
+ from sqlite_export_for_ynab._main import ChunkedTransactionsApi
25
28
  from sqlite_export_for_ynab._main import contents
26
29
  from sqlite_export_for_ynab._main import get_last_knowledge_of_server
27
30
  from sqlite_export_for_ynab._main import get_relations
@@ -71,9 +74,11 @@ from testing.fixtures import SCHEDULED_TRANSACTION_ID_3
71
74
  from testing.fixtures import SCHEDULED_TRANSACTIONS
72
75
  from testing.fixtures import scheduled_transactions_response
73
76
  from testing.fixtures import SERVER_KNOWLEDGE_1
77
+ from testing.fixtures import SERVER_KNOWLEDGE_2
74
78
  from testing.fixtures import SUBTRANSACTION_ID_1
75
79
  from testing.fixtures import SUBTRANSACTION_ID_2
76
80
  from testing.fixtures import TOKEN
81
+ from testing.fixtures import TRANSACTION_CHUNKS
77
82
  from testing.fixtures import TRANSACTION_ID_1
78
83
  from testing.fixtures import TRANSACTION_ID_2
79
84
  from testing.fixtures import TRANSACTION_ID_3
@@ -106,6 +111,87 @@ async def context(tmp_path):
106
111
  yield _Context(progress, con, lock, Mock(spec=asyncio_for_ynab.ApiClient))
107
112
 
108
113
 
114
+ @patch("sqlite_export_for_ynab._main.TransactionsApi.get_transactions")
115
+ @pytest.mark.asyncio
116
+ async def test_chunked_transactions_api_uses_last_knowledge_of_server_when_present(
117
+ get_transactions, context
118
+ ):
119
+ get_transactions.return_value = transactions_response(
120
+ TRANSACTIONS, SERVER_KNOWLEDGE_1
121
+ )
122
+ chunked_transactions_api = ChunkedTransactionsApi(
123
+ context.api_client, date(2020, 1, 1)
124
+ )
125
+
126
+ response = await chunked_transactions_api.get_transactions(
127
+ plan_id=PLAN_ID_1, last_knowledge_of_server=LKOS[PLAN_ID_1]
128
+ )
129
+
130
+ get_transactions.assert_awaited_once_with(
131
+ plan_id=PLAN_ID_1, last_knowledge_of_server=LKOS[PLAN_ID_1]
132
+ )
133
+ assert response.data.transactions == TRANSACTIONS
134
+
135
+
136
+ @pytest.mark.parametrize(
137
+ ("first_month", "today", "expected_chunks"),
138
+ (
139
+ pytest.param(
140
+ date(2021, 4, 1),
141
+ date(2022, 1, 15),
142
+ [
143
+ (date(2021, 4, 1), date(2021, 6, 30)),
144
+ (date(2021, 7, 1), date(2021, 9, 30)),
145
+ (date(2021, 10, 1), date(2021, 12, 31)),
146
+ (date(2022, 1, 1), date(2022, 1, 15)),
147
+ ],
148
+ id="spans-multiple-quarters-with-partial-last-chunk",
149
+ ),
150
+ pytest.param(
151
+ date(2024, 1, 1),
152
+ date(2024, 1, 1),
153
+ [(date(2024, 1, 1), date(2024, 1, 1))],
154
+ id="single-day-range",
155
+ ),
156
+ ),
157
+ )
158
+ def test_quarterly(first_month, today, expected_chunks):
159
+ assert list(_quarterly(first_month, today)) == expected_chunks
160
+
161
+
162
+ def _chunk_merge_test_side_effect(*, plan_id, since_date, until_date):
163
+ assert plan_id == PLAN_ID_1
164
+ assert (since_date, until_date) in TRANSACTION_CHUNKS
165
+ old_transaction, new_transaction, _ = TRANSACTIONS
166
+ if since_date == TRANSACTION_CHUNKS[0][0]:
167
+ return transactions_response([old_transaction], SERVER_KNOWLEDGE_1)
168
+ return transactions_response([new_transaction], SERVER_KNOWLEDGE_2)
169
+
170
+
171
+ @patch("sqlite_export_for_ynab._main._quarterly")
172
+ @patch("sqlite_export_for_ynab._main.TransactionsApi.get_transactions")
173
+ @pytest.mark.asyncio
174
+ async def test_chunked_transactions_api_merges_chunk_responses(
175
+ get_transactions, quarterly_chunks, context
176
+ ):
177
+ get_transactions.side_effect = _chunk_merge_test_side_effect
178
+ quarterly_chunks.return_value = iter(TRANSACTION_CHUNKS)
179
+ chunked_transactions_api = ChunkedTransactionsApi(
180
+ context.api_client, date(2021, 1, 1)
181
+ )
182
+
183
+ response = await chunked_transactions_api.get_transactions(
184
+ plan_id=PLAN_ID_1, last_knowledge_of_server=None
185
+ )
186
+
187
+ old_transaction, new_transaction, _ = TRANSACTIONS
188
+ assert {t.id for t in response.data.transactions} == {
189
+ old_transaction.id,
190
+ new_transaction.id,
191
+ }
192
+ assert response.data.server_knowledge == SERVER_KNOWLEDGE_2
193
+
194
+
109
195
  @pytest.mark.parametrize(
110
196
  ("xdg_data_home", "expected_prefix"),
111
197
  (
@@ -1109,7 +1195,12 @@ async def test_sync_no_data_quiet(tmp_path, capsys):
1109
1195
  )
1110
1196
  @patch(
1111
1197
  "sqlite_export_for_ynab._main.TransactionsApi.get_transactions",
1112
- new=AsyncMock(return_value=transactions_response(TRANSACTIONS, SERVER_KNOWLEDGE_1)),
1198
+ new=AsyncMock(
1199
+ side_effect=lambda *, plan_id, since_date, until_date: transactions_response(
1200
+ [t for t in TRANSACTIONS if since_date <= t.var_date <= until_date],
1201
+ SERVER_KNOWLEDGE_1,
1202
+ )
1203
+ ),
1113
1204
  )
1114
1205
  @patch(
1115
1206
  "sqlite_export_for_ynab._main.ScheduledTransactionsApi.get_scheduled_transactions",