Skip to content

Commit 9c44c11

Browse files
TheMatt2pre-commit-ci[bot]gaborbernat
authored
Fix lock hang on Windows (#231)
Co-authored-by: TheMatt2 <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Bernát Gábor <[email protected]>
1 parent 2cd5f63 commit 9c44c11

File tree

7 files changed

+63
-19
lines changed

7 files changed

+63
-19
lines changed

.pre-commit-config.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ repos:
5454
- id: flake8
5555
additional_dependencies:
5656
- flake8-bugbear==23.3.23
57-
- flake8-comprehensions==3.11.1
57+
- flake8-comprehensions==3.12
5858
- flake8-pytest-style==1.7.2
5959
- flake8-spellcheck==0.28
6060
- flake8-unused-arguments==0.0.13

pyproject.toml

+2-2
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,13 @@ dynamic = [
3737
optional-dependencies.docs = [
3838
"furo>=2023.3.27",
3939
"sphinx>=6.1.3",
40-
"sphinx-autodoc-typehints!=1.23.4,>=1.22",
40+
"sphinx-autodoc-typehints!=1.23.4,>=1.23",
4141
]
4242
optional-dependencies.testing = [
4343
"covdefaults>=2.3",
4444
"coverage>=7.2.3",
4545
"diff-cover>=7.5",
46-
"pytest>=7.2.2",
46+
"pytest>=7.3.1",
4747
"pytest-cov>=4",
4848
"pytest-mock>=3.10",
4949
"pytest-timeout>=2.1",

src/filelock/_soft.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
from errno import EACCES, EEXIST
66

77
from ._api import BaseFileLock
8-
from ._util import raise_on_exist_ro_file
8+
from ._util import raise_on_not_writable_file
99

1010

1111
class SoftFileLock(BaseFileLock):
1212
"""Simply watches the existence of the lock file."""
1313

1414
def _acquire(self) -> None:
15-
raise_on_exist_ro_file(self._lock_file)
15+
raise_on_not_writable_file(self._lock_file)
1616
# first check for exists and read-only mode as the open will mask this case as EEXIST
1717
flags = (
1818
os.O_WRONLY # open for writing only

src/filelock/_util.py

+20-3
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,36 @@
22

33
import os
44
import stat
5+
import sys
6+
from errno import EACCES, EISDIR
57

68

7-
def raise_on_exist_ro_file(filename: str) -> None:
9+
def raise_on_not_writable_file(filename: str) -> None:
10+
"""
11+
Raise an exception if attempting to open the file for writing would fail.
12+
This is done so files that will never be writable can be separated from
13+
files that are writable but currently locked
14+
:param filename: file to check
15+
:raises OSError: as if the file was opened for writing
16+
"""
817
try:
918
file_stat = os.stat(filename) # use stat to do exists + can write to check without race condition
1019
except OSError:
1120
return None # swallow does not exist or other errors
1221

1322
if file_stat.st_mtime != 0: # if os.stat returns but modification is zero that's an invalid os.stat - ignore it
1423
if not (file_stat.st_mode & stat.S_IWUSR):
15-
raise PermissionError(f"Permission denied: {filename!r}")
24+
raise PermissionError(EACCES, "Permission denied", filename)
25+
26+
if stat.S_ISDIR(file_stat.st_mode):
27+
if sys.platform == "win32": # pragma: win32 cover
28+
# On Windows, this is PermissionError
29+
raise PermissionError(EACCES, "Permission denied", filename)
30+
else: # pragma: win32 no cover
31+
# On linux / macOS, this is IsADirectoryError
32+
raise IsADirectoryError(EISDIR, "Is a directory", filename)
1633

1734

1835
__all__ = [
19-
"raise_on_exist_ro_file",
36+
"raise_on_not_writable_file",
2037
]

src/filelock/_windows.py

+9-7
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
import os
44
import sys
5-
from errno import ENOENT
5+
from errno import EACCES
66
from typing import cast
77

88
from ._api import BaseFileLock
9-
from ._util import raise_on_exist_ro_file
9+
from ._util import raise_on_not_writable_file
1010

1111
if sys.platform == "win32": # pragma: win32 cover
1212
import msvcrt
@@ -15,22 +15,24 @@ class WindowsFileLock(BaseFileLock):
1515
"""Uses the :func:`msvcrt.locking` function to hard lock the lock file on windows systems."""
1616

1717
def _acquire(self) -> None:
18-
raise_on_exist_ro_file(self._lock_file)
18+
raise_on_not_writable_file(self._lock_file)
1919
flags = (
2020
os.O_RDWR # open for read and write
2121
| os.O_CREAT # create file if not exists
22-
| os.O_TRUNC # truncate file if not empty
22+
| os.O_TRUNC # truncate file if not empty
2323
)
2424
try:
2525
fd = os.open(self._lock_file, flags, self._mode)
2626
except OSError as exception:
27-
if exception.errno == ENOENT: # No such file or directory
27+
if exception.errno != EACCES: # has no access to this lock
2828
raise
2929
else:
3030
try:
3131
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
32-
except OSError:
33-
os.close(fd)
32+
except OSError as exception:
33+
os.close(fd) # close file first
34+
if exception.errno != EACCES: # file is already locked
35+
raise
3436
else:
3537
self._lock_file_fd = fd
3638

tests/test_filelock.py

+28-4
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,36 @@ def test_ro_file(lock_type: type[BaseFileLock], tmp_file_ro: Path) -> None:
112112
lock.acquire()
113113

114114

115+
WindowsOnly = pytest.mark.skipif(sys.platform != "win32", reason="Windows only")
116+
117+
115118
@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock])
116-
def test_missing_directory(lock_type: type[BaseFileLock], tmp_path_ro: Path) -> None:
117-
lock_path = tmp_path_ro / "a" / "b"
118-
lock = lock_type(str(lock_path))
119+
@pytest.mark.parametrize(
120+
("expected_error", "match", "bad_lock_file"),
121+
[
122+
pytest.param(FileNotFoundError, "No such file or directory:", "a/b", id="non_existent_directory"),
123+
pytest.param(FileNotFoundError, "No such file or directory:", "", id="blank_filename"),
124+
pytest.param(ValueError, "embedded null (byte|character)", "\0", id="null_byte"),
125+
pytest.param(
126+
PermissionError if sys.platform == "win32" else IsADirectoryError,
127+
"Permission denied:" if sys.platform == "win32" else "Is a directory",
128+
".",
129+
id="current_directory",
130+
),
131+
]
132+
+ [pytest.param(OSError, "Invalid argument", i, id=f"invalid_{i}", marks=WindowsOnly) for i in '<>:"|?*\a']
133+
+ [pytest.param(PermissionError, "Permission denied:", i, id=f"permission_{i}", marks=WindowsOnly) for i in "/\\"],
134+
)
135+
@pytest.mark.timeout(5) # timeout in case of infinite loop
136+
def test_bad_lock_file(
137+
lock_type: type[BaseFileLock],
138+
expected_error: type[Exception],
139+
match: str,
140+
bad_lock_file: str,
141+
) -> None:
142+
lock = lock_type(bad_lock_file)
119143

120-
with pytest.raises(OSError, match="No such file or directory:"):
144+
with pytest.raises(expected_error, match=match):
121145
lock.acquire()
122146

123147

whitelist.txt

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ caplog
66
contnode
77
docutils
88
eacces
9+
eisdir
910
enosys
1011
extlinks
1112
fchmod

0 commit comments

Comments
 (0)