Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[run]
# _connections.py only builds real Redis clients / connection pools; the test
# suite mocks `get_redis` instead of talking to a live server, so measuring it
# would report permanently uncovered lines. The pre-pyproject CI passed this as
# `coverage run --omit=...`; the rendered workflow has no such flag, so it lives
# here now.
omit =
concurrency_limit/_connections.py
17 changes: 8 additions & 9 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,23 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v5
with:
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v2
uses: actions/setup-python@v6
with:
python-version: '3.10'
architecture: 'x64'
python-version: '3.x'

- name: Install dependencies and package
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install ".[build]"

- name: Build source and binary distribution package
run: |
python setup.py sdist bdist_wheel
env:
PACKAGE_VERSION: ${{ github.ref }}
python -m build

- name: Check distribution package
run: |
Expand Down
51 changes: 24 additions & 27 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,39 @@ name: Run linter and tests
on: [push, pull_request]

jobs:
test:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version:
- "3.9"
- "3.10"
- "3.11"
- "3.12"
- '3.10'
- '3.11'
- '3.12'
- '3.13'
- '3.14'

steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v5

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies and package
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Install dependencies and package
run: |
python -m pip install --upgrade pip
pip install -e ".[test,dev]"

- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 './concurrency_limit' --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 './concurrency_limit' --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Run lint and code review
run: |
pre-commit run --all-files

- name: Run tests
run: |
# run tests with coverage
coverage run --source='./concurrency_limit' --omit='./concurrency_limit/_connections.py' -m pytest
coverage xml
- name: Run tests with coverage
run: |
coverage run --source='./concurrency_limit' -m pytest
coverage xml

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ coverage.xml
*.egg-info
*.pyc
*.pyo
*.sqlite*
build/
dist/
27 changes: 27 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-merge-conflict
- id: end-of-file-fixer
- id: requirements-txt-fixer
- id: trailing-whitespace
args: ["--markdown-linebreak-ext=md"]

- repo: https://github.com/asottile/pyupgrade
rev: v3.21.2
hooks:
- id: pyupgrade
args: ["--py310-plus"]

- repo: https://github.com/asottile/add-trailing-comma
rev: v4.0.0
hooks:
- id: add-trailing-comma

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.14
hooks:
- id: ruff
args: [ --fix ]
- id: ruff-format
13 changes: 13 additions & 0 deletions .ruff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Project-owned ruff settings, layered on top of the template-managed
# [tool.ruff] section in pyproject.toml (which is overwritten on every copier
# apply, so project-specific rules cannot live there).
#
# This package re-exports its public API with `from .module import *` in every
# submodule, not just in __init__.py: each module declares __all__, so the
# surface is explicit even though ruff cannot resolve it across modules. The
# template only ignores F403/F405 for the package __init__.
extend = "pyproject.toml"

[lint.extend-per-file-ignores]
"concurrency_limit/*.py" = ["F403", "F405"]
"tests/test_limit.py" = ["F403", "F405"]
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
The MIT License (MIT)

Copyright (c) 2021 ANEXIA Internetdienstleisungs GmbH
Copyright (c) 2021-2026 Anexia

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,21 @@ pip install concurrency-limit
# Getting started

## How it works
- A limit of concurrently running scopes of a concurrency group can be defined using the `limit` decorator. A
- A limit of concurrently running scopes of a concurrency group can be defined using the `limit` decorator. A
concurrency group is defined by the `key` attribute of the `LimitConfiguration` instance.
- If the count of concurrently running scopes of a concurrency group is below the configured limit, the scope is
- If the count of concurrently running scopes of a concurrency group is below the configured limit, the scope is
executed immediately.
- If the count of concurrently running scopes of a concurrency group exceeds the configured limit, the context manager
- If the count of concurrently running scopes of a concurrency group exceeds the configured limit, the context manager
wait unit it goes below the configured limit.
- If the count of concurrently running scopes of a concurrency group does not go below the configured limit with the
- If the count of concurrently running scopes of a concurrency group does not go below the configured limit with the
configured timeout, a `ConcurrencyLimitExceededException` exception is raised.

## Usage

### Example 1

Limit the concurrency group `"example-1"` to `100` concurrently running scopes. If there are already `100` running
scopes, wait until the count of concurrently running scopes go below `100`. Fail if this does not happen within `10`
scopes, wait until the count of concurrently running scopes go below `100`. Fail if this does not happen within `10`
seconds by raising a `ConcurrencyLimitExceededException` exception.

```python
Expand All @@ -64,8 +64,8 @@ with concurrency_limit.limit(redis_configuration, limit_configuration):
### Example 2

Limit the concurrency group `"example-1"` to `100` concurrently running scopes. If there are already `100` running
scopes, wait until the count of concurrently running scopes go below `100`. Fail if this does not happen within `10`
seconds by raising a `ConcurrencyLimitExceededException` exception. Check if the concurrently running scopes
scopes, wait until the count of concurrently running scopes go below `100`. Fail if this does not happen within `10`
seconds by raising a `ConcurrencyLimitExceededException` exception. Check if the concurrently running scopes
are below the limit every `1` second.

```python
Expand Down Expand Up @@ -110,7 +110,7 @@ with concurrency_limit.limit(redis_configuration, limit_configuration):

### Example 4

Limit the concurrency group `"example-4"` to `100` concurrently running scopes. The implementation of the
Limit the concurrency group `"example-4"` to `100` concurrently running scopes. The implementation of the
concurrency group needs to now the number of the concurrently running scope.

```python
Expand Down Expand Up @@ -262,32 +262,33 @@ fail by raising a `ConcurrencyLimitExceededException` exception.
Default: `10`

The timeout that defines how long to wait for the concurrency count to go below the configured limit. The timeout
is configured in seconds. Set to `0` if you want to raise a `ConcurrencyLimitExceededException` exception immediately
is configured in seconds. Set to `0` if you want to raise a `ConcurrencyLimitExceededException` exception immediately
if there are too many concurrent executions.

#### `limit_interval: float`

Default: `0.1`

If there are too many concurrent executions of a scope, and a `limit_timeout` is set to a value greater than `0`, this
configuration defines the interval to re-check the current concurrency count. As soon as the concurrency count is
configuration defines the interval to re-check the current concurrency count. As soon as the concurrency count is
below the configured limit, the execution of the scope starts.

#### `limit_expire: int`

Default: `60`

The expiry time of the concurrency count key, configured in seconds. If a concurrency count is untouched for the
The expiry time of the concurrency count key, configured in seconds. If a concurrency count is untouched for the
configured time, it will be deleted.

# Supported versions

| | Supported |
|-------------|-----------|
| Python 3.9 | ✓ |
| Python 3.10 | ✓ |
| Python 3.11 | ✓ |
| Python 3.12 | ✓ |
| Python 3.13 | ✓ |
| Python 3.14 | ✓ |

# List of developers

Expand Down
7 changes: 7 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Reporting Security Issues

Please report any security issues you discovered to opensource[at]anexia-it[dot]com

We will assess the risk, plus make a fix available before we create a GitHub issue.

Thank you for your contribution.
5 changes: 2 additions & 3 deletions concurrency_limit/configuration.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import dataclasses
import typing

import redis
import redis.connection
Expand Down Expand Up @@ -43,13 +42,13 @@ class RedisConfiguration:
unix_socket: bool = False
"Use UNIX socket connection to Redis server. Will ignore `secure`, if set."

connection_class: typing.Type[redis.connection.AbstractConnection] = None
connection_class: type[redis.connection.AbstractConnection] = None
"Redis connection class to use instead of `secure` and `unix_socket` fields, if set."

connection_pool: redis.ConnectionPool = None
"Use this connection pool instance instead of the other fields, if set."

def get_connection_class(self) -> typing.Type[redis.connection.AbstractConnection]:
def get_connection_class(self) -> type[redis.connection.AbstractConnection]:
"""
Returns the `redis.Connection` class to use based on this configuration.

Expand Down
6 changes: 4 additions & 2 deletions concurrency_limit/context_managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@

@contextlib.contextmanager
def limit(
redis_configuration: RedisConfiguration, limit_configuration: LimitConfiguration
redis_configuration: RedisConfiguration,
limit_configuration: LimitConfiguration,
):
"""
The `limit` method is a context manager that allows for executing a scoped block of code under a concurrency limit.
Expand Down Expand Up @@ -100,7 +101,8 @@ class _LockAcquireException(Exception):
# exception. Executing the context manager's scope failed in this case.
if elapsed > lock_timeout:
raise ConcurrencyLimitExceededException(
limit=lock_limit, timeout=lock_timeout
limit=lock_limit,
timeout=lock_timeout,
)

# We failed to acquire an execution slot for the context manager's scope, but we want to try again.
Expand Down
2 changes: 1 addition & 1 deletion concurrency_limit/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class ConcurrencyLimitException(Exception):

def __init__(self, *args, **kwargs):
super().__init__(
self._msg_template.format(*args, **kwargs) if self._msg_template else None
self._msg_template.format(*args, **kwargs) if self._msg_template else None,
)


Expand Down
3 changes: 2 additions & 1 deletion concurrency_limit/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@


def limit_clean(
redis_configuration: RedisConfiguration, limit_configuration: LimitConfiguration
redis_configuration: RedisConfiguration,
limit_configuration: LimitConfiguration,
):
"""
Cleans stale limit locks in the hash for the given limit configuration.
Expand Down
61 changes: 57 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,59 @@
[build-system]
requires = [
"setuptools>=58",
"wheel",
]
requires = ["setuptools>=75", "wheel", "setuptools_scm[toml]>=8"]
build-backend = "setuptools.build_meta"

[project]
name = "concurrency-limit"
dynamic = ["version"]
description = "A library that implements a distributed concurrency limiting mechanism using Redis as a backend."
readme = "README.md"
requires-python = ">=3.10"
license = {file = "LICENSE"}
authors = [{name = "Anexia", email = "opensource@anexia-it.com"}]
urls = {Homepage = "https://github.com/anexia/python-concurrency-limit"}
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = [
"redis>=4.0",
]

[project.optional-dependencies]
test = [
"coverage>=7.13.2",
"pytest>=8",
"pytest-mock>=3.14",
]
dev = [
"pre-commit>=4.5,<4.6",
]
build = [
"build>=1.4.0",
"twine>=6.2.0",
"wheel>=0.46.3",
]

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

[tool.setuptools.packages.find]
where = ["."]
include = ["concurrency_limit*"]

[tool.setuptools_scm]

[tool.ruff.lint.per-file-ignores]
# Package __init__ files re-export the public API with `from .module import *`;
# every submodule defines __all__, so the surface is explicit even though ruff
# cannot resolve it across modules.
"concurrency_limit/__init__.py" = ["F403", "F405"]
11 changes: 0 additions & 11 deletions requirements.txt

This file was deleted.

Loading
Loading