Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ html
htmlcov
test.py
CLAUDE.md
AGENTS.md
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta'

[project]
name = 'locklib'
version = '0.0.22'
version = '0.0.23'
authors = [
{ name='Evgeniy Blinov', email='zheni-b@yandex.ru' },
]
Expand Down
17 changes: 17 additions & 0 deletions tests/documentation/test_readme.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@


def test_lock_protocols_basic():
"""
The README lock examples satisfy LockProtocol at runtime.

This checks multiprocessing.Lock, threading.Lock, threading.RLock, asyncio.Lock, and SmartLock by protocol membership, without exercising locking behavior.
"""
assert isinstance(MLock(), LockProtocol)
assert isinstance(TLock(), LockProtocol)
assert isinstance(TRLock(), LockProtocol)
Expand All @@ -34,17 +39,28 @@ def test_inheritance_order():


def test_almost_all_lock_are_context_locks():
"""
ContextLockProtocol describes the listed synchronous locks as context-manager locks.

The README smoke test checks multiprocessing.Lock, threading.Lock, threading.RLock, and SmartLock by runtime protocol membership without exercising their locking behavior.
"""
assert isinstance(MLock(), ContextLockProtocol)
assert isinstance(TLock(), ContextLockProtocol)
assert isinstance(TRLock(), ContextLockProtocol)
assert isinstance(SmartLock(), ContextLockProtocol)


def test_asyncio_lock_is_async_context_lock():
"""
asyncio.Lock satisfies the async context lock protocol at runtime.

The test checks interface recognition for asyncio.Lock without exercising locking behavior or asserting anything about synchronous context-manager protocols.
"""
assert isinstance(ALock(), AsyncContextLockProtocol)


def test_empty_lock_usage_and_protocols():
"""EmptyLock can be used as a context manager and satisfies LockProtocol and ContextLockProtocol."""
lock = EmptyLock()

with lock:
Expand All @@ -55,6 +71,7 @@ def test_empty_lock_usage_and_protocols():


async def test_async_empty_lock_usage_and_protocols():
"""AsyncEmptyLock can be used as an async context manager and satisfies LockProtocol and AsyncContextLockProtocol."""
lock = AsyncEmptyLock()

async with lock:
Expand Down
30 changes: 30 additions & 0 deletions tests/units/locks/empty/test_async_empty_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,31 +13,56 @@ async def test_acquire_does_not_raise():


def test_release_returns_none_without_prior_acquire():
"""
Release on an unacquired async empty lock returns None immediately.

The lock is stateless, so release does not require ownership, a prior acquire, or awaiting.
"""
lock = AsyncEmptyLock()

assert lock.release() is None


async def test_double_acquire_does_not_block():
"""
AsyncEmptyLock acquire remains a non-blocking no-op across repeated awaits.

The same instance can be acquired twice without an intervening release, and the second acquire should complete immediately.
"""
lock = AsyncEmptyLock()

await lock.acquire()
await lock.acquire()


async def test_context_manager_binds_none():
"""
An async empty lock context manager binds None on entry.

async with AsyncEmptyLock() as value should complete successfully without exposing the lock instance or a token.
"""
async with AsyncEmptyLock() as value:
assert value is None


async def test_nested_context_manager_does_not_deadlock():
"""
AsyncEmptyLock supports nested async context manager use without blocking.

The same lock instance is entered twice with nested async with blocks, and both contexts must complete because the empty lock does not track ownership or held state.
"""
lock = AsyncEmptyLock()

async with lock, lock:
pass


async def test_exception_inside_context_manager_propagates():
"""
Exceptions raised inside an AsyncEmptyLock context are not suppressed.

Raise a ValueError inside the context and assert that a ValueError with the sentinel message is observed outside.
"""
lock = AsyncEmptyLock()

with pytest.raises(ValueError, match='kek'):
Expand All @@ -46,6 +71,11 @@ async def test_exception_inside_context_manager_propagates():


async def test_instance_is_reusable():
"""
One AsyncEmptyLock instance stays reusable across sequential locking cycles.

The same object can mix explicit acquire/release calls and async context-manager use without retaining state between cycles.
"""
lock = AsyncEmptyLock()

for _ in range(3):
Expand Down
35 changes: 35 additions & 0 deletions tests/units/locks/empty/test_empty_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,44 +6,79 @@


def test_acquire_returns_none_and_does_not_raise():
"""
EmptyLock.acquire is a no-op that returns None.

On a fresh instance, calling acquire should not raise and should return exactly None.
"""
lock = EmptyLock()

assert lock.acquire() is None


def test_release_returns_none_without_prior_acquire():
"""
EmptyLock.release is a no-op that returns None without a prior acquire.

Calling release on a fresh EmptyLock should not raise and should return exactly None.
"""
lock = EmptyLock()

assert lock.release() is None


def test_double_acquire_does_not_block():
"""
EmptyLock can be acquired repeatedly without blocking.

Calling acquire twice in a row should complete immediately and should not require an intervening release, because EmptyLock does not track ownership or held state.
"""
lock = EmptyLock()

lock.acquire()
lock.acquire()


def test_context_manager_binds_none():
"""
EmptyLock context manager binds None on entry.

with EmptyLock() as value should not expose the lock instance or any acquisition token.
"""
with EmptyLock() as value:
assert value is None


def test_nested_context_manager_does_not_deadlock():
"""
A single EmptyLock instance can be entered twice in one with statement.

Nested context-manager entry on the same no-op lock should complete without blocking or raising.
"""
lock = EmptyLock()

with lock, lock:
pass


def test_exception_inside_context_manager_propagates():
"""
An EmptyLock context does not suppress exceptions raised inside it.

A ValueError with the sentinel message should be observed outside the with block.
"""
lock = EmptyLock()

with pytest.raises(ValueError, match='kek'), lock:
raise ValueError('kek')


def test_instance_is_reusable():
"""
An EmptyLock instance can be reused across repeated locking cycles.

Mix direct acquire/release calls with with-block usage to confirm each cycle completes independently and leaves no retained state.
"""
lock = EmptyLock()

for _ in range(3):
Expand Down
54 changes: 54 additions & 0 deletions tests/units/locks/smart_lock/test_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@


def test_multiple_set_and_get():
"""
LocksGraph preserves multiple outgoing links from one source.

After several add_link calls from the same source, get_links_from returns the full adjacency set. Missing nodes and nodes that only appear as destinations return an empty set.
"""
graph = LocksGraph()

graph.add_link(1, 2)
Expand All @@ -19,6 +24,11 @@ def test_multiple_set_and_get():


def test_reverse_deleting_of_nodes():
"""
search_cycles finds a three-node path through a branching graph.

With 1 -> 6 and 6 -> {3, 4, 5}, searching from 1 to 5 should return a path of length 3.
"""
graph = LocksGraph()

graph.add_link(1, 6)
Expand All @@ -31,6 +41,11 @@ def test_reverse_deleting_of_nodes():


def test_set_get_delete_and_get():
"""
Deleting one outgoing edge preserves the remaining edges for the same source.

Create multiple edges from one source, delete only one existing target, and assert the source adjacency still contains the other target.
"""
graph = LocksGraph()

graph.add_link(1, 2)
Expand All @@ -45,6 +60,11 @@ def test_set_get_delete_and_get():


def test_delete_from_empty_graph():
"""
Deleting a link from an empty graph is a no-op.

delete_link on a fresh graph should not raise, should not materialize a defaultdict key, and should leave links unchanged.
"""
graph = LocksGraph()

graph.delete_link(1, 2)
Expand All @@ -53,6 +73,11 @@ def test_delete_from_empty_graph():


def test_delete_non_existing_link():
"""
Deleting a missing target edge from an existing source node leaves the graph unchanged.

An unrelated outgoing edge from the same source remains present after the delete operation.
"""
graph = LocksGraph()

graph.add_link(1, 2)
Expand All @@ -63,15 +88,27 @@ def test_delete_non_existing_link():


def test_detect_simple_cycle():
"""
Reject a direct wait-for cycle without storing the closing edge.

After 1 -> 2 exists, adding 2 -> 1 would create a two-node cycle. add_link must raise DeadLockError, and 2's outgoing links must not include the rejected edge to 1.
"""
graph = LocksGraph()

graph.add_link(1, 2)

with pytest.raises(DeadLockError):
graph.add_link(2, 1)

assert 1 not in graph.get_links_from(2)


def test_detect_difficult_cycle():
"""
Reject a long transitive cycle without storing the closing edge.

Build a chain from 1 through 9, then try to close it with 9 -> 1. add_link must raise DeadLockError, and 9's outgoing links must not include the rejected edge to 1.
"""
graph = LocksGraph()

graph.add_link(1, 2)
Expand All @@ -86,8 +123,15 @@ def test_detect_difficult_cycle():
with pytest.raises(DeadLockError):
graph.add_link(9, 1)

assert 1 not in graph.get_links_from(9)


def test_simple_exception_message():
"""
A direct two-node cycle reports only the short DeadLockError message.

After 1 -> 2 exists, closing it with 2 -> 1 should produce exactly the short message and no full-path tail.
"""
graph = LocksGraph()

graph.add_link(1, 2)
Expand All @@ -99,6 +143,11 @@ def test_simple_exception_message():


def test_exception_message_not_so_simple():
"""
A multi-node deadlock reports the full cycle path.

Build a four-node wait-for cycle closed by the 4 -> 1 dependency. The raised DeadLockError should use the base deadlock message and include the path tail 4, 3, 2, 1.
"""
graph = LocksGraph()

graph.add_link(1, 2)
Expand All @@ -112,6 +161,11 @@ def test_exception_message_not_so_simple():


def test_exception_message_not_so_simple_2():
"""
A three-node cycle includes its full path in the DeadLockError message.

Closing 1 -> 2 and 2 -> 3 with 3 -> 1 should report the path 3, 2, 1.
"""
graph = LocksGraph()

graph.add_link(1, 2)
Expand Down
Loading
Loading