sqlite-export-for-ynab 2.5.1__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.
- {sqlite_export_for_ynab-2.5.1/sqlite_export_for_ynab.egg-info → sqlite_export_for_ynab-2.6.0}/PKG-INFO +2 -1
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/setup.cfg +2 -1
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab/_main.py +30 -3
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0/sqlite_export_for_ynab.egg-info}/PKG-INFO +2 -1
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab.egg-info/requires.txt +1 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/tests/_main_test.py +94 -2
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/LICENSE +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/README.md +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/pyproject.toml +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/setup.py +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab/__init__.py +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab/__main__.py +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab/ddl/__init__.py +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab/ddl/create-relations.sql +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab/ddl/drop-relations.sql +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab/py.typed +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab.egg-info/SOURCES.txt +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab.egg-info/dependency_links.txt +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab.egg-info/entry_points.txt +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab.egg-info/top_level.txt +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/testing/__init__.py +0 -0
- {sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/testing/fixtures.py +0 -0
- {sqlite_export_for_ynab-2.5.1 → 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.
|
|
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,6 +17,7 @@ License-File: LICENSE
|
|
|
17
17
|
Requires-Dist: aiohttp>=3
|
|
18
18
|
Requires-Dist: aiopathlib
|
|
19
19
|
Requires-Dist: aiosqlite
|
|
20
|
+
Requires-Dist: fasteners
|
|
20
21
|
Requires-Dist: rich>=14
|
|
21
22
|
Dynamic: license-file
|
|
22
23
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[metadata]
|
|
2
2
|
name = sqlite_export_for_ynab
|
|
3
|
-
version = 2.
|
|
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,6 +22,7 @@ install_requires =
|
|
|
22
22
|
aiohttp>=3
|
|
23
23
|
aiopathlib
|
|
24
24
|
aiosqlite
|
|
25
|
+
fasteners
|
|
25
26
|
rich>=14
|
|
26
27
|
python_requires = >=3.12
|
|
27
28
|
|
{sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab/_main.py
RENAMED
|
@@ -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(
|
|
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
|
-
|
|
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.
|
|
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,6 +17,7 @@ License-File: LICENSE
|
|
|
17
17
|
Requires-Dist: aiohttp>=3
|
|
18
18
|
Requires-Dist: aiopathlib
|
|
19
19
|
Requires-Dist: aiosqlite
|
|
20
|
+
Requires-Dist: fasteners
|
|
20
21
|
Requires-Dist: rich>=14
|
|
21
22
|
Dynamic: license-file
|
|
22
23
|
|
|
@@ -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
|
-
|
|
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):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab/__init__.py
RENAMED
|
File without changes
|
{sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab/__main__.py
RENAMED
|
File without changes
|
{sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab/ddl/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlite_export_for_ynab-2.5.1 → sqlite_export_for_ynab-2.6.0}/sqlite_export_for_ynab/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|