Skip to content

Commit bbb80be

Browse files
committed
CHANGES: Add file_lock module entry (#501)
1 parent f33681d commit bbb80be

File tree

2 files changed

+8
-205
lines changed

2 files changed

+8
-205
lines changed

CHANGES

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,14 @@ $ uv add libvcs --prerelease allow
1818

1919
<!-- Maintainers, insert changes / features for the next release here -->
2020

21-
_Upcoming changes will be written here._
21+
### Internal
22+
23+
#### _internal: Add file locking module (#501)
24+
25+
- Add {class}`~libvcs._internal.file_lock.FileLock` for cross-platform file locking
26+
- Add {func}`~libvcs._internal.file_lock.atomic_init` for race-free initialization
27+
- Support both sync and async usage patterns
28+
- Useful for pytest-xdist compatibility
2229

2330
### Development
2431

tests/_internal/test_file_lock.py

Lines changed: 0 additions & 204 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import asyncio
65
import os
76
import pickle # Used to test exception picklability for multiprocessing support
87
import threading
@@ -14,14 +13,11 @@
1413

1514
from libvcs._internal.file_lock import (
1615
AcquireReturnProxy,
17-
AsyncAcquireReturnProxy,
18-
AsyncFileLock,
1916
FileLock,
2017
FileLockContext,
2118
FileLockError,
2219
FileLockStale,
2320
FileLockTimeout,
24-
async_atomic_init,
2521
atomic_init,
2622
)
2723

@@ -284,128 +280,6 @@ def test_stale_detection(
284280
lock.acquire()
285281

286282

287-
# =============================================================================
288-
# AsyncFileLock Tests
289-
# =============================================================================
290-
291-
292-
class TestAsyncFileLock:
293-
"""Tests for AsyncFileLock asynchronous operations."""
294-
295-
@pytest.mark.asyncio
296-
async def test_async_context_manager(self, tmp_path: Path) -> None:
297-
"""Test AsyncFileLock as async context manager."""
298-
lock_path = tmp_path / "test.lock"
299-
lock = AsyncFileLock(lock_path)
300-
301-
assert not lock.is_locked
302-
async with lock:
303-
assert lock.is_locked
304-
assert lock_path.exists()
305-
assert not lock.is_locked
306-
307-
@pytest.mark.asyncio
308-
async def test_async_explicit_acquire_release(self, tmp_path: Path) -> None:
309-
"""Test explicit acquire() and release() for async lock."""
310-
lock_path = tmp_path / "test.lock"
311-
lock = AsyncFileLock(lock_path)
312-
313-
proxy = await lock.acquire()
314-
assert isinstance(proxy, AsyncAcquireReturnProxy)
315-
assert lock.is_locked
316-
317-
await lock.release()
318-
assert not lock.is_locked
319-
320-
@pytest.mark.asyncio
321-
async def test_async_reentrant(self, tmp_path: Path) -> None:
322-
"""Test async reentrant locking."""
323-
lock_path = tmp_path / "test.lock"
324-
lock = AsyncFileLock(lock_path)
325-
326-
await lock.acquire()
327-
assert lock.lock_counter == 1
328-
329-
await lock.acquire()
330-
assert lock.lock_counter == 2
331-
332-
await lock.release()
333-
assert lock.lock_counter == 1
334-
335-
await lock.release()
336-
assert lock.lock_counter == 0
337-
338-
@pytest.mark.asyncio
339-
async def test_async_timeout(self, tmp_path: Path) -> None:
340-
"""Test async lock timeout."""
341-
lock_path = tmp_path / "test.lock"
342-
343-
lock1 = AsyncFileLock(lock_path)
344-
await lock1.acquire()
345-
346-
lock2 = AsyncFileLock(lock_path, timeout=0.1)
347-
with pytest.raises(FileLockTimeout):
348-
await lock2.acquire()
349-
350-
await lock1.release()
351-
352-
@pytest.mark.asyncio
353-
async def test_async_non_blocking(self, tmp_path: Path) -> None:
354-
"""Test async non-blocking acquire."""
355-
lock_path = tmp_path / "test.lock"
356-
357-
lock1 = AsyncFileLock(lock_path)
358-
await lock1.acquire()
359-
360-
lock2 = AsyncFileLock(lock_path)
361-
with pytest.raises(FileLockTimeout):
362-
await lock2.acquire(blocking=False)
363-
364-
await lock1.release()
365-
366-
@pytest.mark.asyncio
367-
async def test_async_acquire_proxy_context(self, tmp_path: Path) -> None:
368-
"""Test AsyncAcquireReturnProxy as async context manager."""
369-
lock_path = tmp_path / "test.lock"
370-
lock = AsyncFileLock(lock_path)
371-
372-
proxy = await lock.acquire()
373-
async with proxy as acquired_lock:
374-
assert acquired_lock is lock
375-
assert lock.is_locked
376-
377-
assert not lock.is_locked
378-
379-
@pytest.mark.asyncio
380-
async def test_async_concurrent_acquisition(self, tmp_path: Path) -> None:
381-
"""Test concurrent async lock acquisition."""
382-
lock_path = tmp_path / "test.lock"
383-
results: list[int] = []
384-
385-
async def worker(lock: AsyncFileLock, worker_id: int) -> None:
386-
async with lock:
387-
results.append(worker_id)
388-
await asyncio.sleep(0.01)
389-
390-
lock = AsyncFileLock(lock_path)
391-
await asyncio.gather(*[worker(lock, i) for i in range(3)])
392-
393-
# All workers should have completed
394-
assert len(results) == 3
395-
# Results should be sequential (one at a time)
396-
assert sorted(results) == list(range(3))
397-
398-
@pytest.mark.asyncio
399-
async def test_async_repr(self, tmp_path: Path) -> None:
400-
"""Test __repr__ for async lock."""
401-
lock_path = tmp_path / "test.lock"
402-
lock = AsyncFileLock(lock_path)
403-
404-
assert "unlocked" in repr(lock)
405-
async with lock:
406-
assert "locked" in repr(lock)
407-
408-
409283
# =============================================================================
410284
# FileLockContext Tests
411285
# =============================================================================
@@ -613,81 +487,3 @@ def init_fn() -> None:
613487

614488
# Only one thread should have initialized
615489
assert init_count["count"] == 1
616-
617-
618-
# =============================================================================
619-
# async_atomic_init Tests
620-
# =============================================================================
621-
622-
623-
class TestAsyncAtomicInit:
624-
"""Tests for async_atomic_init function."""
625-
626-
@pytest.mark.asyncio
627-
async def test_async_atomic_init_first(self, tmp_path: Path) -> None:
628-
"""Test first async_atomic_init performs initialization."""
629-
resource_path = tmp_path / "resource"
630-
resource_path.mkdir()
631-
init_called = []
632-
633-
async def async_init_fn() -> None:
634-
init_called.append(True)
635-
await asyncio.sleep(0)
636-
637-
result = await async_atomic_init(resource_path, async_init_fn)
638-
639-
assert result is True
640-
assert len(init_called) == 1
641-
assert (resource_path / ".initialized").exists()
642-
643-
@pytest.mark.asyncio
644-
async def test_async_atomic_init_already_done(self, tmp_path: Path) -> None:
645-
"""Test async_atomic_init skips when already initialized."""
646-
resource_path = tmp_path / "resource"
647-
resource_path.mkdir()
648-
(resource_path / ".initialized").touch()
649-
650-
init_called = []
651-
652-
async def async_init_fn() -> None:
653-
init_called.append(True)
654-
655-
result = await async_atomic_init(resource_path, async_init_fn)
656-
657-
assert result is False
658-
assert len(init_called) == 0
659-
660-
@pytest.mark.asyncio
661-
async def test_async_atomic_init_sync_fn(self, tmp_path: Path) -> None:
662-
"""Test async_atomic_init works with sync init function."""
663-
resource_path = tmp_path / "resource"
664-
resource_path.mkdir()
665-
init_called = []
666-
667-
def sync_init_fn() -> None:
668-
init_called.append(True)
669-
670-
result = await async_atomic_init(resource_path, sync_init_fn)
671-
672-
assert result is True
673-
assert len(init_called) == 1
674-
675-
@pytest.mark.asyncio
676-
async def test_async_atomic_init_concurrent(self, tmp_path: Path) -> None:
677-
"""Test async_atomic_init handles concurrent calls."""
678-
resource_path = tmp_path / "resource"
679-
resource_path.mkdir()
680-
init_count = {"count": 0}
681-
682-
async def init_fn() -> None:
683-
init_count["count"] += 1
684-
await asyncio.sleep(0.1) # Simulate slow init
685-
686-
results = await asyncio.gather(
687-
*[async_atomic_init(resource_path, init_fn) for _ in range(5)]
688-
)
689-
690-
# Only one should have returned True
691-
assert sum(results) == 1
692-
# Only one init should have run
693-
assert init_count["count"] == 1

0 commit comments

Comments
 (0)