From dccfc6539189b3a38bd5b4235a36c03342be761b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 1 Jul 2026 02:26:56 +0300 Subject: [PATCH 1/4] Add AGENTS.md to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 71f2829..a5dd665 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ html htmlcov test.py CLAUDE.md +AGENTS.md From 61ed8d8874bdb7d583b8484e2a9e79b4d84b97c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 1 Jul 2026 02:29:04 +0300 Subject: [PATCH 2/4] Bump version to 0.0.23 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 339d385..d4e59b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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' }, ] From 45f94c1fb29f7c153a16271d84ad27201420869f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 1 Jul 2026 13:21:55 +0300 Subject: [PATCH 3/4] Add a lot of docstrings --- tests/documentation/test_readme.py | 17 ++++ .../locks/empty/test_async_empty_lock.py | 30 +++++++ tests/units/locks/empty/test_empty_lock.py | 35 ++++++++ tests/units/locks/smart_lock/test_graph.py | 50 ++++++++++++ tests/units/locks/smart_lock/test_lock.py | 16 ++++ tests/units/locks/tracer/test_events.py | 10 +++ tests/units/locks/tracer/test_tracer.py | 79 +++++++++++++++++++ .../protocols/test_async_context_lock.py | 16 ++++ tests/units/protocols/test_context_lock.py | 12 +++ tests/units/protocols/test_lock.py | 11 +++ tests/units/test_errors.py | 1 + 11 files changed, 277 insertions(+) diff --git a/tests/documentation/test_readme.py b/tests/documentation/test_readme.py index a90d6dc..129c3c7 100644 --- a/tests/documentation/test_readme.py +++ b/tests/documentation/test_readme.py @@ -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) @@ -34,6 +39,11 @@ 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) @@ -41,10 +51,16 @@ def test_almost_all_lock_are_context_locks(): 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: @@ -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: diff --git a/tests/units/locks/empty/test_async_empty_lock.py b/tests/units/locks/empty/test_async_empty_lock.py index 48fb021..131e4e8 100644 --- a/tests/units/locks/empty/test_async_empty_lock.py +++ b/tests/units/locks/empty/test_async_empty_lock.py @@ -13,12 +13,22 @@ 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() @@ -26,11 +36,21 @@ async def test_double_acquire_does_not_block(): 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: @@ -38,6 +58,11 @@ async def test_nested_context_manager_does_not_deadlock(): 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'): @@ -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): diff --git a/tests/units/locks/empty/test_empty_lock.py b/tests/units/locks/empty/test_empty_lock.py index 0a84abd..04d9d0d 100644 --- a/tests/units/locks/empty/test_empty_lock.py +++ b/tests/units/locks/empty/test_empty_lock.py @@ -6,18 +6,33 @@ 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() @@ -25,11 +40,21 @@ def test_double_acquire_does_not_block(): 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: @@ -37,6 +62,11 @@ def test_nested_context_manager_does_not_deadlock(): 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: @@ -44,6 +74,11 @@ def test_exception_inside_context_manager_propagates(): 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): diff --git a/tests/units/locks/smart_lock/test_graph.py b/tests/units/locks/smart_lock/test_graph.py index a31bba6..0cb6382 100644 --- a/tests/units/locks/smart_lock/test_graph.py +++ b/tests/units/locks/smart_lock/test_graph.py @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) @@ -63,6 +88,11 @@ def test_delete_non_existing_link(): def test_detect_simple_cycle(): + """ + Reject a direct wait-for cycle when adding the closing edge. + + After one link exists between two nodes, adding the reverse link would create a two-node cycle, so add_link must raise DeadLockError immediately. + """ graph = LocksGraph() graph.add_link(1, 2) @@ -72,6 +102,11 @@ def test_detect_simple_cycle(): def test_detect_difficult_cycle(): + """ + Detect a long transitive cycle when a new graph link closes it. + + Build a chain from 1 through 9, then assert that adding 9 -> 1 raises DeadLockError rather than accepting the edge. + """ graph = LocksGraph() graph.add_link(1, 2) @@ -88,6 +123,11 @@ def test_detect_difficult_cycle(): 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) @@ -99,6 +139,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) @@ -112,6 +157,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) diff --git a/tests/units/locks/smart_lock/test_lock.py b/tests/units/locks/smart_lock/test_lock.py index fd1949f..ce3f8df 100644 --- a/tests/units/locks/smart_lock/test_lock.py +++ b/tests/units/locks/smart_lock/test_lock.py @@ -9,6 +9,7 @@ def test_release_unlocked(): + """Releasing a fresh SmartLock raises RuntimeError with the exact unlocked-lock message.""" lock = SmartLock() with pytest.raises(RuntimeError, match=match('Release unlocked lock.')): @@ -16,6 +17,11 @@ def test_release_unlocked(): def test_normal_using(): + """ + A single SmartLock supports normal contended context-manager use. + + Several threads increment shared state under the lock, and the final count must include every increment. + """ number_of_threads = 5 number_of_attempts_per_thread = 100000 @@ -41,6 +47,11 @@ def function() -> None: @pytest.mark.timeout(5) def test_raise_when_simple_deadlock(): + """ + SmartLock detects a two-thread, two-lock deadlock instead of blocking. + + Each iteration runs opposite acquisition orders and requires DeadLockError to surface before the threads can finish. + """ number_of_attempts = 50 lock_1 = SmartLock() @@ -85,6 +96,11 @@ def function_2(): @pytest.mark.timeout(5) def test_raise_when_not_so_simple_deadlock(): # noqa: PLR0915 + """ + SmartLock reports a three-lock cyclic wait instead of hanging. + + Each attempt starts three threads with rotated lock order; two DeadLockError signals are enough to break the cycle and let all threads finish. + """ number_of_attempts = 50 lock_1 = SmartLock() diff --git a/tests/units/locks/tracer/test_events.py b/tests/units/locks/tracer/test_events.py index 0bbfdbe..664dc71 100644 --- a/tests/units/locks/tracer/test_events.py +++ b/tests/units/locks/tracer/test_events.py @@ -2,6 +2,11 @@ def test_it_has_2_kinds_of_event_types(): + """ + TracerEventType exposes all required event categories. + + ACQUIRE, RELEASE, and ACTION should be truthy and pairwise distinct so traced lock events can be classified unambiguously. + """ assert TracerEventType.ACQUIRE assert TracerEventType.RELEASE assert TracerEventType.ACTION @@ -12,6 +17,11 @@ def test_it_has_2_kinds_of_event_types(): def test_equality_of_events(): + """ + Events compare equal only when their type, thread id, and identifier all match. + + Changing any of those fields makes otherwise similar events unequal. + """ assert TracerEvent(TracerEventType.ACQUIRE, 1) == TracerEvent(TracerEventType.ACQUIRE, 1) assert TracerEvent(TracerEventType.RELEASE, 1) == TracerEvent(TracerEventType.RELEASE, 1) assert TracerEvent(TracerEventType.ACTION, 1) == TracerEvent(TracerEventType.ACTION, 1) diff --git a/tests/units/locks/tracer/test_tracer.py b/tests/units/locks/tracer/test_tracer.py index 5a69c94..8d244b6 100644 --- a/tests/units/locks/tracer/test_tracer.py +++ b/tests/units/locks/tracer/test_tracer.py @@ -10,6 +10,11 @@ def test_base_trace_with_methods(): + """ + Delegated acquire and release calls are traced after each wrapped method returns. + + The pseudo-lock records its own markers; the wrapper should append the matching ACQUIRE or RELEASE event immediately after each marker. + """ class PseudoLock: def acquire(self): self.trace.append('acquire') @@ -29,6 +34,10 @@ def set_trace_collection(self, trace: List[Union[str, TracerEvent]]): def test_base_trace_with_context_manager(): + """A LockTraceWrapper context manager delegates to acquire and release in order. + + Wrap a lock whose methods record markers, enter an empty with block, and assert the wrapper appends matching ACQUIRE and RELEASE events after those calls. + """ class PseudoLock: def acquire(self): self.trace.append('acquire') @@ -48,6 +57,11 @@ def set_trace_collection(self, trace: List[Union[str, TracerEvent]]): def test_notify_adds_new_event(): + """ + notify appends one ACTION event for the current thread. + + Calling notify(identifier) should add exactly one TracerEvent with type ACTION and the provided identifier. + """ wrapper = LockTraceWrapper(Lock()) wrapper.notify('kek') @@ -57,6 +71,11 @@ def test_notify_adds_new_event(): def test_try_to_release_event_without_corresponding_acquire_event(): + """ + An unmatched RELEASE event makes the trace order invalid. + + was_event_locked raises StrangeEventOrderError unless exceptions are disabled, in which case it returns False. + """ class PseudoLock: def acquire(self): pass @@ -80,6 +99,11 @@ def release(self): def test_event_is_locked_if_there_was_no_events(): + """ + An empty trace follows the missing-event path. + + was_event_locked returns False with raise_exception=False and otherwise raises ThereWasNoSuchEventError with the exact missing-event message. + """ wrapper = LockTraceWrapper(Lock()) assert not wrapper.was_event_locked('kek', raise_exception=False) @@ -92,6 +116,11 @@ def test_event_is_locked_if_there_was_no_events(): def test_event_is_locked_if_there_are_only_opening_and_slosing_events(): + """ + A balanced acquire/release trace without the target ACTION is still missing the event. + + It returns False with exceptions disabled and otherwise raises ThereWasNoSuchEventError. + """ wrapper = LockTraceWrapper(Lock()) with wrapper: @@ -107,6 +136,11 @@ def test_event_is_locked_if_there_are_only_opening_and_slosing_events(): def test_simple_case_of_locked_event(): + """ + An event recorded inside a completed lock context is treated as locked. + + was_event_locked should return True after the context exits, since the lock only needed to be held when notify was called. + """ wrapper = LockTraceWrapper(Lock()) with wrapper: @@ -116,6 +150,11 @@ def test_simple_case_of_locked_event(): def test_simple_case_of_locked_multiple_events(): + """ + Repeated matching events inside one completed lock section are treated as locked. + + Record several actions with the same identifier under one context and assert was_event_locked returns True. + """ wrapper = LockTraceWrapper(Lock()) with wrapper: @@ -128,6 +167,11 @@ def test_simple_case_of_locked_multiple_events(): def test_locked_events_with_only_acquire(): + """ + An event after an acquire is considered locked even if the lock has not been released yet. + + was_event_locked treats matching actions in the same thread as protected while the acquire stack remains open. + """ wrapper = LockTraceWrapper(Lock()) with wrapper: @@ -140,6 +184,11 @@ def test_locked_events_with_only_acquire(): def test_multiple_locked_events_in_100_threads(): + """ + Matching events are checked against each event's own thread lock span. + + Start many threads that each acquire the wrapper once, emit many matching ACTION notifications inside that context, and release it; every matching action should be treated as locked. + """ wrapper = LockTraceWrapper(Lock()) def function(): @@ -159,6 +208,11 @@ def function(): def test_multiple_locked_events_and_1_not_locked_in_100_threads(): + """ + Return False when any matching event is emitted without that thread acquiring the wrapper. + + Many threads emit locked ACTION events; one thread emits the same identifier without entering the wrapper, so was_event_locked rejects the whole set. + """ wrapper = LockTraceWrapper(Lock()) def function_with_locked_events(): @@ -182,6 +236,11 @@ def function_with_not_locked_event(): def test_simple_not_locked_event(): + """ + Return False when a matching action exists outside an open acquire. + + The event is present in the trace, so the check should report it as unlocked instead of treating it as missing. + """ wrapper = LockTraceWrapper(Lock()) wrapper.notify('kek') @@ -190,6 +249,11 @@ def test_simple_not_locked_event(): def test_simple_one_not_locked_event_and_one_locked(): + """ + Lock-state checks are scoped to the requested event identifier. + + Record one unlocked 'lol' action and a separate locked 'kek' action in the same trace; 'lol' should report False while 'kek' reports True. + """ wrapper = LockTraceWrapper(Lock()) wrapper.notify('lol') @@ -202,6 +266,11 @@ def test_simple_one_not_locked_event_and_one_locked(): def test_when_event_locked_and_unlocked_its_unlocked(): + """ + An event identifier is locked only when every matching occurrence happens while the lock is held. + + A locked occurrence followed by an unlocked occurrence must return False. + """ wrapper = LockTraceWrapper(Lock()) with wrapper: @@ -212,6 +281,11 @@ def test_when_event_locked_and_unlocked_its_unlocked(): def test_when_event_unlocked_and_locked_its_unlocked(): + """ + An event identifier is unsafe if any occurrence happens outside the lock. + + This covers an unlocked occurrence followed by a locked occurrence and expects the result to stay False. + """ wrapper = LockTraceWrapper(Lock()) wrapper.notify('lol') @@ -222,6 +296,11 @@ def test_when_event_unlocked_and_locked_its_unlocked(): def test_unknown_event_type(): + """ + Unknown tracer event types are ignored when checking whether an event was locked. + + If the trace contains only an unrecognized event and no matching ACTION, was_event_locked should follow the normal missing-event behavior. + """ wrapper = LockTraceWrapper(Lock()) wrapper.trace.append(TracerEvent('unknown', 1)) diff --git a/tests/units/protocols/test_async_context_lock.py b/tests/units/protocols/test_async_context_lock.py index 882a3dd..c9b3c1e 100644 --- a/tests/units/protocols/test_async_context_lock.py +++ b/tests/units/protocols/test_async_context_lock.py @@ -18,6 +18,11 @@ ], ) def test_locks_are_instances_of_context_lock_protocol(lock): # type: ignore[no-untyped-def, unused-ignore] + """ + asyncio.Lock and AsyncEmptyLock satisfy AsyncContextLockProtocol at runtime. + + This positive membership check focuses on protocol shape rather than locking behavior. + """ assert isinstance(lock, AsyncContextLockProtocol) @@ -38,10 +43,20 @@ def test_locks_are_instances_of_context_lock_protocol(lock): # type: ignore[no- ], ) def test_other_objects_are_not_instances_of_context_lock(other): # type: ignore[no-untyped-def, unused-ignore] + """ + Objects without async context-lock support must not satisfy AsyncContextLockProtocol. + + Verify that unrelated objects and synchronous lock/context-lock implementations are rejected. + """ assert not isinstance(other, AsyncContextLockProtocol) def test_just_async_contextmanager_is_not_async_context_lock(): # type: ignore[no-untyped-def] + """ + A plain async context manager is not treated as an async context lock. + + The object supports async enter and exit, but it must fail the protocol check because it does not provide acquire and release methods. + """ @asynccontextmanager async def context_manager(): # type: ignore[no-untyped-def] yield 'kek' @@ -50,6 +65,7 @@ async def context_manager(): # type: ignore[no-untyped-def] def test_not_implemented_methods_for_async_context_lock_protocol(): # type: ignore[no-untyped-def] + """Inherited AsyncContextLockProtocol methods on a minimal subclass raise the exact protocol misuse error.""" class AsyncContextLockProtocolImplementation(AsyncContextLockProtocol): pass diff --git a/tests/units/protocols/test_context_lock.py b/tests/units/protocols/test_context_lock.py index 5be1f41..b1f91b4 100644 --- a/tests/units/protocols/test_context_lock.py +++ b/tests/units/protocols/test_context_lock.py @@ -22,6 +22,7 @@ ], ) def test_locks_are_instances_of_context_lock_protocol(lock): # type: ignore[no-untyped-def, unused-ignore] + """The listed standard locks, SmartLock, and EmptyLock satisfy ContextLockProtocol at runtime.""" assert isinstance(lock, ContextLockProtocol) @@ -38,6 +39,11 @@ def test_locks_are_instances_of_context_lock_protocol(lock): # type: ignore[no- ], ) def test_other_objects_are_not_instances_of_context_lock(other): # type: ignore[no-untyped-def, unused-ignore] + """ + Objects without the synchronous context-lock shape must not satisfy ContextLockProtocol. + + Check unrelated values and an async-only lock shape to ensure runtime matching requires acquire/release plus __enter__/__exit__. + """ assert not isinstance(other, ContextLockProtocol) @@ -51,6 +57,11 @@ def test_asyncio_lock_is_not_just_context_lock(): # type: ignore[no-untyped-def def test_just_contextmanager_is_not_context_lock(): # type: ignore[no-untyped-def] + """ + A plain synchronous context manager must not satisfy ContextLockProtocol. + + It provides context-manager entry and exit but lacks acquire and release, which the runtime protocol check also requires. + """ @contextmanager def context_manager(): # type: ignore[no-untyped-def] yield 'kek' @@ -59,6 +70,7 @@ def context_manager(): # type: ignore[no-untyped-def] def test_not_implemented_methods_for_context_lock_protocol(): # type: ignore[no-untyped-def] + """Inherited ContextLockProtocol methods on a minimal subclass raise the exact protocol misuse error.""" class ContextLockProtocolImplementation(ContextLockProtocol): pass diff --git a/tests/units/protocols/test_lock.py b/tests/units/protocols/test_lock.py index 9fba7e8..31a28e4 100644 --- a/tests/units/protocols/test_lock.py +++ b/tests/units/protocols/test_lock.py @@ -22,6 +22,11 @@ ], ) def test_locks_are_instances_of_lock_protocol(lock): # type: ignore[no-untyped-def, unused-ignore] + """ + The listed stdlib and locklib locks satisfy LockProtocol at runtime. + + This includes both synchronous and asynchronous context-lock implementations because LockProtocol only requires acquire and release. + """ assert isinstance(lock, LockProtocol) @@ -37,10 +42,16 @@ def test_locks_are_instances_of_lock_protocol(lock): # type: ignore[no-untyped- ], ) def test_other_objects_are_not_instances_of_lock(other): # type: ignore[no-untyped-def, unused-ignore] + """ + Unrelated objects are not accepted as lock protocol instances. + + Verify common non-lock primitives and containers fail isinstance checks so LockProtocol stays limited to objects exposing the lock API. + """ assert not isinstance(other, LockProtocol) def test_not_implemented_methods_for_lock_protocol(): # type: ignore[no-untyped-def] + """Inherited LockProtocol methods on a minimal subclass raise the exact protocol misuse error.""" class LockProtocolImplementation(LockProtocol): pass diff --git a/tests/units/test_errors.py b/tests/units/test_errors.py index e4dc03b..76faa54 100644 --- a/tests/units/test_errors.py +++ b/tests/units/test_errors.py @@ -5,5 +5,6 @@ def test_raise(): + """DeadLockError can be raised directly with a caller-supplied message.""" with pytest.raises(DeadLockError, match=match('some message')): raise DeadLockError('some message') From 4ca564d3d2f3c5a70b0fc88594452e73bd675e3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 1 Jul 2026 15:46:54 +0300 Subject: [PATCH 4/4] Some tests became stronger --- tests/units/locks/smart_lock/test_graph.py | 12 ++++--- tests/units/locks/smart_lock/test_lock.py | 39 ++++++++++++++-------- tests/units/locks/tracer/test_tracer.py | 32 ++++++++++++++---- 3 files changed, 59 insertions(+), 24 deletions(-) diff --git a/tests/units/locks/smart_lock/test_graph.py b/tests/units/locks/smart_lock/test_graph.py index 0cb6382..cbe126e 100644 --- a/tests/units/locks/smart_lock/test_graph.py +++ b/tests/units/locks/smart_lock/test_graph.py @@ -89,9 +89,9 @@ def test_delete_non_existing_link(): def test_detect_simple_cycle(): """ - Reject a direct wait-for cycle when adding the closing edge. + Reject a direct wait-for cycle without storing the closing edge. - After one link exists between two nodes, adding the reverse link would create a two-node cycle, so add_link must raise DeadLockError immediately. + 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() @@ -100,12 +100,14 @@ def test_detect_simple_cycle(): with pytest.raises(DeadLockError): graph.add_link(2, 1) + assert 1 not in graph.get_links_from(2) + def test_detect_difficult_cycle(): """ - Detect a long transitive cycle when a new graph link closes it. + Reject a long transitive cycle without storing the closing edge. - Build a chain from 1 through 9, then assert that adding 9 -> 1 raises DeadLockError rather than accepting the 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() @@ -121,6 +123,8 @@ 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(): """ diff --git a/tests/units/locks/smart_lock/test_lock.py b/tests/units/locks/smart_lock/test_lock.py index ce3f8df..bfa8e39 100644 --- a/tests/units/locks/smart_lock/test_lock.py +++ b/tests/units/locks/smart_lock/test_lock.py @@ -50,38 +50,50 @@ def test_raise_when_simple_deadlock(): """ SmartLock detects a two-thread, two-lock deadlock instead of blocking. - Each iteration runs opposite acquisition orders and requires DeadLockError to surface before the threads can finish. + Each iteration runs opposite acquisition orders. Worker handlers collect unexpected exceptions so they fail the main test, and exactly one DeadLockError must be enough to let both threads finish. """ number_of_attempts = 50 lock_1 = SmartLock() lock_2 = SmartLock() - queue = Queue() - for _ in range(number_of_attempts): flag = False - def function_1(): + deadlock_errors = [] + unexpected_errors = [] + result_lock = Lock() + + def function_1(deadlock_errors=deadlock_errors, unexpected_errors=unexpected_errors, result_lock=result_lock): nonlocal flag try: while True: with lock_1, lock_2: if flag: break - except DeadLockError: - flag = True - queue.put(True) - - def function_2(): + except DeadLockError as error: + with result_lock: + flag = True + deadlock_errors.append(error) + except Exception as error: # noqa: BLE001 + with result_lock: + flag = True + unexpected_errors.append(error) + + def function_2(deadlock_errors=deadlock_errors, unexpected_errors=unexpected_errors, result_lock=result_lock): nonlocal flag try: while True: with lock_2, lock_1: if flag: break - except DeadLockError: - flag = True - queue.put(True) + except DeadLockError as error: + with result_lock: + flag = True + deadlock_errors.append(error) + except Exception as error: # noqa: BLE001 + with result_lock: + flag = True + unexpected_errors.append(error) thread_1 = Thread(target=function_1) thread_2 = Thread(target=function_2) @@ -91,7 +103,8 @@ def function_2(): thread_1.join() thread_2.join() - assert queue.get() + assert not unexpected_errors + assert len(deadlock_errors) == 1 @pytest.mark.timeout(5) diff --git a/tests/units/locks/tracer/test_tracer.py b/tests/units/locks/tracer/test_tracer.py index 8d244b6..0c4e364 100644 --- a/tests/units/locks/tracer/test_tracer.py +++ b/tests/units/locks/tracer/test_tracer.py @@ -187,7 +187,7 @@ def test_multiple_locked_events_in_100_threads(): """ Matching events are checked against each event's own thread lock span. - Start many threads that each acquire the wrapper once, emit many matching ACTION notifications inside that context, and release it; every matching action should be treated as locked. + Start many threads that each acquire the wrapper once, emit many matching ACTION notifications inside that context, and release it. The trace must contain every expected ACTION plus one ACQUIRE/RELEASE pair per thread before was_event_locked returns True. """ wrapper = LockTraceWrapper(Lock()) @@ -204,6 +204,13 @@ def function(): for thread in threads: thread.join() + action_events = [event for event in wrapper.trace if event.type == TracerEventType.ACTION and event.identifier == 'kek'] + acquire_events = [event for event in wrapper.trace if event.type == TracerEventType.ACQUIRE] + release_events = [event for event in wrapper.trace if event.type == TracerEventType.RELEASE] + + assert len(action_events) == 100 * 1000 + assert len(acquire_events) == 100 + assert len(release_events) == 100 assert wrapper.was_event_locked('kek') @@ -211,18 +218,28 @@ def test_multiple_locked_events_and_1_not_locked_in_100_threads(): """ Return False when any matching event is emitted without that thread acquiring the wrapper. - Many threads emit locked ACTION events; one thread emits the same identifier without entering the wrapper, so was_event_locked rejects the whole set. + Many threads emit locked ACTION events, while one thread emits the same identifier without entering the wrapper. Worker exceptions are collected first, so the final False result must come from the unlocked event rather than a failed worker. """ wrapper = LockTraceWrapper(Lock()) + unexpected_errors = [] + errors_lock = Lock() def function_with_locked_events(): - with wrapper: - for _ in range(1000): - wrapper.notify('kek') - sleep(0.1) + try: + with wrapper: + for _ in range(1000): + wrapper.notify('kek') + sleep(0.1) + except Exception as error: # noqa: BLE001 + with errors_lock: + unexpected_errors.append(error) def function_with_not_locked_event(): - wrapper.notify('kek') + try: + wrapper.notify('kek') + except Exception as error: # noqa: BLE001 + with errors_lock: + unexpected_errors.append(error) threads = [Thread(target=function_with_locked_events) for _ in range(100)] threads.append(Thread(target=function_with_not_locked_event)) @@ -232,6 +249,7 @@ def function_with_not_locked_event(): for thread in threads: thread.join() + assert not unexpected_errors assert not wrapper.was_event_locked('kek')