Skip to content

Commit 20420b3

Browse files
committed
fix(locks): review fixes — accurate docs, fcntl import guard, test hardening
Addresses /code-review findings on the portalocker refactor: - flock docstring corrected: portalocker uses Win32 LockFileEx (pywin32, pulled in automatically on Windows) for SHARED locks, so concurrent readers ARE honoured; EXCLUSIVE uses msvcrt (retries ~10s then raises, not an infinite block); failures raise portalocker.LockException, not OSError. - Re-add the issue #93 regression guard: assert no openkb module hard-imports the Unix-only fcntl at module level (replaces the dropped import-without-fcntl test without depending on portalocker internals). - Strengthen the cross-process lock test: assert both BLOCKED (while held) and ACQUIRED (after release), and check the probe's exit code so an ImportError surfaces clearly instead of an empty-stdout false failure. - Drop the now-dead 'import pytest' / 'import portalocker' from the test module.
1 parent 8cba8c1 commit 20420b3

2 files changed

Lines changed: 59 additions & 25 deletions

File tree

openkb/locks.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,16 @@
2121
def flock(fh: IO, *, exclusive: bool) -> None:
2222
"""Acquire an advisory lock on an open file handle (cross-platform).
2323
24-
Delegates to :mod:`portalocker`, which is fcntl-backed on POSIX and
25-
msvcrt/Win32-backed on Windows. The call blocks until the lock is acquired,
26-
matching ``fcntl.flock``. Shared (``exclusive=False``) locks are honoured
27-
where the platform supports them; on Windows they may be taken exclusively
28-
(portalocker handles the platform differences).
24+
Delegates to :mod:`portalocker`:
25+
26+
- **POSIX** — ``fcntl.flock``; the call blocks indefinitely until acquired.
27+
- **Windows** — shared locks use the Win32 ``LockFileEx`` API (``pywin32``,
28+
which portalocker pulls in automatically on Windows), so concurrent
29+
readers are honoured; exclusive locks use ``msvcrt.locking``, which
30+
retries for ~10s and then raises rather than blocking indefinitely.
31+
32+
On failure portalocker raises :class:`portalocker.LockException` — note this
33+
is *not* an ``OSError`` (e.g. on filesystems without working lock support).
2934
"""
3035
portalocker.lock(fh, portalocker.LOCK_EX if exclusive else portalocker.LOCK_SH)
3136

tests/test_cross_platform_locks.py

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,43 @@
33
File locking is delegated to :mod:`portalocker` (fcntl on POSIX, msvcrt/Win32
44
on Windows), so OpenKB no longer hard-imports the Unix-only ``fcntl``. The
55
atomic-write path still special-cases the Unix-only ``os.fchmod`` and directory
6-
``os.fsync``. These tests pin the platform-neutral behaviour that is verifiable
7-
on POSIX; portalocker carries its own Windows test coverage.
6+
``os.fsync``. These tests pin the platform-neutral behaviour verifiable on
7+
POSIX; portalocker carries its own Windows test coverage.
88
"""
99
from __future__ import annotations
1010

11+
import ast
1112
import os
1213
import subprocess
1314
import sys
15+
from pathlib import Path
1416

15-
import portalocker
16-
import pytest
17-
17+
import openkb
1818
from openkb import locks
1919

2020

21+
def _module_level_imports_fcntl(path: Path) -> bool:
22+
"""True if the module has a top-level ``import fcntl`` / ``from fcntl import``."""
23+
tree = ast.parse(path.read_text(encoding="utf-8"))
24+
for node in tree.body: # module-level statements only (import-time crash risk)
25+
if isinstance(node, ast.Import) and any(a.name == "fcntl" for a in node.names):
26+
return True
27+
if isinstance(node, ast.ImportFrom) and node.module == "fcntl":
28+
return True
29+
return False
30+
31+
32+
def test_openkb_modules_do_not_hard_import_fcntl():
33+
"""Guards issue #93: OpenKB's own modules must import on Windows (no bare fcntl)."""
34+
pkg_dir = Path(openkb.__file__).parent
35+
offenders = [
36+
str(py.relative_to(pkg_dir))
37+
for py in pkg_dir.rglob("*.py")
38+
if _module_level_imports_fcntl(py)
39+
]
40+
assert not offenders, f"Unix-only fcntl hard-imported at module level in: {offenders}"
41+
42+
2143
def test_flock_funlock_roundtrip(tmp_path):
2244
"""flock/funlock acquire and release both exclusive and shared locks."""
2345
lock_path = tmp_path / "test.lock"
@@ -28,28 +50,35 @@ def test_flock_funlock_roundtrip(tmp_path):
2850
locks.funlock(fh) # must not raise
2951

3052

31-
def test_flock_exclusive_blocks_other_process(tmp_path):
32-
"""An exclusive flock is a real OS lock that excludes another process."""
53+
def test_flock_exclusive_excludes_other_process(tmp_path):
54+
"""An exclusive flock is a real OS lock: it excludes another process while
55+
held, and the lock is acquirable again once released."""
3356
lock_path = tmp_path / "test.lock"
34-
fh = lock_path.open("a+", encoding="utf-8")
35-
locks.flock(fh, exclusive=True)
36-
try:
37-
probe = (
38-
"import portalocker\n"
39-
f"fh = open({str(lock_path)!r}, 'a+')\n"
40-
"try:\n"
41-
" portalocker.lock(fh, portalocker.LOCK_EX | portalocker.LOCK_NB)\n"
42-
" print('ACQUIRED')\n"
43-
"except portalocker.LockException:\n"
44-
" print('BLOCKED')\n"
45-
)
57+
probe = (
58+
"import portalocker\n"
59+
f"fh = open({str(lock_path)!r}, 'a+')\n"
60+
"try:\n"
61+
" portalocker.lock(fh, portalocker.LOCK_EX | portalocker.LOCK_NB)\n"
62+
" print('ACQUIRED')\n"
63+
"except portalocker.LockException:\n"
64+
" print('BLOCKED')\n"
65+
)
66+
67+
def run_probe() -> str:
4668
result = subprocess.run(
4769
[sys.executable, "-c", probe], capture_output=True, text=True
4870
)
49-
assert "BLOCKED" in result.stdout, result.stdout + result.stderr
71+
assert result.returncode == 0, result.stderr # probe itself ran cleanly
72+
return result.stdout.strip()
73+
74+
fh = lock_path.open("a+", encoding="utf-8")
75+
locks.flock(fh, exclusive=True)
76+
try:
77+
assert run_probe() == "BLOCKED" # held → other process is excluded
5078
finally:
5179
locks.funlock(fh)
5280
fh.close()
81+
assert run_probe() == "ACQUIRED" # released → other process can acquire
5382

5483

5584
def test_atomic_write_bytes_without_fchmod(monkeypatch, tmp_path):

0 commit comments

Comments
 (0)