diff --git a/CHANGES b/CHANGES index 16fc76e4..94c5b824 100644 --- a/CHANGES +++ b/CHANGES @@ -29,6 +29,13 @@ _Notes on the upcoming release will go here._ - Support both sync and async usage patterns - Useful for pytest-xdist compatibility +### Tests + +#### tests: Add async tests for file_lock module (#505) + +- Add {class}`TestAsyncFileLock` with 8 async tests +- Add {class}`TestAsyncAtomicInit` with 4 async tests + ## libvcs 0.40.0 (2026-04-25) ### What's new diff --git a/pyproject.toml b/pyproject.toml index 1c3bc99c..18bf3263 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ dev = [ # Testing "gp-libs", "pytest", + "pytest-asyncio", "pytest-rerunfailures", "pytest-mock", "pytest-watcher", @@ -94,6 +95,7 @@ docs = [ testing = [ "gp-libs", "pytest", + "pytest-asyncio", "pytest-rerunfailures", "pytest-mock", "pytest-watcher", @@ -245,7 +247,10 @@ testpaths = [ ] filterwarnings = [ "ignore:The frontend.Option(Parser)? class.*:DeprecationWarning::", + "ignore:The configuration option \"asyncio_default_fixture_loop_scope\" is unset.:DeprecationWarning:pytest_asyncio.plugin", ] +asyncio_mode = "strict" +asyncio_default_fixture_loop_scope = "function" [tool.pytest-watcher] now = true diff --git a/tests/_internal/test_file_lock_async.py b/tests/_internal/test_file_lock_async.py new file mode 100644 index 00000000..8214132e --- /dev/null +++ b/tests/_internal/test_file_lock_async.py @@ -0,0 +1,205 @@ +"""Async tests for libvcs._internal.file_lock.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from libvcs._internal.file_lock import ( + AsyncAcquireReturnProxy, + AsyncFileLock, + FileLockTimeout, + async_atomic_init, +) + + +class TestAsyncFileLock: + """Tests for AsyncFileLock asynchronous operations.""" + + @pytest.mark.asyncio + async def test_async_context_manager(self, tmp_path: Path) -> None: + """Test AsyncFileLock as async context manager.""" + lock_path = tmp_path / "test.lock" + lock = AsyncFileLock(lock_path) + + assert not lock.is_locked + async with lock: + assert lock.is_locked + assert lock_path.exists() + assert not lock.is_locked + + @pytest.mark.asyncio + async def test_async_explicit_acquire_release(self, tmp_path: Path) -> None: + """Test explicit acquire() and release() for async lock.""" + lock_path = tmp_path / "test.lock" + lock = AsyncFileLock(lock_path) + + proxy = await lock.acquire() + assert isinstance(proxy, AsyncAcquireReturnProxy) + assert lock.is_locked + + await lock.release() + assert not lock.is_locked + + @pytest.mark.asyncio + async def test_async_reentrant(self, tmp_path: Path) -> None: + """Test async reentrant locking.""" + lock_path = tmp_path / "test.lock" + lock = AsyncFileLock(lock_path) + + await lock.acquire() + assert lock.lock_counter == 1 + + await lock.acquire() + assert lock.lock_counter == 2 + + await lock.release() + assert lock.lock_counter == 1 + + await lock.release() + assert lock.lock_counter == 0 + + @pytest.mark.asyncio + async def test_async_timeout(self, tmp_path: Path) -> None: + """Test async lock timeout.""" + lock_path = tmp_path / "test.lock" + + lock1 = AsyncFileLock(lock_path) + await lock1.acquire() + + lock2 = AsyncFileLock(lock_path, timeout=0.1) + with pytest.raises(FileLockTimeout): + await lock2.acquire() + + await lock1.release() + + @pytest.mark.asyncio + async def test_async_non_blocking(self, tmp_path: Path) -> None: + """Test async non-blocking acquire.""" + lock_path = tmp_path / "test.lock" + + lock1 = AsyncFileLock(lock_path) + await lock1.acquire() + + lock2 = AsyncFileLock(lock_path) + with pytest.raises(FileLockTimeout): + await lock2.acquire(blocking=False) + + await lock1.release() + + @pytest.mark.asyncio + async def test_async_acquire_proxy_context(self, tmp_path: Path) -> None: + """Test AsyncAcquireReturnProxy as async context manager.""" + lock_path = tmp_path / "test.lock" + lock = AsyncFileLock(lock_path) + + proxy = await lock.acquire() + async with proxy as acquired_lock: + assert acquired_lock is lock + assert lock.is_locked + + assert not lock.is_locked + + @pytest.mark.asyncio + async def test_async_concurrent_acquisition(self, tmp_path: Path) -> None: + """Test concurrent async lock acquisition.""" + lock_path = tmp_path / "test.lock" + results: list[int] = [] + + async def worker(lock: AsyncFileLock, worker_id: int) -> None: + async with lock: + results.append(worker_id) + await asyncio.sleep(0.01) + + lock = AsyncFileLock(lock_path) + await asyncio.gather(*[worker(lock, i) for i in range(3)]) + + # All workers should have completed + assert len(results) == 3 + # Results should be sequential (one at a time) + assert sorted(results) == list(range(3)) + + @pytest.mark.asyncio + async def test_async_repr(self, tmp_path: Path) -> None: + """Test __repr__ for async lock.""" + lock_path = tmp_path / "test.lock" + lock = AsyncFileLock(lock_path) + + assert "unlocked" in repr(lock) + async with lock: + assert "locked" in repr(lock) + + +class TestAsyncAtomicInit: + """Tests for async_atomic_init function.""" + + @pytest.mark.asyncio + async def test_async_atomic_init_first(self, tmp_path: Path) -> None: + """Test first async_atomic_init performs initialization.""" + resource_path = tmp_path / "resource" + resource_path.mkdir() + init_called: list[bool] = [] + + async def async_init_fn() -> None: + init_called.append(True) + await asyncio.sleep(0) + + result = await async_atomic_init(resource_path, async_init_fn) + + assert result is True + assert len(init_called) == 1 + assert (resource_path / ".initialized").exists() + + @pytest.mark.asyncio + async def test_async_atomic_init_already_done(self, tmp_path: Path) -> None: + """Test async_atomic_init skips when already initialized.""" + resource_path = tmp_path / "resource" + resource_path.mkdir() + (resource_path / ".initialized").touch() + + init_called: list[bool] = [] + + async def async_init_fn() -> None: + init_called.append(True) + + result = await async_atomic_init(resource_path, async_init_fn) + + assert result is False + assert len(init_called) == 0 + + @pytest.mark.asyncio + async def test_async_atomic_init_sync_fn(self, tmp_path: Path) -> None: + """Test async_atomic_init works with sync init function.""" + resource_path = tmp_path / "resource" + resource_path.mkdir() + init_called: list[bool] = [] + + def sync_init_fn() -> None: + init_called.append(True) + + result = await async_atomic_init(resource_path, sync_init_fn) + + assert result is True + assert len(init_called) == 1 + + @pytest.mark.asyncio + async def test_async_atomic_init_concurrent(self, tmp_path: Path) -> None: + """Test async_atomic_init handles concurrent calls.""" + resource_path = tmp_path / "resource" + resource_path.mkdir() + init_count = {"count": 0} + + async def init_fn() -> None: + init_count["count"] += 1 + await asyncio.sleep(0.1) # Simulate slow init + + results = await asyncio.gather( + *[async_atomic_init(resource_path, init_fn) for _ in range(5)] + ) + + # Only one should have returned True + assert sum(results) == 1 + # Only one init should have run + assert init_count["count"] == 1 diff --git a/uv.lock b/uv.lock index 11fb0ada..b20e7835 100644 --- a/uv.lock +++ b/uv.lock @@ -72,6 +72,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -365,7 +374,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -585,6 +594,7 @@ dev = [ { name = "gp-sphinx" }, { name = "mypy" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, @@ -610,6 +620,7 @@ lint = [ testing = [ { name = "gp-libs" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, @@ -631,6 +642,7 @@ dev = [ { name = "gp-sphinx", specifier = "==0.0.1a10" }, { name = "mypy" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, @@ -654,6 +666,7 @@ lint = [ testing = [ { name = "gp-libs" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, @@ -971,6 +984,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "pytest-cov" version = "7.1.0"