sqlite-export-for-ynab 2.5.0__tar.gz → 2.6.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 (23) hide show
  1. {sqlite_export_for_ynab-2.5.0/sqlite_export_for_ynab.egg-info → sqlite_export_for_ynab-2.6.0}/PKG-INFO +3 -2
  2. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/setup.cfg +3 -2
  3. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab/_main.py +30 -3
  4. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0/sqlite_export_for_ynab.egg-info}/PKG-INFO +3 -2
  5. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab.egg-info/requires.txt +2 -1
  6. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/tests/_main_test.py +94 -2
  7. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/LICENSE +0 -0
  8. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/README.md +0 -0
  9. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/pyproject.toml +0 -0
  10. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/setup.py +0 -0
  11. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab/__init__.py +0 -0
  12. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab/__main__.py +0 -0
  13. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab/ddl/__init__.py +0 -0
  14. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab/ddl/create-relations.sql +0 -0
  15. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab/ddl/drop-relations.sql +0 -0
  16. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab/py.typed +0 -0
  17. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab.egg-info/SOURCES.txt +0 -0
  18. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab.egg-info/dependency_links.txt +0 -0
  19. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab.egg-info/entry_points.txt +0 -0
  20. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab.egg-info/top_level.txt +0 -0
  21. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/testing/__init__.py +0 -0
  22. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.0}/testing/fixtures.py +0 -0
  23. {sqlite_export_for_ynab-2.5.0 → sqlite_export_for_ynab-2.6.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.0
3
+ Version: 2.6.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,8 @@ License-File: LICENSE
17
17
  Requires-Dist: aiohttp>=3
18
18
  Requires-Dist: aiopathlib
19
19
  Requires-Dist: aiosqlite
20
- Requires-Dist: rich
20
+ Requires-Dist: fasteners
21
+ Requires-Dist: rich>=14
21
22
  Dynamic: license-file
22
23
 
23
24
  # sqlite-export-for-ynab
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = sqlite_export_for_ynab
3
- version = 2.5.0
3
+ version = 2.6.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,8 @@ install_requires =
22
22
  aiohttp>=3
23
23
  aiopathlib
24
24
  aiosqlite
25
- rich
25
+ fasteners
26
+ rich>=14
26
27
  python_requires = >=3.12
27
28
 
28
29
  [options.entry_points]
@@ -23,6 +23,7 @@ from urllib.parse import urlunparse
23
23
 
24
24
  import aiohttp
25
25
  import aiosqlite
26
+ import fasteners
26
27
  from aiopathlib import AsyncPath
27
28
  from rich.progress import BarColumn
28
29
  from rich.progress import MofNCompleteColumn
@@ -66,6 +67,7 @@ _ENV_TOKEN = "YNAB_PERSONAL_ACCESS_TOKEN"
66
67
  _PACKAGE = "sqlite-export-for-ynab"
67
68
 
68
69
  _BATCH_SIZE = 100
70
+ _SYNC_LOCK_TIMEOUT = 30.0
69
71
 
70
72
  _PROGRESS_COLUMNS = (
71
73
  TextColumn("[progress.description]{task.description}"),
@@ -143,14 +145,39 @@ class _Context:
143
145
  session: aiohttp.ClientSession
144
146
  progress: Progress
145
147
  con: aiosqlite.Connection
148
+ lock: fasteners.InterProcessLock
146
149
 
147
150
 
148
151
  @asynccontextmanager
149
- async def _context(db: Path, *, quiet: bool) -> AsyncIterator[_Context]:
152
+ async def _context(
153
+ db: Path, *, quiet: bool, timeout: float = _SYNC_LOCK_TIMEOUT
154
+ ) -> AsyncIterator[_Context]:
150
155
  progress = Progress(*_PROGRESS_COLUMNS, disable=quiet)
156
+ lock_path = db.parent / f"{db.name}.lock"
157
+ lock = fasteners.InterProcessLock(lock_path)
151
158
  async with aiohttp.ClientSession() as session, aiosqlite.connect(db) as con:
152
159
  con.row_factory = aiosqlite.Row
153
- yield _Context(session, progress, con)
160
+ loop = asyncio.get_running_loop()
161
+ deadline = loop.time() + timeout
162
+ _print("Acquiring lock...", quiet=quiet)
163
+ while True:
164
+ acquired = await asyncio.to_thread(lock.acquire, False)
165
+ if acquired:
166
+ _print("Done", quiet=quiet)
167
+ break
168
+ if loop.time() >= deadline:
169
+ raise TimeoutError(
170
+ f"Timed out waiting {timeout} seconds for sync lock at {lock.path}"
171
+ )
172
+ await asyncio.sleep(0.1)
173
+
174
+ try:
175
+ yield _Context(session, progress, con, lock)
176
+ finally:
177
+ try:
178
+ await asyncio.to_thread(lock.release)
179
+ finally:
180
+ await AsyncPath(lock_path).unlink(missing_ok=True)
154
181
 
155
182
 
156
183
  @contextmanager
@@ -169,7 +196,7 @@ async def sync(
169
196
  ) -> None:
170
197
  await AsyncPath(db).parent.mkdir(parents=True, exist_ok=True)
171
198
 
172
- async with _context(db, quiet=quiet) as context:
199
+ async with _context(db, quiet=quiet, timeout=_SYNC_LOCK_TIMEOUT) as context:
173
200
  plans = (await YnabClient(token, context.session)("plans"))["plans"]
174
201
 
175
202
  plan_ids = [plan["id"] for plan in plans]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlite_export_for_ynab
3
- Version: 2.5.0
3
+ Version: 2.6.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,8 @@ License-File: LICENSE
17
17
  Requires-Dist: aiohttp>=3
18
18
  Requires-Dist: aiopathlib
19
19
  Requires-Dist: aiosqlite
20
- Requires-Dist: rich
20
+ Requires-Dist: fasteners
21
+ Requires-Dist: rich>=14
21
22
  Dynamic: license-file
22
23
 
23
24
  # sqlite-export-for-ynab
@@ -1,4 +1,5 @@
1
1
  aiohttp>=3
2
2
  aiopathlib
3
3
  aiosqlite
4
- rich
4
+ fasteners
5
+ rich>=14
@@ -3,11 +3,13 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import json
5
5
  from configparser import ConfigParser
6
+ from contextlib import asynccontextmanager
6
7
  from pathlib import Path
7
8
  from unittest.mock import patch
8
9
 
9
10
  import aiohttp
10
11
  import aiosqlite
12
+ import fasteners
11
13
  import pytest
12
14
  import pytest_asyncio
13
15
  from aiohttp.http_exceptions import HttpProcessingError
@@ -16,6 +18,7 @@ from rich.progress import Progress
16
18
  from sqlite_export_for_ynab import default_db_path
17
19
  from sqlite_export_for_ynab._main import _ALL_RELATIONS
18
20
  from sqlite_export_for_ynab._main import _Context
21
+ from sqlite_export_for_ynab._main import _context
19
22
  from sqlite_export_for_ynab._main import _ENV_TOKEN
20
23
  from sqlite_export_for_ynab._main import _PACKAGE
21
24
  from sqlite_export_for_ynab._main import _PROGRESS_COLUMNS
@@ -89,7 +92,7 @@ async def fetchall(con, query):
89
92
 
90
93
 
91
94
  @pytest_asyncio.fixture
92
- async def context():
95
+ async def context(tmp_path):
93
96
  with Progress(*_PROGRESS_COLUMNS, disable=True) as progress:
94
97
  async with (
95
98
  aiohttp.ClientSession(loop=asyncio.get_event_loop()) as session,
@@ -97,7 +100,10 @@ async def context():
97
100
  ):
98
101
  con.row_factory = aiosqlite.Row
99
102
  await con.executescript(await contents("create-relations.sql"))
100
- yield _Context(session, progress, con)
103
+ lock = fasteners.InterProcessLock(
104
+ tmp_path / "sqlite-export-for-ynab-test.lock"
105
+ )
106
+ yield _Context(session, progress, con, lock)
101
107
 
102
108
 
103
109
  @pytest.mark.parametrize(
@@ -695,6 +701,92 @@ def test_resolve_token_env(monkeypatch):
695
701
  assert resolve_token() == TOKEN
696
702
 
697
703
 
704
+ @patch(
705
+ "sqlite_export_for_ynab._main.fasteners.InterProcessLock.acquire",
706
+ autospec=True,
707
+ )
708
+ @patch("sqlite_export_for_ynab._main.asyncio.get_running_loop")
709
+ @patch("sqlite_export_for_ynab._main.aiohttp.ClientSession")
710
+ @pytest.mark.asyncio
711
+ async def test_sync_lock_times_out(
712
+ mock_client_session, mock_get_running_loop, mock_acquire, tmp_path
713
+ ):
714
+ class FakeLoop:
715
+ def __init__(self):
716
+ self._times = iter((0.0, 0.2))
717
+
718
+ def time(self):
719
+ return next(self._times)
720
+
721
+ mock_get_running_loop.return_value = FakeLoop()
722
+
723
+ @asynccontextmanager
724
+ async def fake_client_session():
725
+ yield object()
726
+
727
+ mock_client_session.return_value = fake_client_session()
728
+ mock_acquire.return_value = False
729
+
730
+ with pytest.raises(TimeoutError):
731
+ await _context(tmp_path / "db.sqlite", quiet=True, timeout=0.1).__aenter__()
732
+
733
+ assert mock_acquire.call_count == 1
734
+ assert mock_acquire.call_args.args[1] is False
735
+
736
+
737
+ @patch(
738
+ "sqlite_export_for_ynab._main.fasteners.InterProcessLock.acquire",
739
+ autospec=True,
740
+ )
741
+ @patch("sqlite_export_for_ynab._main.fasteners.InterProcessLock.release", autospec=True)
742
+ @patch("sqlite_export_for_ynab._main.asyncio.sleep")
743
+ @patch("sqlite_export_for_ynab._main.asyncio.get_running_loop")
744
+ @patch("sqlite_export_for_ynab._main.aiohttp.ClientSession")
745
+ @pytest.mark.asyncio
746
+ async def test_context_retries_after_sleep(
747
+ mock_client_session,
748
+ mock_get_running_loop,
749
+ mock_sleep,
750
+ mock_release,
751
+ mock_acquire,
752
+ tmp_path,
753
+ ):
754
+ class FakeLoop:
755
+ def __init__(self):
756
+ self._times = iter((0.0, 0.0, 0.2))
757
+
758
+ def time(self):
759
+ return next(self._times)
760
+
761
+ mock_get_running_loop.return_value = FakeLoop()
762
+
763
+ @asynccontextmanager
764
+ async def fake_client_session():
765
+ yield object()
766
+
767
+ mock_client_session.return_value = fake_client_session()
768
+ mock_acquire.side_effect = [False, True]
769
+
770
+ async with _context(tmp_path / "db.sqlite", quiet=True, timeout=0.1):
771
+ pass
772
+
773
+ assert mock_acquire.call_count == 2
774
+ assert mock_acquire.call_args_list[0].args[1] is False
775
+ assert mock_sleep.call_count == 1
776
+ assert mock_release.call_count == 1
777
+
778
+
779
+ @pytest.mark.asyncio
780
+ async def test_context_removes_lock_file(tmp_path):
781
+ db = tmp_path / "db.sqlite"
782
+ lock_path = tmp_path / "db.sqlite.lock"
783
+
784
+ async with _context(db, quiet=True):
785
+ assert lock_path.exists()
786
+
787
+ assert not lock_path.exists()
788
+
789
+
698
790
  @pytest.mark.asyncio
699
791
  @pytest.mark.usefixtures(mock_aioresponses.__name__)
700
792
  async def test_sync_no_data(tmp_path, mock_aioresponses):