Skip to content

Commit

Permalink
Add tests for detecting race conditions
Browse files Browse the repository at this point in the history
  • Loading branch information
yakimka committed Oct 9, 2024
1 parent b9af07f commit 86973a6
Show file tree
Hide file tree
Showing 5 changed files with 136 additions and 15 deletions.
44 changes: 44 additions & 0 deletions .github/workflows/workflow-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,50 @@ jobs:
&& .venv/bin/pytest
"
free-threading-test:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
image-name: [ 'quay.io/pypa/manylinux_2_28_x86_64']

steps:
- uses: actions/checkout@v4

- name: Prepare Docker
run: |
docker login "$CACHE_REGISTRY" -u "$CACHE_REGISTRY_USERNAME" --password="${CACHE_REGISTRY_TOKEN}"
docker buildx create --use --driver=docker-container
docker --version && docker compose --version
- name: Load cached venv
id: cached-venv
uses: actions/cache@v4
with:
path: |
.venv
key: free-threading${{ matrix.image-name }}-${{ hashFiles('./poetry.lock') }}

- name: Pull docker image
run: |
docker pull ${{ matrix.image-name }}
docker tag ${{ matrix.image-name }} free-threading-image
- name: Run checks
run: >
docker run -e VIRTUAL_ENV=/opt/code/.venv --rm -v $(pwd):/opt/code -w /opt/code
free-threading-image bash -c "
if [ ! -d .venv ]; then
python3.13t -m venv .venv;
fi
&& .venv/bin/pip install pytest pytest-asyncio pytest-cov pytest-randomly pytest-race pytest-repeat
&& .venv/bin/pip install --no-deps -e .
&& .venv/bin/python -VV
&& .venv/bin/pytest --ignore=tests/test_integrations
"
release-package:
runs-on: ubuntu-latest
needs: [ check-code ]
Expand Down
26 changes: 12 additions & 14 deletions picodi/_picodi.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
Iterable,
Iterator,
)
from contextlib import asynccontextmanager, contextmanager
from contextlib import asynccontextmanager, contextmanager, nullcontext
from dataclasses import dataclass
from typing import (
Annotated,
Expand Down Expand Up @@ -89,10 +89,9 @@ def add(
)

def get(self, dependency: DependencyCallable) -> Provider:
with self._storage.lock:
dependency = self.get_dep_or_override(dependency)
self._storage.touched_dependencies.add(dependency)
return self._storage.deps[dependency]
dependency = self.get_dep_or_override(dependency)
self._storage.touched_dependencies.add(dependency)
return self._storage.deps[dependency]

def get_dep_or_override(self, dependency: DependencyCallable) -> DependencyCallable:
return self._storage.overrides.get(dependency, dependency)
Expand Down Expand Up @@ -210,27 +209,24 @@ def clear_overrides(self) -> None:
"""
Clear all overrides. It will remove all overrides, but keep the dependencies.
"""
with self._storage.lock:
self._storage.overrides.clear()
self._storage.overrides.clear()

def clear_touched(self) -> None:
"""
Clear the touched dependencies.
It will remove all dependencies resolved during the picodi lifecycle.
"""
with self._storage.lock:
self._storage.touched_dependencies.clear()
self._storage.touched_dependencies.clear()

def clear(self) -> None:
"""
Clear the registry. It will remove all dependencies and overrides.
This method will not close any dependencies. So you need to manually call
:func:`shutdown_dependencies` before this method.
"""
with self._storage.lock:
self._storage.deps.clear()
self._storage.overrides.clear()
self._storage.touched_dependencies.clear()
self._storage.deps.clear()
self._storage.overrides.clear()
self._storage.touched_dependencies.clear()


_registry_storage = RegistryStorage()
Expand All @@ -241,7 +237,6 @@ def clear(self) -> None:
SingletonScope: SingletonScope(),
ContextVarScope: ContextVarScope(),
}
_lock = threading.RLock()


def Provide(dependency: DependencyCallable, /) -> Any: # noqa: N802
Expand Down Expand Up @@ -727,6 +722,9 @@ def _extract_and_register_dependency_from_parameter(
return None


_lock = nullcontext()


class LazyResolver:
def __init__(
self,
Expand Down
30 changes: 29 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ httpx = "^0.27.0"
pytest-markdown-docs = "^0.5.1"
sphinx = ">=7.3.7,<9.0.0"
furo = "^2024.5.6"
pytest-race = "0.2.0"
pytest-repeat = "^0.9.3"

[tool.isort]
# isort configuration:
Expand Down
49 changes: 49 additions & 0 deletions tests/test_race.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import asyncio
from random import randint

import pytest

from picodi import Provide, SingletonScope, dependency, inject


@pytest.mark.repeat(5)
def test_scope_resolving_races(start_race):
@dependency(scope_class=SingletonScope)
def get_random_int():
return randint(1, 10000)

@inject
def service(num: int = Provide(get_random_int)):
return num

results = []

def actual_test():
results.append(service())

start_race(threads_num=8, target=actual_test)

assert len(set(results)) == 1, results


@pytest.mark.repeat(5)
def test_scope_resolving_races_async(start_race):
@dependency(scope_class=SingletonScope)
async def get_random_int():
return randint(1, 10000)

@inject
async def service(num: int = Provide(get_random_int)):
return num

results = []

async def main():
results.append(await service())

def actual_test():
asyncio.run(main())

start_race(threads_num=8, target=actual_test)

assert len(set(results)) == 1, results

0 comments on commit 86973a6

Please sign in to comment.