Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .github/workflows/unittests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11", "3.12" ]
python-version: [ "3.10", "3.11", "3.12", "3.13" ]

steps:
- uses: actions/checkout@v3
Expand All @@ -27,9 +27,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8 pytest
pip install -r requirements-tests.txt
pip install .
pip install .[anyio,tests]
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,18 @@ because [python decided not to support RLock in asyncio](https://discuss.python.
their [argument](https://discuss.python.org/t/asyncio-rlock-reentrant-locks-for-async-python/21509/2) being that every
extra bit of functionality adds to maintenance cost.

Install with
Install normally for asyncio support:

```bash
pip install fair-async-rlock

```

or with AnyIO support:

```bash
pip install fair-async-rlock[anyio]
````

## About Fair Reentrant Lock for AsyncIO

A reentrant lock (or recursive lock) is a particular type of lock that can be "locked" multiple times by the same task
Expand Down Expand Up @@ -111,4 +117,5 @@ with `asyncio.Lock`.
### Change Log

27 Jan, 2024 - 1.0.7 released. Fixed a bug that allowed another task to get the lock before a waiter got its turn on the
event loop.
event loop.
17 Mar, 2025 - 2.0.0 released. Remove support for < 3.10.
1 change: 0 additions & 1 deletion fair_async_rlock/__init__.py

This file was deleted.

31 changes: 27 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
# pyproject.toml

[build-system]
requires = [
"setuptools>=42",
"wheel"
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "fair_async_rlock"
version = "2.0.0"
description = "A fair async RLock for Python"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "Apache Software License" }
authors = [{ name = "Joshua G. Albert", email = "[email protected]" }]
keywords = ["async", "fair", "reentrant", "lock", "concurrency"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent"
]
build-backend = "setuptools.build_meta"
urls = { "Homepage" = "https://github.com/joshuaalbert/FairAsyncRLock" }
dynamic = ["dependencies", "optional-dependencies"]

[tool.setuptools]
include-package-data = true


[tool.setuptools.packages.find]
where = ["src"]
1 change: 1 addition & 0 deletions requirements-anyio.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
anyio>=4.5
7 changes: 5 additions & 2 deletions requirements-tests.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
pytest<8.0.0
pytest-asyncio
flake8
pytest
pytest-asyncio
trio==0.25.*
anyio>=4.5
File renamed without changes.
40 changes: 14 additions & 26 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,19 @@
#!/usr/bin/env python

from setuptools import find_packages
from setuptools import setup

with open("README.md", "r") as fh:
long_description = fh.read()
install_requires = []

setup(name='fair_async_rlock',
version='1.0.7',
description='A well-tested implementation of a fair asynchronous RLock for concurrent programming.',
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/Joshuaalbert/FairAsyncRLock",
author='Joshua G. Albert',
author_email='[email protected]',
setup_requires=[],
install_requires=[],
tests_require=[
'pytest',
'pytest-asyncio'
],
package_dir={'': './'},
packages=find_packages('./'),
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
],
python_requires='>=3.7',
)

def load_requirements(file_name):
with open(file_name, "r") as file:
return [line.strip() for line in file if line.strip() and not line.startswith("#")]


setup(
install_requires=load_requirements("requirements.txt"),
extras_require={
"tests": load_requirements("requirements-tests.txt"),
'anyio': load_requirements("requirements-anyio.txt")
}
)
6 changes: 6 additions & 0 deletions src/fair_async_rlock/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from fair_async_rlock.asyncio_fair_async_rlock import *

try:
from fair_async_rlock.anyio_fair_async_rlock import *
except ImportError:
pass
29 changes: 29 additions & 0 deletions src/fair_async_rlock/anyio_fair_async_rlock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from typing import TypeVar

import anyio

from fair_async_rlock.base_fair_async_rlock import BaseFairAsyncRLock

__all__ = [
'AnyIOFairAsyncRLock'
]
TaskType = TypeVar('TaskType')
EventType = TypeVar('EventType')


class AnyIOFairAsyncRLock(BaseFairAsyncRLock[anyio.TaskInfo, anyio.Event]):
"""
A fair reentrant lock for async programming. Fair means that it respects the order of acquisition.
"""

def _get_current_task(self) -> anyio.TaskInfo:
return anyio.get_current_task()

def _get_cancelled_exc_class(self) -> type[BaseException]:
return anyio.get_cancelled_exc_class()

def _get_wake_event(self) -> anyio.Event:
return anyio.Event()

async def _checkpoint(self) -> None:
await anyio.lowlevel.checkpoint()
28 changes: 28 additions & 0 deletions src/fair_async_rlock/asyncio_fair_async_rlock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import asyncio
from typing import Any, TypeVar

from fair_async_rlock.base_fair_async_rlock import BaseFairAsyncRLock

__all__ = [
'FairAsyncRLock'
]
TaskType = TypeVar('TaskType')
EventType = TypeVar('EventType')


class FairAsyncRLock(BaseFairAsyncRLock[asyncio.Task[Any], asyncio.Event]):
"""
A fair reentrant lock for async programming. Fair means that it respects the order of acquisition.
"""

def _get_current_task(self) -> asyncio.Task[Any] | None:
return asyncio.current_task()

def _get_cancelled_exc_class(self) -> type[BaseException]:
return asyncio.CancelledError

def _get_wake_event(self) -> asyncio.Event:
return asyncio.Event()

async def _checkpoint(self) -> None:
await asyncio.sleep(0)
Original file line number Diff line number Diff line change
@@ -1,47 +1,85 @@
import asyncio
from abc import ABC, abstractmethod
from collections import deque

__all__ = [
'FairAsyncRLock'
]
from typing import Any, Generic, TypeVar, Protocol

class _Event(Protocol):
def set(self) -> None: ...
async def wait(self) -> Any: ...

class FairAsyncRLock:

TaskType = TypeVar("TaskType")
EventType = TypeVar("EventType", bound=_Event)


class AbstractFairAsyncRLock(ABC, Generic[TaskType, EventType]):
@abstractmethod
def _get_current_task(self) -> TaskType | None:
...

@abstractmethod
def _get_cancelled_exc_class(self) -> type[BaseException]:
...

@abstractmethod
def _get_wake_event(self) -> EventType:
...

@abstractmethod
async def _checkpoint(self) -> None:
...


class BaseFairAsyncRLock(AbstractFairAsyncRLock[TaskType, EventType]):
"""
A fair reentrant lock for async programming. Fair means that it respects the order of acquisition.
"""

def __init__(self):
self._owner: asyncio.Task | None = None
self._count = 0
self._owner_transfer = False
self._queue = deque()
self._owner: TaskType | None = None
self._count: int = 0
self._owner_transfer: bool = False
self._queue: deque[EventType] = deque()

def is_owner(self, task=None):
def is_owner(self, task: TaskType | None = None) -> bool:
if task is None:
task = asyncio.current_task()
task = self._get_current_task()
return self._owner == task

def locked(self) -> bool:
return self._owner is not None

async def acquire(self):
async def acquire(self) -> None:
"""Acquire the lock."""
me = asyncio.current_task()
me = self._get_current_task()

# If the lock is reentrant, acquire it immediately
if self.is_owner(task=me):
self._count += 1
try:
await self._checkpoint()
except self._get_cancelled_exc_class():
# Cancelled, while reentrant, so release the lock
self._owner_transfer = False
self._owner = me
self._count = 1
self._current_task_release()
raise
return

# If the lock is free (and ownership not in midst of transfer), acquire it immediately
if self._count == 0 and not self._owner_transfer:
self._owner = me
self._count = 1
try:
await self._checkpoint()
except self._get_cancelled_exc_class():
self._current_task_release()
raise
return

# Create an event for this task, to notify when it's ready for acquire
event = asyncio.Event()
event = self._get_wake_event()
self._queue.append(event)

# Wait for the lock to be free, then acquire
Expand All @@ -50,17 +88,18 @@ async def acquire(self):
self._owner_transfer = False
self._owner = me
self._count = 1
except asyncio.CancelledError:
except self._get_cancelled_exc_class():
try: # if in queue, then cancelled before release
self._queue.remove(event)
except ValueError: # otherwise, release happened, this was next, and we simulate passing on
except ValueError:
# otherwise, release happened, this was next, and we simulate passing on
self._owner_transfer = False
self._owner = me
self._count = 1
self._current_task_release()
raise

def _current_task_release(self):
def _current_task_release(self) -> None:
self._count -= 1
if self._count == 0:
self._owner = None
Expand All @@ -73,7 +112,7 @@ def _current_task_release(self):

def release(self):
"""Release the lock"""
me = asyncio.current_task()
me = self._get_current_task()

if self._owner is None:
raise RuntimeError(f"Cannot release un-acquired lock. {me} tried to release.")
Expand Down
Empty file added src/fair_async_rlock/py.typed
Empty file.
Empty file.
Loading
Loading