diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4c92829..108877c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.8', '3.8', '3.10', '3.11'] + python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 71209cc..f0acbb8 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.8', '3.9', '3.10', '3.11'] + python-version: ['3.9', '3.10', '3.11', '3.12'] os: ['macos-latest', 'windows-latest'] steps: @@ -41,7 +41,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.8', '3.9', '3.10', '3.11'] + python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 7101b3f..b0efa53 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -12,6 +12,6 @@ jobs: - uses: actions/stale@v8 with: days-before-stale: 30 + days-before-pr-stale: -1 exempt-issue-labels: in-progress,help-wanted,pinned,security,enhancement exempt-all-pr-assignees: true - diff --git a/docs/conf.py b/docs/conf.py index 10570a6..3dd0173 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -350,4 +350,6 @@ # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'http://docs.python.org/3/': None} +intersphinx_mapping = dict( + python=('http://docs.python.org/3/', None), +) diff --git a/portalocker/__about__.py b/portalocker/__about__.py index a0b817a..c4a0806 100644 --- a/portalocker/__about__.py +++ b/portalocker/__about__.py @@ -1,6 +1,6 @@ __package_name__ = 'portalocker' __author__ = 'Rick van Hattem' __email__ = 'wolph@wol.ph' -__version__ = '2.10.1' +__version__ = '3.0.0' __description__ = '''Wraps the portalocker recipe for easy usage''' __url__ = 'https://github.com/WoLpH/portalocker' diff --git a/portalocker/__init__.py b/portalocker/__init__.py index 7e757ef..ce03e83 100644 --- a/portalocker/__init__.py +++ b/portalocker/__init__.py @@ -20,7 +20,7 @@ #: Current author's email address __email__ = __about__.__email__ #: Version number -__version__ = '2.10.1' +__version__ = '3.0.0' #: Package description for Pypi __description__ = __about__.__description__ #: Package homepage diff --git a/portalocker/__main__.py b/portalocker/__main__.py index ecac207..ed8cf4e 100644 --- a/portalocker/__main__.py +++ b/portalocker/__main__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import argparse import logging import os @@ -25,7 +27,7 @@ logger = logging.getLogger(__name__) -def main(argv=None): +def main(argv: typing.Sequence[str] | None = None) -> None: parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(required=True) @@ -46,15 +48,21 @@ def main(argv=None): args.func(args) -def _read_file(path: pathlib.Path, seen_files: typing.Set[pathlib.Path]): +def _read_file( + path: pathlib.Path, + seen_files: set[pathlib.Path], +) -> typing.Iterator[str]: if path in seen_files: return - names = set() + names: set[str] = set() seen_files.add(path) paren = False from_ = None for line in path.open(): + if '__future__' in line: + continue + if paren: if ')' in line: line = line.split(')', 1)[1] @@ -82,20 +90,23 @@ def _read_file(path: pathlib.Path, seen_files: typing.Set[pathlib.Path]): yield _clean_line(line, names) -def _clean_line(line, names): +def _clean_line(line: str, names: set[str]): # Replace `some_import.spam` with `spam` if names: joined_names = '|'.join(names) - line = re.sub(fr'\b({joined_names})\.', '', line) + line = re.sub(rf'\b({joined_names})\.', '', line) # Replace useless assignments (e.g. `spam = spam`) return _USELESS_ASSIGNMENT_RE.sub('', line) -def combine(args): +def combine(args: argparse.Namespace): output_file = args.output_file pathlib.Path(output_file.name).parent.mkdir(parents=True, exist_ok=True) + # We're handling this separately because it has to be the first import. + output_file.write('from __future__ import annotations\n') + output_file.write( _TEXT_TEMPLATE.format((base_path / 'README.rst').read_text()), ) @@ -103,7 +114,7 @@ def combine(args): _TEXT_TEMPLATE.format((base_path / 'LICENSE').read_text()), ) - seen_files: typing.Set[pathlib.Path] = set() + seen_files: set[pathlib.Path] = set() for line in _read_file(src_path / '__init__.py', seen_files): output_file.write(line) diff --git a/portalocker/exceptions.py b/portalocker/exceptions.py index e871d13..54d1bfa 100644 --- a/portalocker/exceptions.py +++ b/portalocker/exceptions.py @@ -1,5 +1,7 @@ import typing +from portalocker import types + class BaseLockException(Exception): # noqa: N818 # Error codes: @@ -8,7 +10,7 @@ class BaseLockException(Exception): # noqa: N818 def __init__( self, *args: typing.Any, - fh: typing.Union[typing.IO, None, int] = None, + fh: typing.Union[types.IO, None, int] = None, **kwargs: typing.Any, ) -> None: self.fh = fh diff --git a/portalocker/portalocker.py b/portalocker/portalocker.py index ceceeaa..e9184d6 100644 --- a/portalocker/portalocker.py +++ b/portalocker/portalocker.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import os import typing -from . import constants, exceptions +from . import constants, exceptions, types # Alias for readability. Due to import recursion issues we cannot do: # from .constants import LockFlags @@ -12,9 +14,7 @@ class HasFileno(typing.Protocol): def fileno(self) -> int: ... -LOCKER: typing.Optional[typing.Callable[ - [typing.Union[int, HasFileno], int], typing.Any]] = None - +LOCKER: typing.Callable[[int | HasFileno, int], typing.Any] | None = None if os.name == 'nt': # pragma: no cover import msvcrt @@ -26,7 +26,7 @@ def fileno(self) -> int: ... __overlapped = pywintypes.OVERLAPPED() - def lock(file_: typing.Union[typing.IO, int], flags: LockFlags): + def lock(file_: typing.IO | int, flags: LockFlags): # Windows locking does not support locking through `fh.fileno()` so # we cast it to make mypy and pyright happy file_ = typing.cast(typing.IO, file_) @@ -100,9 +100,9 @@ def unlock(file_: typing.IO): # 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 + LOCKER = fcntl.flock # pyright: ignore[reportConstantRedefinition] - def lock(file_: typing.Union[typing.IO, int], flags: LockFlags): + def lock(file: int | types.IO, flags: LockFlags): # type: ignore[misc] assert LOCKER is not None, 'We need a locking function in `LOCKER` ' # Locking with NON_BLOCKING without EXCLUSIVE or SHARED enabled # results in an error @@ -115,7 +115,7 @@ def lock(file_: typing.Union[typing.IO, int], flags: LockFlags): ) try: - LOCKER(file_, flags) + 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), @@ -130,25 +130,25 @@ def lock(file_: typing.Union[typing.IO, int], flags: LockFlags): # again (if it wants to). raise exceptions.AlreadyLocked( exc_value, - fh=file_, + 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_, + 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_, + fh=file, ) from exc_value - def unlock(file_: typing.IO): + def unlock(file: types.IO): # type: ignore[misc] assert LOCKER is not None, 'We need a locking function in `LOCKER` ' - LOCKER(file_.fileno(), LockFlags.UNBLOCK) + LOCKER(file.fileno(), LockFlags.UNBLOCK) else: # pragma: no cover raise RuntimeError('PortaLocker only defined for nt and posix platforms') diff --git a/portalocker/redis.py b/portalocker/redis.py index 11ee876..f58c492 100644 --- a/portalocker/redis.py +++ b/portalocker/redis.py @@ -1,3 +1,6 @@ +# pyright: reportUnknownMemberType=false +from __future__ import annotations + import _thread import json import logging @@ -5,7 +8,7 @@ import time import typing -from redis import client +import redis from . import exceptions, utils @@ -15,8 +18,8 @@ DEFAULT_THREAD_SLEEP_TIME = 0.1 -class PubSubWorkerThread(client.PubSubWorkerThread): # type: ignore - def run(self): +class PubSubWorkerThread(redis.client.PubSubWorkerThread): # type: ignore + def run(self) -> None: try: super().run() except Exception: # pragma: no cover @@ -61,28 +64,29 @@ class RedisLock(utils.LockBase): ''' - redis_kwargs: typing.Dict[str, typing.Any] - thread: typing.Optional[PubSubWorkerThread] + redis_kwargs: dict[str, typing.Any] + thread: PubSubWorkerThread | None channel: str timeout: float - connection: typing.Optional[client.Redis] - pubsub: typing.Optional[client.PubSub] = None + connection: redis.client.Redis[str] | None + pubsub: redis.client.PubSub | None = None close_connection: bool - DEFAULT_REDIS_KWARGS: typing.ClassVar[typing.Dict[str, typing.Any]] = dict( + DEFAULT_REDIS_KWARGS: typing.ClassVar[dict[str, typing.Any]] = dict( health_check_interval=10, + decode_responses=True, ) def __init__( self, channel: str, - connection: typing.Optional[client.Redis] = None, - timeout: typing.Optional[float] = None, - check_interval: typing.Optional[float] = None, - fail_when_locked: typing.Optional[bool] = False, + connection: redis.client.Redis[str] | None = None, + timeout: float | None = None, + check_interval: float | None = None, + fail_when_locked: bool | None = False, thread_sleep_time: float = DEFAULT_THREAD_SLEEP_TIME, unavailable_timeout: float = DEFAULT_UNAVAILABLE_TIMEOUT, - redis_kwargs: typing.Optional[typing.Dict] = None, + redis_kwargs: dict[str, typing.Any] | None = None, ): # We don't want to close connections given as an argument self.close_connection = not connection @@ -103,18 +107,22 @@ def __init__( fail_when_locked=fail_when_locked, ) - def get_connection(self) -> client.Redis: + def get_connection(self) -> redis.client.Redis[str]: if not self.connection: - self.connection = client.Redis(**self.redis_kwargs) + self.connection = redis.client.Redis(**self.redis_kwargs) return self.connection - def channel_handler(self, message): + def channel_handler(self, message: dict[str, str]) -> None: if message.get('type') != 'message': # pragma: no cover return + raw_data = message.get('data') + if not raw_data: + return + try: - data = json.loads(message.get('data')) + data = json.loads(raw_data) except TypeError: # pragma: no cover logger.debug('TypeError while parsing: %r', message) return @@ -128,10 +136,10 @@ def client_name(self): 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: float | None = None, + check_interval: float | None = None, + fail_when_locked: bool | None = None, + ) -> RedisLock: timeout = utils.coalesce(timeout, self.timeout, 0.0) check_interval = utils.coalesce( check_interval, @@ -189,7 +197,11 @@ def acquire( # type: ignore[override] raise exceptions.AlreadyLocked(exceptions) - def check_or_kill_lock(self, connection, timeout): + def check_or_kill_lock( + self, + connection: redis.client.Redis[str], + timeout: float, + ): # Random channel name to get messages back from the lock response_channel = f'{self.channel}-{random.random()}' @@ -217,7 +229,9 @@ def check_or_kill_lock(self, connection, timeout): for client_ in connection.client_list('pubsub'): # pragma: no cover if client_.get('name') == self.client_name: logger.warning('Killing unavailable redis client: %r', client_) - connection.client_kill_filter(client_.get('id')) + connection.client_kill_filter( # pyright: ignore + client_.get('id'), + ) return None def release(self): diff --git a/portalocker/types.py b/portalocker/types.py new file mode 100644 index 0000000..c0ee5cc --- /dev/null +++ b/portalocker/types.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import pathlib +import typing +from typing import Union + +# fmt: off +Mode = typing.Literal[ + # Text modes + # Read text + 'r', 'rt', 'tr', + # Write text + 'w', 'wt', 'tw', + # Append text + 'a', 'at', 'ta', + # Exclusive creation text + 'x', 'xt', 'tx', + # Read and write text + 'r+', '+r', 'rt+', 'r+t', '+rt', 'tr+', 't+r', '+tr', + # Write and read text + 'w+', '+w', 'wt+', 'w+t', '+wt', 'tw+', 't+w', '+tw', + # Append and read text + 'a+', '+a', 'at+', 'a+t', '+at', 'ta+', 't+a', '+ta', + # Exclusive creation and read text + 'x+', '+x', 'xt+', 'x+t', '+xt', 'tx+', 't+x', '+tx', + # Universal newline support + 'U', 'rU', 'Ur', 'rtU', 'rUt', 'Urt', 'trU', 'tUr', 'Utr', + + # Binary modes + # Read binary + 'rb', 'br', + # Write binary + 'wb', 'bw', + # Append binary + 'ab', 'ba', + # Exclusive creation binary + 'xb', 'bx', + # Read and write binary + 'rb+', 'r+b', '+rb', 'br+', 'b+r', '+br', + # Write and read binary + 'wb+', 'w+b', '+wb', 'bw+', 'b+w', '+bw', + # Append and read binary + 'ab+', 'a+b', '+ab', 'ba+', 'b+a', '+ba', + # Exclusive creation and read binary + 'xb+', 'x+b', '+xb', 'bx+', 'b+x', '+bx', + # Universal newline support in binary mode + 'rbU', 'rUb', 'Urb', 'brU', 'bUr', 'Ubr', +] +Filename = Union[str, pathlib.Path] +IO: typing.TypeAlias = Union[ # type: ignore[name-defined] + typing.IO[str], + typing.IO[bytes], +] + + +class FileOpenKwargs(typing.TypedDict): + buffering: int | None + encoding: str | None + errors: str | None + newline: str | None + closefd: bool | None + opener: typing.Callable[[str, int], int] | None diff --git a/portalocker/utils.py b/portalocker/utils.py index 5115b0e..f2c630e 100644 --- a/portalocker/utils.py +++ b/portalocker/utils.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import abc import atexit import contextlib @@ -10,7 +12,8 @@ import typing import warnings -from . import constants, exceptions, portalocker +from . import constants, exceptions, portalocker, types +from .types import Filename, Mode logger = logging.getLogger(__name__) @@ -24,11 +27,9 @@ 'open_atomic', ] -Filename = typing.Union[str, pathlib.Path] - def coalesce(*args: typing.Any, test_value: typing.Any = None) -> typing.Any: - '''Simple coalescing function that returns the first value that is not + """Simple coalescing function that returns the first value that is not equal to the `test_value`. Or `None` if no value is valid. Usually this means that the last given value is the default value. @@ -48,7 +49,7 @@ def coalesce(*args: typing.Any, test_value: typing.Any = None) -> typing.Any: # This won't work because of the `is not test_value` type testing: >>> coalesce([], dict(spam='eggs'), test_value=[]) [] - ''' + """ return next((arg for arg in args if arg is not test_value), None) @@ -56,8 +57,8 @@ def coalesce(*args: typing.Any, test_value: typing.Any = None) -> typing.Any: def open_atomic( filename: Filename, binary: bool = True, -) -> typing.Iterator[typing.IO]: - '''Open a file for atomic writing. Instead of locking this method allows +) -> typing.Iterator[types.IO]: + """Open a file for atomic writing. Instead of locking this method allows you to write the entire file and move it to the actual location. Note that this makes the assumption that a rename is atomic on your platform which is generally the case but not a guarantee. @@ -80,24 +81,28 @@ def open_atomic( ... written = fh.write(b'test') >>> assert path_filename.exists() >>> path_filename.unlink() - ''' + """ # `pathlib.Path` cast in case `path` is a `str` - path: pathlib.Path = pathlib.Path(filename) + path: pathlib.Path + if isinstance(filename, pathlib.Path): + path = filename + else: + path = pathlib.Path(filename) 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) - temp_fh = tempfile.NamedTemporaryFile( + with tempfile.NamedTemporaryFile( mode=binary and 'wb' or 'w', dir=str(path.parent), delete=False, - ) - yield temp_fh - temp_fh.flush() - os.fsync(temp_fh.fileno()) - temp_fh.close() + ) as temp_fh: + yield temp_fh + temp_fh.flush() + os.fsync(temp_fh.fileno()) + try: os.rename(temp_fh.name, path) finally: @@ -115,9 +120,9 @@ class LockBase(abc.ABC): # pragma: no cover def __init__( self, - timeout: typing.Optional[float] = None, - check_interval: typing.Optional[float] = None, - fail_when_locked: typing.Optional[bool] = None, + timeout: float | None = None, + check_interval: float | None = None, + fail_when_locked: bool | None = None, ): self.timeout = coalesce(timeout, DEFAULT_TIMEOUT) self.check_interval = coalesce(check_interval, DEFAULT_CHECK_INTERVAL) @@ -129,15 +134,15 @@ def __init__( @abc.abstractmethod def acquire( self, - timeout: typing.Optional[float] = None, - check_interval: typing.Optional[float] = None, - fail_when_locked: typing.Optional[bool] = None, + timeout: float | None = None, + check_interval: float | None = None, + fail_when_locked: bool | None = None, ) -> typing.IO[typing.AnyStr]: ... def _timeout_generator( self, - timeout: typing.Optional[float], - check_interval: typing.Optional[float], + timeout: float | None, + check_interval: float | None, ) -> typing.Iterator[int]: f_timeout = coalesce(timeout, self.timeout, 0.0) f_check_interval = coalesce(check_interval, self.check_interval, 0.0) @@ -162,19 +167,19 @@ def __enter__(self) -> typing.IO[typing.AnyStr]: def __exit__( self, - exc_type: typing.Optional[typing.Type[BaseException]], - exc_value: typing.Optional[BaseException], + exc_type: type[BaseException] | None, + exc_value: BaseException | None, traceback: typing.Any, # Should be typing.TracebackType - ) -> typing.Optional[bool]: + ) -> bool | None: self.release() return None - def __delete__(self, instance): + def __delete__(self, instance: LockBase): instance.release() class Lock(LockBase): - '''Lock manager with built-in timeout + """Lock manager with built-in timeout Args: filename: filename @@ -192,21 +197,31 @@ class Lock(LockBase): Note that the file is opened first and locked later. So using 'w' as mode will result in truncate _BEFORE_ the lock is checked. - ''' + """ + + fh: types.IO | None + filename: str + mode: str + truncate: bool + timeout: float + check_interval: float + fail_when_locked: bool + flags: constants.LockFlags + file_open_kwargs: dict[str, typing.Any] def __init__( self, filename: Filename, - mode: str = 'a', - timeout: typing.Optional[float] = None, + mode: Mode = 'a', + timeout: float | None = None, check_interval: float = DEFAULT_CHECK_INTERVAL, fail_when_locked: bool = DEFAULT_FAIL_WHEN_LOCKED, flags: constants.LockFlags = LOCK_METHOD, - **file_open_kwargs, + **file_open_kwargs: typing.Any, ): if 'w' in mode: truncate = True - mode = mode.replace('w', 'a') + mode = typing.cast(Mode, mode.replace('w', 'a')) else: truncate = False @@ -218,21 +233,19 @@ def __init__( stacklevel=1, ) - self.fh: typing.Optional[typing.IO] = None - self.filename: str = str(filename) - self.mode: str = mode - self.truncate: bool = truncate - self.timeout: float = timeout - self.check_interval: float = check_interval - self.fail_when_locked: bool = fail_when_locked - self.flags: constants.LockFlags = flags + self.fh = None + self.filename = str(filename) + self.mode = mode + self.truncate = truncate + self.flags = flags self.file_open_kwargs = file_open_kwargs + super().__init__(timeout, check_interval, fail_when_locked) def acquire( self, - timeout: typing.Optional[float] = None, - check_interval: typing.Optional[float] = None, - fail_when_locked: typing.Optional[bool] = None, + timeout: float | None = None, + check_interval: float | None = None, + fail_when_locked: bool | None = None, ) -> typing.IO[typing.AnyStr]: '''Acquire the locked filehandle''' @@ -248,9 +261,10 @@ def acquire( ) # If we already have a filehandle, return it - fh: typing.Optional[typing.IO] = self.fh + fh = self.fh if fh: - return fh + # Due to type invariance we need to cast the type + return typing.cast(typing.IO[typing.AnyStr], fh) # Get a new filehandler fh = self._get_fh() @@ -296,7 +310,7 @@ def try_close(): # pragma: no cover fh = self._prepare_fh(fh) self.fh = fh - return fh + return typing.cast(typing.IO[typing.AnyStr], fh) def __enter__(self) -> typing.IO[typing.AnyStr]: return self.acquire() @@ -308,15 +322,18 @@ def release(self): self.fh.close() self.fh = None - def _get_fh(self) -> typing.IO: + def _get_fh(self) -> types.IO: '''Get a new filehandle''' - return open( # noqa: SIM115 - self.filename, - self.mode, - **self.file_open_kwargs, + return typing.cast( + types.IO, + open( # noqa: SIM115 + self.filename, + self.mode, + **self.file_open_kwargs, + ), ) - def _get_lock(self, fh: typing.IO) -> typing.IO: + def _get_lock(self, fh: types.IO) -> types.IO: ''' Try to lock the given filehandle @@ -324,7 +341,7 @@ def _get_lock(self, fh: typing.IO) -> typing.IO: portalocker.lock(fh, self.flags) return fh - def _prepare_fh(self, fh: typing.IO) -> typing.IO: + def _prepare_fh(self, fh: types.IO) -> types.IO: ''' Prepare the filehandle for usage @@ -347,12 +364,12 @@ class RLock(Lock): def __init__( self, - filename, - mode='a', - timeout=DEFAULT_TIMEOUT, - check_interval=DEFAULT_CHECK_INTERVAL, - fail_when_locked=False, - flags=LOCK_METHOD, + filename: Filename, + mode: Mode = 'a', + timeout: float = DEFAULT_TIMEOUT, + check_interval: float = DEFAULT_CHECK_INTERVAL, + fail_when_locked: bool = False, + flags: constants.LockFlags = LOCK_METHOD, ): super().__init__( filename, @@ -366,12 +383,13 @@ def __init__( def acquire( self, - timeout: typing.Optional[float] = None, - check_interval: typing.Optional[float] = None, - fail_when_locked: typing.Optional[bool] = None, - ) -> typing.IO: + timeout: float | None = None, + check_interval: float | None = None, + fail_when_locked: bool | None = None, + ) -> typing.IO[typing.AnyStr]: + fh: typing.IO[typing.AnyStr] if self._acquire_count >= 1: - fh = self.fh + fh = typing.cast(typing.IO[typing.AnyStr], self.fh) else: fh = super().acquire(timeout, check_interval, fail_when_locked) self._acquire_count += 1 @@ -392,11 +410,11 @@ def release(self): class TemporaryFileLock(Lock): def __init__( self, - filename='.lock', - timeout=DEFAULT_TIMEOUT, - check_interval=DEFAULT_CHECK_INTERVAL, - fail_when_locked=True, - flags=LOCK_METHOD, + filename: str = '.lock', + timeout: float = DEFAULT_TIMEOUT, + check_interval: float = DEFAULT_CHECK_INTERVAL, + fail_when_locked: bool = True, + flags: constants.LockFlags = LOCK_METHOD, ): Lock.__init__( self, @@ -416,7 +434,7 @@ def release(self): class BoundedSemaphore(LockBase): - ''' + """ Bounded semaphore to prevent too many parallel processes from running This method is deprecated because multiple processes that are completely @@ -429,9 +447,9 @@ class BoundedSemaphore(LockBase): 'bounded_semaphore.00.lock' >>> str(sorted(semaphore.get_random_filenames())[1]) 'bounded_semaphore.01.lock' - ''' + """ - lock: typing.Optional[Lock] + lock: Lock | None def __init__( self, @@ -439,15 +457,15 @@ def __init__( name: str = 'bounded_semaphore', filename_pattern: str = '{name}.{number:02d}.lock', directory: str = tempfile.gettempdir(), - timeout: typing.Optional[float] = DEFAULT_TIMEOUT, - check_interval: typing.Optional[float] = DEFAULT_CHECK_INTERVAL, - fail_when_locked: typing.Optional[bool] = True, + timeout: float | None = DEFAULT_TIMEOUT, + check_interval: float | None = DEFAULT_CHECK_INTERVAL, + fail_when_locked: bool | None = True, ): self.maximum = maximum self.name = name self.filename_pattern = filename_pattern self.directory = directory - self.lock: typing.Optional[Lock] = None + self.lock: Lock | None = None super().__init__( timeout=timeout, check_interval=check_interval, @@ -470,7 +488,7 @@ def get_random_filenames(self) -> typing.Sequence[pathlib.Path]: random.shuffle(filenames) return filenames - def get_filename(self, number) -> pathlib.Path: + def get_filename(self, number: int) -> pathlib.Path: return pathlib.Path(self.directory) / self.filename_pattern.format( name=self.name, number=number, @@ -478,10 +496,10 @@ def get_filename(self, number) -> pathlib.Path: def acquire( # type: ignore[override] self, - timeout: typing.Optional[float] = None, - check_interval: typing.Optional[float] = None, - fail_when_locked: typing.Optional[bool] = None, - ) -> typing.Optional[Lock]: + timeout: float | None = None, + check_interval: float | None = None, + fail_when_locked: bool | None = None, + ) -> Lock | None: assert not self.lock, 'Already locked' filenames = self.get_filenames() @@ -522,7 +540,7 @@ def release(self): # pragma: no cover class NamedBoundedSemaphore(BoundedSemaphore): - ''' + """ Bounded semaphore to prevent too many parallel processes from running It's also possible to specify a timeout when acquiring the lock to wait @@ -544,17 +562,17 @@ class NamedBoundedSemaphore(BoundedSemaphore): >>> 'bounded_semaphore' in str(semaphore.get_filenames()[0]) True - ''' + """ def __init__( self, maximum: int, - name: typing.Optional[str] = None, + name: str | None = None, filename_pattern: str = '{name}.{number:02d}.lock', directory: str = tempfile.gettempdir(), - timeout: typing.Optional[float] = DEFAULT_TIMEOUT, - check_interval: typing.Optional[float] = DEFAULT_CHECK_INTERVAL, - fail_when_locked: typing.Optional[bool] = True, + timeout: float | None = DEFAULT_TIMEOUT, + check_interval: float | None = DEFAULT_CHECK_INTERVAL, + fail_when_locked: bool | None = True, ): if name is None: name = 'bounded_semaphore.%d' % random.randint(0, 1000000) diff --git a/portalocker_tests/test_redis.py b/portalocker_tests/test_redis.py index e9bec02..2f274a1 100644 --- a/portalocker_tests/test_redis.py +++ b/portalocker_tests/test_redis.py @@ -46,7 +46,7 @@ def test_redis_lock(): @pytest.mark.parametrize('timeout', [None, 0, 0.001]) @pytest.mark.parametrize('check_interval', [None, 0, 0.0005]) def test_redis_lock_timeout(timeout, check_interval): - connection = client.Redis() + connection: client.Redis[str] = client.Redis(decode_responses=True) channel = str(random.random()) lock_a = redis.RedisLock(channel) lock_a.acquire(timeout=timeout, check_interval=check_interval) diff --git a/portalocker_tests/tests.py b/portalocker_tests/tests.py index ee0d91b..a875d4f 100644 --- a/portalocker_tests/tests.py +++ b/portalocker_tests/tests.py @@ -186,18 +186,24 @@ def test_exlusive(tmpfile): portalocker.lock(fh, portalocker.LOCK_EX | portalocker.LOCK_NB) # Make sure we can't read the locked file - with pytest.raises(portalocker.LockException), open( - tmpfile, - 'r+', - ) as fh2: + with ( + pytest.raises(portalocker.LockException), + open( + tmpfile, + 'r+', + ) as fh2, + ): portalocker.lock(fh2, portalocker.LOCK_EX | portalocker.LOCK_NB) assert fh2.read() == text_0 # Make sure we can't write the locked file - with pytest.raises(portalocker.LockException), open( - tmpfile, - 'w+', - ) as fh2: + with ( + pytest.raises(portalocker.LockException), + open( + tmpfile, + 'w+', + ) as fh2, + ): portalocker.lock(fh2, portalocker.LOCK_EX | portalocker.LOCK_NB) fh2.write('surprise and fear') @@ -218,10 +224,13 @@ def test_shared(tmpfile): assert fh2.read() == 'spam and eggs' # Make sure we can't write the locked file - with pytest.raises(portalocker.LockException), open( - tmpfile, - 'w+', - ) as fh2: + with ( + pytest.raises(portalocker.LockException), + open( + tmpfile, + 'w+', + ) as fh2, + ): portalocker.lock(fh2, portalocker.LOCK_EX | portalocker.LOCK_NB) fh2.write('surprise and fear') @@ -287,7 +296,7 @@ def exclusive_lock(filename, **kwargs): @dataclasses.dataclass(order=True) class LockResult: - exception_class: typing.Union[typing.Type[Exception], None] = None + exception_class: typing.Union[type[Exception], None] = None exception_message: typing.Union[str, None] = None exception_repr: typing.Union[str, None] = None diff --git a/pyproject.toml b/pyproject.toml index 0c61e2b..b537120 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,11 +37,11 @@ classifiers = [ 'Operating System :: Unix', 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: IronPython', 'Programming Language :: Python :: Implementation :: PyPy', @@ -98,3 +98,4 @@ skip = '*/htmlcov,./docs/_build,*.asc' [tool.pyright] include = ['portalocker', 'portalocker_tests'] exclude = ['dist/*'] +strict = ['portalocker'] diff --git a/ruff.toml b/ruff.toml index e4180ff..0853930 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,13 +1,22 @@ # We keep the ruff configuration separate so it can easily be shared across # all projects -target-version = 'py38' +target-version = 'py39' src = ['portalocker'] -exclude = ['docs'] +exclude = [ + 'docs', + # Ignore local test files/directories/old-stuff + 'test.py', + '*_old.py', +] line-length = 80 +[format] +quote-style = 'single' + + [lint] ignore = [ 'A001', # Variable {name} is shadowing a Python builtin @@ -26,6 +35,7 @@ ignore = [ 'SIM114', # Combine `if` branches using logical `or` operator 'RET506', # Unnecessary `else` after `raise` statement 'FA100', # Missing `from __future__ import annotations`, but uses `typing.Optional` + 'RUF100', # Unused noqa directives. Due to multiple Python versions, we need to keep them ] select = [ diff --git a/tox.ini b/tox.ini index 29bf765..371099c 100644 --- a/tox.ini +++ b/tox.ini @@ -42,6 +42,7 @@ changedir = basepython = python3 deps = pyright + types-redis -e{toxinidir}[tests,redis] commands = pyright {toxinidir}/portalocker {toxinidir}/portalocker_tests