Skip to content

Commit

Permalink
Merge branch 'release/2.10.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
wolph committed Jun 22, 2024
2 parents 36229d2 + c78d055 commit 06d58c4
Show file tree
Hide file tree
Showing 14 changed files with 268 additions and 75 deletions.
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ exclude_lines =
raise NotImplementedError
if 0:
if __name__ == .__main__.:
typing.Protocol

omit =
portalocker/redis.py
Expand Down
1 change: 1 addition & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ files = portalocker

ignore_missing_imports = True

check_untyped_defs = True
2 changes: 1 addition & 1 deletion portalocker/__about__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
__package_name__ = 'portalocker'
__author__ = 'Rick van Hattem'
__email__ = '[email protected]'
__version__ = '2.8.2'
__version__ = '2.10.0'
__description__ = '''Wraps the portalocker recipe for easy usage'''
__url__ = 'https://github.com/WoLpH/portalocker'
17 changes: 10 additions & 7 deletions portalocker/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
from . import __about__, constants, exceptions, portalocker, utils
from . import __about__, constants, exceptions, portalocker
from .utils import (
BoundedSemaphore,
Lock,
RLock,
TemporaryFileLock,
open_atomic,
)

try: # pragma: no cover
from .redis import RedisLock
Expand All @@ -13,7 +20,7 @@
#: Current author's email address
__email__ = __about__.__email__
#: Version number
__version__ = '2.8.2'
__version__ = '2.10.0'
#: Package description for Pypi
__description__ = __about__.__description__
#: Package homepage
Expand Down Expand Up @@ -52,11 +59,6 @@

#: Locking utility class to automatically handle opening with timeouts and
#: context wrappers
Lock = utils.Lock
RLock = utils.RLock
BoundedSemaphore = utils.BoundedSemaphore
TemporaryFileLock = utils.TemporaryFileLock
open_atomic = utils.open_atomic

__all__ = [
'lock',
Expand All @@ -71,6 +73,7 @@
'RLock',
'AlreadyLocked',
'BoundedSemaphore',
'TemporaryFileLock',
'open_atomic',
'RedisLock',
]
40 changes: 32 additions & 8 deletions portalocker/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
import os
import pathlib
import re
import typing

base_path = pathlib.Path(__file__).parent.parent
src_path = base_path / 'portalocker'
dist_path = base_path / 'dist'
_default_output_path = base_path / 'dist' / 'portalocker.py'

_RELATIVE_IMPORT_RE = re.compile(r'^from \. import (?P<names>.+)$')
_NAMES_RE = re.compile(r'(?P<names>[^()]+)$')
_RELATIVE_IMPORT_RE = re.compile(
r'^from \.(?P<from>.*?) import (?P<paren>\(?)(?P<names>[^()]+)$',
)
_USELESS_ASSIGNMENT_RE = re.compile(r'^(?P<name>\w+) = \1\n$')

_TEXT_TEMPLATE = """'''
Expand Down Expand Up @@ -42,18 +46,38 @@ def main(argv=None):
args.func(args)


def _read_file(path, seen_files):
def _read_file(path: pathlib.Path, seen_files: typing.Set[pathlib.Path]):
if path in seen_files:
return

names = set()
seen_files.add(path)
paren = False
from_ = None
for line in path.open():
if match := _RELATIVE_IMPORT_RE.match(line):
for name in match.group('names').split(','):
name = name.strip()
names.add(name)
yield from _read_file(src_path / f'{name}.py', seen_files)
if paren:
if ')' in line:
line = line.split(')', 1)[1]
paren = False
continue

match = _NAMES_RE.match(line)
else:
match = _RELATIVE_IMPORT_RE.match(line)

if match:
if not paren:
paren = bool(match.group('paren'))
from_ = match.group('from')

if from_:
names.add(from_)
yield from _read_file(src_path / f'{from_}.py', seen_files)
else:
for name in match.group('names').split(','):
name = name.strip()
names.add(name)
yield from _read_file(src_path / f'{name}.py', seen_files)
else:
yield _clean_line(line, names)

Expand All @@ -79,7 +103,7 @@ def combine(args):
_TEXT_TEMPLATE.format((base_path / 'LICENSE').read_text()),
)

seen_files = set()
seen_files: typing.Set[pathlib.Path] = set()
for line in _read_file(src_path / '__init__.py', seen_files):
output_file.write(line)

Expand Down
1 change: 1 addition & 0 deletions portalocker/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- `UNBLOCK` unlock
'''

import enum
import os

Expand Down
61 changes: 49 additions & 12 deletions portalocker/portalocker.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import contextlib
import os
import typing

Expand All @@ -9,6 +8,14 @@
LockFlags = constants.LockFlags


class HasFileno(typing.Protocol):
def fileno(self) -> int: ...


LOCKER: typing.Optional[typing.Callable[
[typing.Union[int, HasFileno], int], typing.Any]] = None


if os.name == 'nt': # pragma: no cover
import msvcrt

Expand Down Expand Up @@ -87,14 +94,18 @@ def unlock(file_: typing.IO):
) from exc

elif os.name == 'posix': # pragma: no cover
import errno
import fcntl

# The locking implementation.
# Expected values are either fcntl.flock() or fcntl.lockf(),
# but any callable that matches the syntax will be accepted.
LOCKER = fcntl.flock

def lock(file_: typing.Union[typing.IO, int], flags: LockFlags):
locking_exceptions = (IOError,)
with contextlib.suppress(NameError):
locking_exceptions += (BlockingIOError,) # type: ignore
# Locking with NON_BLOCKING without EXCLUSIVE or SHARED enabled results
# in an error
assert LOCKER is not None, 'We need a locing function in `LOCKER` '
# Locking with NON_BLOCKING without EXCLUSIVE or SHARED enabled
# results in an error
if (flags & LockFlags.NON_BLOCKING) and not flags & (
LockFlags.SHARED | LockFlags.EXCLUSIVE
):
Expand All @@ -104,14 +115,40 @@ def lock(file_: typing.Union[typing.IO, int], flags: LockFlags):
)

try:
fcntl.flock(file_, flags)
except locking_exceptions as exc_value:
# The exception code varies on different systems so we'll catch
# every IO error
raise exceptions.LockException(exc_value, fh=file_) from exc_value
LOCKER(file_, flags)
except OSError as exc_value:
# Python can use one of several different exception classes to
# represent timeout (most likely is BlockingIOError and IOError),
# but these errors may also represent other failures. On some
# systems, `IOError is OSError` which means checking for either
# IOError or OSError can mask other errors.
# The safest check is to catch OSError (from which the others
# inherit) and check the errno (which should be EACCESS or EAGAIN
# according to the spec).
if exc_value.errno in (errno.EACCES, errno.EAGAIN):
# A timeout exception, wrap this so the outer code knows to try
# again (if it wants to).
raise exceptions.AlreadyLocked(
exc_value,
fh=file_,
) from exc_value
else:
# Something else went wrong; don't wrap this so we stop
# immediately.
raise exceptions.LockException(
exc_value,
fh=file_,
) from exc_value
except EOFError as exc_value:
# On NFS filesystems, flock can raise an EOFError
raise exceptions.LockException(
exc_value,
fh=file_,
) from exc_value

def unlock(file_: typing.IO):
fcntl.flock(file_.fileno(), LockFlags.UNBLOCK)
assert LOCKER is not None, 'We need a locing function in `LOCKER` '
LOCKER(file_.fileno(), LockFlags.UNBLOCK)

else: # pragma: no cover
raise RuntimeError('PortaLocker only defined for nt and posix platforms')
4 changes: 2 additions & 2 deletions portalocker/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,12 @@ def channel_handler(self, message):
def client_name(self):
return f'{self.channel}-lock'

def acquire(
def acquire( # type: ignore[override]
self,
timeout: typing.Optional[float] = None,
check_interval: typing.Optional[float] = None,
fail_when_locked: typing.Optional[bool] = None,
):
) -> 'RedisLock':
timeout = utils.coalesce(timeout, self.timeout, 0.0)
check_interval = utils.coalesce(
check_interval,
Expand Down
26 changes: 16 additions & 10 deletions portalocker/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def open_atomic(
# `pathlib.Path` cast in case `path` is a `str`
path: pathlib.Path = pathlib.Path(filename)

assert not path.exists(), '%r exists' % path
assert not path.exists(), f'{path!r} exists'

# Create the parent directory if it doesn't exist
path.parent.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -132,8 +132,7 @@ def acquire(
timeout: typing.Optional[float] = None,
check_interval: typing.Optional[float] = None,
fail_when_locked: typing.Optional[bool] = None,
):
return NotImplemented
) -> typing.IO[typing.AnyStr]: ...

def _timeout_generator(
self,
Expand All @@ -156,10 +155,9 @@ def _timeout_generator(
time.sleep(max(0.001, (i * f_check_interval) - since_start_time))

@abc.abstractmethod
def release(self):
return NotImplemented
def release(self): ...

def __enter__(self):
def __enter__(self) -> typing.IO[typing.AnyStr]:
return self.acquire()

def __exit__(
Expand Down Expand Up @@ -235,7 +233,7 @@ def acquire(
timeout: typing.Optional[float] = None,
check_interval: typing.Optional[float] = None,
fail_when_locked: typing.Optional[bool] = None,
) -> typing.IO:
) -> typing.IO[typing.AnyStr]:
'''Acquire the locked filehandle'''

fail_when_locked = coalesce(fail_when_locked, self.fail_when_locked)
Expand Down Expand Up @@ -281,20 +279,28 @@ def try_close(): # pragma: no cover
if fail_when_locked:
try_close()
raise exceptions.AlreadyLocked(exception) from exc
except Exception as exc:
# Something went wrong with the locking mechanism.
# Wrap in a LockException and re-raise:
try_close()
raise exceptions.LockException(exc) from exc

# Wait a bit
# Wait a bit

if exception:
try_close()
# We got a timeout... reraising
raise exceptions.LockException(exception)
raise exception

# Prepare the filehandle (truncate if needed)
fh = self._prepare_fh(fh)

self.fh = fh
return fh

def __enter__(self) -> typing.IO[typing.AnyStr]:
return self.acquire()

def release(self):
'''Releases the currently locked file handle'''
if self.fh:
Expand Down Expand Up @@ -470,7 +476,7 @@ def get_filename(self, number) -> pathlib.Path:
number=number,
)

def acquire(
def acquire( # type: ignore[override]
self,
timeout: typing.Optional[float] = None,
check_interval: typing.Optional[float] = None,
Expand Down
13 changes: 11 additions & 2 deletions portalocker_tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@

import pytest

from portalocker import utils

logger = logging.getLogger(__name__)


@pytest.fixture
@pytest.fixture(scope='function')
def tmpfile(tmp_path):
filename = tmp_path / str(random.random())
filename = tmp_path / str(random.random())[2:]
yield str(filename)
with contextlib.suppress(PermissionError):
filename.unlink(missing_ok=True)
Expand All @@ -21,3 +23,10 @@ def pytest_sessionstart(session):
# I'm not a 100% certain this will work correctly unfortunately... there
# is some potential for breaking tests
multiprocessing.set_start_method('spawn')


@pytest.fixture(autouse=True)
def reduce_timeouts(monkeypatch):
'For faster testing we reduce the timeouts.'
monkeypatch.setattr(utils, 'DEFAULT_TIMEOUT', 0.1)
monkeypatch.setattr(utils, 'DEFAULT_CHECK_INTERVAL', 0.05)
6 changes: 6 additions & 0 deletions portalocker_tests/test_combined.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
def test_combined(tmpdir):
output_file = tmpdir.join('combined.py')
__main__.main(['combine', '--output-file', output_file.strpath])

print(output_file) # noqa: T201
print('#################') # noqa: T201
print(output_file.read()) # noqa: T201
print('#################') # noqa: T201

sys.path.append(output_file.dirname)
# Combined is being generated above but linters won't understand that
import combined # type: ignore
Expand Down
Loading

0 comments on commit 06d58c4

Please sign in to comment.