diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 00000000..1277b2ad --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,144 @@ +name: "πŸ› οΈ check" +on: + workflow_dispatch: + push: + branches: ["main"] + tags-ignore: ["**"] + pull_request: + schedule: + - cron: "0 8 * * *" + +concurrency: + group: check-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: "πŸ§ͺ Test ${{ matrix.py }} - ${{ matrix.os }}" + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + py: + - "3.14t" + - "3.14" + - "3.13" + - "3.12" + - "3.11" + - "3.10" + - "3.9" + - "pypy3.11" + os: + - ubuntu-24.04 + - windows-2025 + - macos-15 + exclude: + - { os: windows-2025, py: pypy3.11 } + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: "πŸ”„ Install the latest version of uv" + uses: astral-sh/setup-uv@v6 + - name: "πŸ§ͺ Install tox" + run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv + - name: "🐍 Install Python" + run: uv python install --python-preference only-managed ${{ matrix.py }} + - name: "βš™οΈ Setup test suite" + run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.py }} + env: + UV_PYTHON_PREFERENCE: only-managed + - name: "βœ… Run test suite" + run: | + if [[ "${{ matrix.py }}" == pypy* ]]; then + tox run --skip-pkg-install -e ${{ matrix.py }} -- + else + tox run --skip-pkg-install -e ${{ matrix.py }} + fi + shell: bash + env: + PYTEST_ADDOPTS: "-vv --durations=20" + UV_PYTHON_PREFERENCE: only-managed + DIFF_AGAINST: HEAD + - name: "πŸ“ Rename coverage report file" + if: ${{ !startsWith(matrix.py, 'pypy')}} + run: | + import os; import sys + os.rename(f".tox/.coverage.${{ matrix.py }}", f".tox/.coverage.${{ matrix.py }}-{sys.platform}") + shell: python + - name: "πŸ“¦ Upload coverage data" + if: ${{ !startsWith(matrix.py, 'pypy')}} + uses: actions/upload-artifact@v4 + with: + include-hidden-files: true + name: .coverage.${{ matrix.os }}.${{ matrix.py }} + path: ".tox/.coverage.*" + retention-days: 3 + + coverage: + name: "πŸ“Š Combine coverage" + runs-on: ubuntu-24.04 + needs: test + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: "πŸ”„ Install the latest version of uv" + uses: astral-sh/setup-uv@v6 + - name: "πŸ§ͺ Install tox" + run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv + - name: "πŸ“¦ Build package to generate version" + run: uv build --python 3.13 --python-preference only-managed --wheel . --out-dir dist + - name: "βš™οΈ Setup coverage tool" + run: tox -e coverage --notest + env: + UV_PYTHON_PREFERENCE: only-managed + - name: "⬇️ Download coverage data" + uses: actions/download-artifact@v5 + with: + path: .tox + pattern: .coverage.* + merge-multiple: true + - name: "πŸ“Š Combine and report coverage" + run: tox -e coverage --skip-pkg-install + env: + UV_PYTHON_PREFERENCE: only-managed + - name: "πŸ“€ Upload HTML report" + uses: actions/upload-artifact@v4 + with: + name: html-report + path: .tox/htmlcov + + check: + name: "πŸ”Ž ${{ matrix.tox_env }} - ${{ matrix.os }}" + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-24.04 + - windows-2025 + tox_env: + - dev + - type + - docs + - pkg_meta + exclude: + - { os: windows-2025, tox_env: pkg_meta } + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: "πŸ”„ Install the latest version of uv" + uses: astral-sh/setup-uv@v6 + - name: "πŸ§ͺ Install tox" + run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv + - name: "βš™οΈ Setup test suite" + run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.tox_env }} + env: + UV_PYTHON_PREFERENCE: only-managed + - name: "βœ… Run test suite" + run: tox run --skip-pkg-install -e ${{ matrix.tox_env }} + env: + UV_PYTHON_PREFERENCE: only-managed diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml deleted file mode 100644 index ed427be3..00000000 --- a/.github/workflows/check.yml +++ /dev/null @@ -1,150 +0,0 @@ -name: check -on: - workflow_dispatch: - push: - branches: "main" - tags-ignore: ["**"] - pull_request: - schedule: - - cron: "0 8 * * *" - -concurrency: - group: check-${{ github.ref }} - cancel-in-progress: true - -jobs: - test: - name: test ${{ matrix.py }} - ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - py: - - "3.12" - - "3.11" - - "3.10" - - "3.9" - - "3.8" - - "pypy3.9" - - "pypy3.8" - os: - - ubuntu-latest - - windows-latest - - macos-latest - - steps: - - name: Setup python for tox - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - name: Install tox - run: python -m pip install tox - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Setup python for test ${{ matrix.py }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.py }} - - name: Pick environment to run - run: | - import os; import platform; import sys; from pathlib import Path - env = f'TOXENV=py{"" if platform.python_implementation() == "CPython" else "py"}3{sys.version_info.minor}' - print(f"Picked: {env} for {sys.version} based of {sys.executable}") - with Path(os.environ["GITHUB_ENV"]).open("ta") as file_handler: - file_handler.write(env) - shell: python - - name: Setup test suite - run: tox r -vv --notest - - if: ${{ startsWith(matrix.py, 'pypy') }} - name: Run test suite - run: tox r --skip-pkg-install -- - env: - PYTEST_ADDOPTS: "-vv --durations=20" - CI_RUN: "yes" - - if: ${{ !startsWith(matrix.py, 'pypy')}} - name: Run test suite - run: tox r --skip-pkg-install - env: - PYTEST_ADDOPTS: "-vv --durations=20" - CI_RUN: "yes" - DIFF_AGAINST: HEAD - - if: ${{ !startsWith(matrix.py, 'pypy')}} - name: Rename coverage report file - run: | - import os; import sys - os.rename(f".tox/.coverage.{os.environ['TOXENV']}", f".tox/.coverage.{os.environ['TOXENV']}-{sys.platform}") - shell: python - - if: ${{ !startsWith(matrix.py, 'pypy')}} - name: Upload coverage data - uses: actions/upload-artifact@v3 - with: - name: coverage-data - path: ".tox/.coverage.*" - - coverage: - name: Combine coverage - runs-on: ubuntu-latest - needs: test - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - name: Install tox - run: python -m pip install tox build - - name: Setup coverage tool - run: tox -e coverage --notest - - name: Install package builder - run: python -m pip install build - - name: Build package - run: pyproject-build --wheel . - - name: Download coverage data - uses: actions/download-artifact@v3 - with: - name: coverage-data - path: .tox - - name: Show contents - run: ls -alth * - - name: pwd - run: pwd - - name: Combine and report coverage - run: tox -e coverage - - name: Upload HTML report - uses: actions/upload-artifact@v3 - with: - name: html-report - path: .tox/htmlcov - - check: - name: ${{ matrix.tox_env }} - ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: - - ubuntu-latest - - windows-latest - tox_env: - - dev - - type - - docs - - readme - exclude: - - { os: windows-latest, tox_env: readme } - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Setup Python "3.11" - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - name: Install tox - run: python -m pip install tox - - name: Setup test suite - run: tox -vv --notest -e ${{ matrix.tox_env }} - - name: Run test suite - run: tox --skip-pkg-install -e ${{ matrix.tox_env }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 00000000..2bc16d46 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,42 @@ +name: πŸš€Release to PyPI +on: + push: + tags: ["*"] + +env: + dists-artifact-name: python-package-distributions + +jobs: + build: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: "πŸ”„ Install the latest version of uv" + uses: astral-sh/setup-uv@v6 + - name: "πŸ“¦ Build package" + run: uv build --python 3.13 --python-preference only-managed --sdist --wheel . --out-dir dist + - name: "πŸ“€ Store the distribution packages" + uses: actions/upload-artifact@v4 + with: + name: ${{ env.dists-artifact-name }} + path: dist/* + + release: + needs: + - build + runs-on: ubuntu-24.04 + environment: + name: "Release to PyPI" + url: https://pypi.org/project/filelock/${{ github.ref_name }} + permissions: + id-token: write + steps: + - name: "⬇️ Download all the dists" + uses: actions/download-artifact@v5 + with: + name: ${{ env.dists-artifact-name }} + path: dist/ + - name: "πŸš€ Publish to PyPI" + uses: pypa/gh-action-pypi-publish@v1.12.4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 0da2b8fc..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Release to PyPI -on: - push: - tags: ["*"] - -jobs: - release: - runs-on: ubuntu-latest - environment: - name: release - url: https://pypi.org/p/filelock - permissions: - id-token: write - steps: - - name: Setup python to build package - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - name: Install build - run: python -m pip install build - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Build package - run: pyproject-build -s -w . -o dist - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.9.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ab84d912..0a80e156 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,30 +1,40 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v6.0.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.33.2 + hooks: + - id: check-github-workflows + args: ["--verbose"] - repo: https://github.com/codespell-project/codespell - rev: v2.3.0 + rev: v2.4.1 hooks: - id: codespell - args: ["--write-changes"] - - repo: https://github.com/tox-dev/tox-ini-fmt - rev: "1.3.1" + additional_dependencies: ["tomli>=2.2.1"] + - repo: https://github.com/tox-dev/tox-toml-fmt + rev: "v1.0.0" hooks: - - id: tox-ini-fmt - args: ["-p", "fix"] + - id: tox-toml-fmt - repo: https://github.com/tox-dev/pyproject-fmt - rev: "2.1.3" + rev: "v2.6.0" hooks: - id: pyproject-fmt - additional_dependencies: ["tox>=4.12.1"] - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.4.9" + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "v0.12.8" hooks: - id: ruff-format - id: ruff args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"] + - repo: https://github.com/rbubley/mirrors-prettier + rev: "v3.6.2" # Use the sha / tag you want to point at + hooks: + - id: prettier + additional_dependencies: + - prettier@3.6.2 + - "@prettier/plugin-xml@3.4.2" - repo: meta hooks: - id: check-hooks-apply diff --git a/.readthedocs.yml b/.readthedocs.yml index ab301130..af60a61c 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,15 +1,10 @@ version: 2 build: - os: ubuntu-22.04 + os: ubuntu-lts-latest tools: python: "3" -python: - install: - - method: pip - path: . - extra_requirements: - - docs -sphinx: - builder: html - configuration: docs/conf.py - fail_on_warning: true + commands: + - pip install uv + - uv venv + - uv pip install tox-uv + - .venv/bin/tox run -e docs -- diff --git a/README.md b/README.md index 0631c88e..081c7b73 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,6 @@ status](https://readthedocs.org/projects/py-filelock/badge/?version=latest)](htt [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Downloads](https://static.pepy.tech/badge/filelock/month)](https://pepy.tech/project/filelock) -[![check](https://github.com/tox-dev/py-filelock/actions/workflows/check.yml/badge.svg)](https://github.com/tox-dev/py-filelock/actions/workflows/check.yml) +[![check](https://github.com/tox-dev/py-filelock/actions/workflows/check.yaml/badge.svg)](https://github.com/tox-dev/py-filelock/actions/workflows/check.yaml) For more information checkout the [official documentation](https://py-filelock.readthedocs.io/en/latest/index.html). diff --git a/docs/index.rst b/docs/index.rst index 7995f760..57c6bd89 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -57,7 +57,7 @@ working directory is currently used. To do so, create a :class:`FileLock =0.4", - "hatchling>=1.18", + "hatch-vcs>=0.5", + "hatchling>=1.27", ] [project] @@ -20,7 +20,7 @@ license = "Unlicense" maintainers = [ { name = "BernΓ‘t GΓ‘bor", email = "gaborjbernat@gmail.com" }, ] -requires-python = ">=3.8" +requires-python = ">=3.9" classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", @@ -28,11 +28,11 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Internet", "Topic :: Software Development :: Libraries", "Topic :: System", @@ -40,30 +40,55 @@ classifiers = [ dynamic = [ "version", ] -optional-dependencies.docs = [ - "furo>=2023.9.10", - "sphinx>=7.2.6", - "sphinx-autodoc-typehints!=1.23.4,>=1.25.2", -] -optional-dependencies.testing = [ - "covdefaults>=2.3", - "coverage>=7.3.2", - "diff-cover>=8.0.1", - "pytest>=7.4.3", - "pytest-asyncio>=0.21", - "pytest-cov>=4.1", - "pytest-mock>=3.12", - "pytest-timeout>=2.2", - "virtualenv>=20.26.2", -] -optional-dependencies.typing = [ - "typing-extensions>=4.8; python_version<'3.11'", -] urls.Documentation = "https://py-filelock.readthedocs.io" urls.Homepage = "https://github.com/tox-dev/py-filelock" urls.Source = "https://github.com/tox-dev/py-filelock" urls.Tracker = "https://github.com/tox-dev/py-filelock/issues" +[dependency-groups] +dev = [ + { include-group = "coverage" }, + { include-group = "docs" }, + { include-group = "fix" }, + { include-group = "pkg-meta" }, + { include-group = "test" }, + { include-group = "type" }, +] + +test = [ + "covdefaults>=2.3", + "diff-cover>=9.6", + "pytest>=8.4.1", + "pytest-asyncio>=1.1", + "pytest-cov>=6.2.1", + "pytest-mock>=3.14.1", + "pytest-timeout>=2.4", + "virtualenv>=20.33.1", +] +type = [ + "mypy>=1.17.1", + "typing-extensions>=4.14.1; python_version<'3.11'", + { include-group = "test" }, +] +docs = [ + "furo>=2025.7.19", + "sphinx>=8.2.3", + "sphinx-autodoc-typehints>=3.2", +] +fix = [ + "pre-commit-uv>=4.1.4", +] +pkg-meta = [ + "check-wheel-contents>=0.6.3", + "twine>=6.1", + "uv>=0.8.5", +] +coverage = [ + "covdefaults>=2.3", + "coverage[toml]>=7.10.2", + "diff-cover>=9.6", +] + [tool.hatch] build.hooks.vcs.version-file = "src/filelock/version.py" build.targets.sdist.include = [ @@ -74,7 +99,6 @@ build.targets.sdist.include = [ version.source = "vcs" [tool.ruff] -target-version = "py38" line-length = 120 format.preview = true format.docstring-code-line-length = 100 @@ -83,7 +107,6 @@ lint.select = [ "ALL", ] lint.ignore = [ - "ANN101", # Missing type annotation for `self` in method "COM812", # Conflict with formatter "CPY", # No copyright statements "D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible @@ -91,15 +114,16 @@ lint.ignore = [ "D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible "D301", # Use `r"""` if any backslashes in a docstring "D401", # First line of docstring should be in imperative mood + "DOC", # no support yet "ISC001", # Conflict with formatter "S104", # Possible binding to all interface ] lint.per-file-ignores."tests/**/*.py" = [ "D", # don"t care about documentation in tests - "FBT", # don"t care about booleans as positional arguments in tests + "FBT", # don't care about booleans as positional arguments in tests "INP001", # no implicit namespace "PLR2004", # Magic value used in comparison, consider replacing with a constant variable - "S101", # asserts allowed in tests... + "S101", # asserts allowed in tests "S603", # `subprocess` call: check for execution of untrusted input ] lint.isort = { known-first-party = [ @@ -115,6 +139,21 @@ count = true quiet-level = 3 ignore-words-list = "master" +[tool.pyproject-fmt] +max_supported_python = "3.13" + +[tool.pytest.ini_options] +asyncio_default_fixture_loop_scope = "session" +testpaths = [ + "tests", +] +verbosity_assertions = 2 +filterwarnings = [ + "error", + "ignore:unclosed database in None: else: # pragma: win32 no cover try: import fcntl - except ImportError: + + _ = (fcntl.flock, fcntl.LOCK_EX, fcntl.LOCK_NB, fcntl.LOCK_UN) + except (ImportError, AttributeError): pass else: has_fcntl = True @@ -56,7 +58,7 @@ def _release(self) -> None: # Do not remove the lockfile: # https://github.com/tox-dev/py-filelock/issues/31 # https://stackoverflow.com/questions/17708885/flock-removing-locked-file-without-race-condition - fd = cast(int, self._context.lock_file_fd) + fd = cast("int", self._context.lock_file_fd) self._context.lock_file_fd = None fcntl.flock(fd, fcntl.LOCK_UN) os.close(fd) diff --git a/src/filelock/_windows.py b/src/filelock/_windows.py index 8db55dcb..348251d1 100644 --- a/src/filelock/_windows.py +++ b/src/filelock/_windows.py @@ -40,7 +40,7 @@ def _acquire(self) -> None: self._context.lock_file_fd = fd def _release(self) -> None: - fd = cast(int, self._context.lock_file_fd) + fd = cast("int", self._context.lock_file_fd) self._context.lock_file_fd = None msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) os.close(fd) diff --git a/src/filelock/asyncio.py b/src/filelock/asyncio.py index f5848c89..22ae122d 100644 --- a/src/filelock/asyncio.py +++ b/src/filelock/asyncio.py @@ -8,6 +8,7 @@ import os import time from dataclasses import dataclass +from inspect import iscoroutinefunction from threading import local from typing import TYPE_CHECKING, Any, Callable, NoReturn, cast @@ -95,7 +96,7 @@ def __call__( # type: ignore[override] # noqa: PLR0913 run_in_executor=run_in_executor, executor=executor, ) - return cast(BaseAsyncFileLock, instance) + return cast("BaseAsyncFileLock", instance) class BaseAsyncFileLock(BaseFileLock, metaclass=AsyncFileLockMeta): @@ -265,7 +266,7 @@ async def release(self, force: bool = False) -> None: # type: ignore[override] _LOGGER.debug("Lock %s released on %s", lock_id, lock_filename) async def _run_internal_method(self, method: Callable[[], Any]) -> None: - if asyncio.iscoroutinefunction(method): + if iscoroutinefunction(method): await method() elif self.run_in_executor: loop = self.loop or asyncio.get_running_loop() diff --git a/tests/test_async_filelock.py b/tests/test_async_filelock.py index e0b150c1..05062152 100644 --- a/tests/test_async_filelock.py +++ b/tests/test_async_filelock.py @@ -11,7 +11,7 @@ @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.parametrize("path_type", [str, PurePath, Path]) @pytest.mark.parametrize("filename", ["a", "new/b", "new2/new3/c"]) -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_simple( lock_type: type[BaseAsyncFileLock], path_type: type[str | Path], @@ -43,7 +43,7 @@ async def test_simple( @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.parametrize("path_type", [str, PurePath, Path]) @pytest.mark.parametrize("filename", ["a", "new/b", "new2/new3/c"]) -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_acquire( lock_type: type[BaseAsyncFileLock], path_type: type[str | Path], @@ -73,7 +73,7 @@ async def test_acquire( @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_non_blocking(lock_type: type[BaseAsyncFileLock], tmp_path: Path) -> None: # raises Timeout error when the lock cannot be acquired lock_path = tmp_path / "a" @@ -91,32 +91,32 @@ async def test_non_blocking(lock_type: type[BaseAsyncFileLock], tmp_path: Path) assert not lock_5.is_locked # try to acquire lock 2 - with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): + with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): await lock_2.acquire(blocking=False) assert not lock_2.is_locked assert lock_1.is_locked # try to acquire pre-parametrized `blocking=False` lock 3 with `acquire` - with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): + with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): await lock_3.acquire() assert not lock_3.is_locked assert lock_1.is_locked # try to acquire pre-parametrized `blocking=False` lock 3 with context manager - with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): + with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): async with lock_3: pass assert not lock_3.is_locked assert lock_1.is_locked # try to acquire pre-parametrized `timeout=0` lock 4 with `acquire` - with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): + with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): await lock_4.acquire() assert not lock_4.is_locked assert lock_1.is_locked # try to acquire pre-parametrized `timeout=0` lock 4 with context manager - with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): + with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): async with lock_4: pass assert not lock_4.is_locked @@ -124,13 +124,13 @@ async def test_non_blocking(lock_type: type[BaseAsyncFileLock], tmp_path: Path) # blocking precedence over timeout # try to acquire pre-parametrized `timeout=-1,blocking=False` lock 5 with `acquire` - with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): + with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): await lock_5.acquire() assert not lock_5.is_locked assert lock_1.is_locked # try to acquire pre-parametrized `timeout=-1,blocking=False` lock 5 with context manager - with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): + with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): async with lock_5: pass assert not lock_5.is_locked @@ -147,7 +147,7 @@ async def test_non_blocking(lock_type: type[BaseAsyncFileLock], tmp_path: Path) @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.parametrize("thread_local", [True, False]) -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_non_executor(lock_type: type[BaseAsyncFileLock], thread_local: bool, tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path), thread_local=thread_local, run_in_executor=False) @@ -157,7 +157,7 @@ async def test_non_executor(lock_type: type[BaseAsyncFileLock], thread_local: bo assert not lock.is_locked -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_coroutine_function(tmp_path: Path) -> None: acquired = released = False @@ -179,3 +179,92 @@ async def _release(self) -> None: # type: ignore[override] await lock.release() assert acquired assert released + + +@pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) +@pytest.mark.asyncio +async def test_wait_message_logged( + lock_type: type[BaseAsyncFileLock], tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.DEBUG) + lock_path = tmp_path / "a" + first_lock = lock_type(str(lock_path)) + second_lock = lock_type(str(lock_path), timeout=0.2) + + # Hold the lock so second_lock has to wait + await first_lock.acquire() + with pytest.raises(Timeout): + await second_lock.acquire() + assert any("waiting" in msg for msg in caplog.messages) + + +@pytest.mark.parametrize("lock_type", [AsyncSoftFileLock, AsyncFileLock]) +@pytest.mark.asyncio +async def test_attempting_to_acquire_branch( + lock_type: type[BaseAsyncFileLock], tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.DEBUG) + + lock = lock_type(str(tmp_path / "a")) + await lock.acquire() + assert any("Attempting to acquire lock" in m for m in caplog.messages) + await lock.release() + + +@pytest.mark.asyncio +async def test_thread_local_run_in_executor(tmp_path: Path) -> None: # noqa: RUF029 + with pytest.raises(ValueError, match="run_in_executor is not supported when thread_local is True"): + AsyncSoftFileLock(str(tmp_path / "a"), thread_local=True, run_in_executor=True) + + +@pytest.mark.parametrize("lock_type", [AsyncSoftFileLock, AsyncFileLock]) +@pytest.mark.asyncio +async def test_attempting_to_acquire( + lock_type: type[BaseAsyncFileLock], tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.DEBUG) + lock = lock_type(str(tmp_path / "a.lock"), run_in_executor=False) + await lock.acquire(timeout=0.1) + assert any("Attempting to acquire lock" in m for m in caplog.messages) + await lock.release() + + +@pytest.mark.parametrize("lock_type", [AsyncSoftFileLock, AsyncFileLock]) +@pytest.mark.asyncio +async def test_attempting_to_release( + lock_type: type[BaseAsyncFileLock], tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.DEBUG) + lock = lock_type(str(tmp_path / "a.lock"), run_in_executor=False) + + await lock.acquire(timeout=0.1) # lock_counter = 1, is_locked = True + await lock.acquire(timeout=0.1) # lock_counter = 2 (reentrant) + await lock.release(force=True) + + assert any("Attempting to release lock" in m for m in caplog.messages) + assert any("released" in m for m in caplog.messages) + + +@pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) +@pytest.mark.asyncio +async def test_release_early_exit_when_unlocked(lock_type: type[BaseAsyncFileLock], tmp_path: Path) -> None: + lock = lock_type(str(tmp_path / "a.lock"), run_in_executor=False) + assert not lock.is_locked + await lock.release() + assert not lock.is_locked + + +@pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) +@pytest.mark.asyncio +async def test_release_nonzero_counter_exit( + lock_type: type[BaseAsyncFileLock], tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.DEBUG) + lock = lock_type(str(tmp_path / "a.lock"), run_in_executor=False) + await lock.acquire() + await lock.acquire() + await lock.release() # counter goes 2β†’1 + assert lock.lock_counter == 1 + assert lock.is_locked + assert not any("Attempting to release" in m for m in caplog.messages) + await lock.release() diff --git a/tests/test_filelock.py b/tests/test_filelock.py index 6c63255d..91f7682e 100644 --- a/tests/test_filelock.py +++ b/tests/test_filelock.py @@ -12,7 +12,7 @@ from pathlib import Path, PurePath from stat import S_IWGRP, S_IWOTH, S_IWUSR, filemode from types import TracebackType -from typing import TYPE_CHECKING, Any, Callable, Iterator, Tuple, Type, Union +from typing import TYPE_CHECKING, Any, Callable, Union from uuid import uuid4 from weakref import WeakValueDictionary @@ -21,6 +21,8 @@ from filelock import BaseFileLock, FileLock, SoftFileLock, Timeout, UnixFileLock, WindowsFileLock if TYPE_CHECKING: + from collections.abc import Iterator + from pytest_mock import MockerFixture @@ -63,7 +65,7 @@ def make_ro(path: Path) -> Iterator[None]: path.chmod(path.stat().st_mode | write) -@pytest.fixture() +@pytest.fixture def tmp_path_ro(tmp_path: Path) -> Iterator[Path]: with make_ro(tmp_path): yield tmp_path @@ -81,7 +83,7 @@ def test_ro_folder(lock_type: type[BaseFileLock], tmp_path_ro: Path) -> None: lock.acquire() -@pytest.fixture() +@pytest.fixture def tmp_file_ro(tmp_path: Path) -> Iterator[Path]: filename = tmp_path / "a" filename.write_text("") @@ -218,7 +220,7 @@ def test_nested_contruct(lock_type: type[BaseFileLock], tmp_path: Path) -> None: assert not lock_1.is_locked -_ExcInfoType = Union[Tuple[Type[BaseException], BaseException, TracebackType], Tuple[None, None, None]] +_ExcInfoType = Union[tuple[type[BaseException], BaseException, TracebackType], tuple[None, None, None]] class ExThread(threading.Thread): @@ -304,7 +306,7 @@ def test_timeout(lock_type: type[BaseFileLock], tmp_path: Path) -> None: assert not lock_2.is_locked # try to acquire lock 2 - with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): + with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): lock_2.acquire(timeout=0.1) assert not lock_2.is_locked assert lock_1.is_locked @@ -333,44 +335,44 @@ def test_non_blocking(lock_type: type[BaseFileLock], tmp_path: Path) -> None: assert not lock_5.is_locked # try to acquire lock 2 - with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): + with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): lock_2.acquire(blocking=False) assert not lock_2.is_locked assert lock_1.is_locked # try to acquire pre-parametrized `blocking=False` lock 3 with `acquire` - with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): + with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): lock_3.acquire() assert not lock_3.is_locked assert lock_1.is_locked # try to acquire pre-parametrized `blocking=False` lock 3 with context manager - with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."), lock_3: + with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."), lock_3: pass assert not lock_3.is_locked assert lock_1.is_locked # try to acquire pre-parametrized `timeout=0` lock 4 with `acquire` - with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): + with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): lock_4.acquire() assert not lock_4.is_locked assert lock_1.is_locked # try to acquire pre-parametrized `timeout=0` lock 4 with context manager - with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."), lock_4: + with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."), lock_4: pass assert not lock_4.is_locked assert lock_1.is_locked # blocking precedence over timeout # try to acquire pre-parametrized `timeout=-1,blocking=False` lock 5 with `acquire` - with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): + with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): lock_5.acquire() assert not lock_5.is_locked assert lock_1.is_locked # try to acquire pre-parametrized `timeout=-1,blocking=False` lock 5 with context manager - with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."), lock_5: + with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."), lock_5: pass assert not lock_5.is_locked assert lock_1.is_locked @@ -397,7 +399,7 @@ def test_default_timeout(lock_type: type[BaseFileLock], tmp_path: Path) -> None: assert not lock_2.is_locked # try to acquire lock 2 - with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): + with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): lock_2.acquire() assert not lock_2.is_locked assert lock_1.is_locked @@ -405,7 +407,7 @@ def test_default_timeout(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_2.timeout = 0 assert lock_2.timeout == 0 - with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): + with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): lock_2.acquire() assert not lock_2.is_locked assert lock_1.is_locked @@ -459,7 +461,7 @@ def test_del(lock_type: type[BaseFileLock], tmp_path: Path) -> None: assert not lock_2.is_locked # try to acquire lock 2 - with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): + with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): lock_2.acquire(timeout=0.1) # delete lock 1 and try to acquire lock 2 again @@ -614,8 +616,8 @@ def test_soft_errors(tmp_path: Path, mocker: MockerFixture) -> None: def _check_file_read_write(txt_file: Path) -> None: for _ in range(3): uuid = str(uuid4()) - txt_file.write_text(uuid) - assert txt_file.read_text() == uuid + txt_file.write_text(uuid, encoding="utf-8") + assert txt_file.read_text(encoding="utf-8") == uuid @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @@ -680,7 +682,7 @@ def test_lock_can_be_non_thread_local( def test_subclass_compatibility(tmp_path: Path) -> None: class MyFileLock(FileLock): - def __init__( # noqa: PLR0913 Too many arguments to function call (6 > 5) + def __init__( self, lock_file: str | os.PathLike[str], timeout: float = -1, @@ -689,14 +691,15 @@ def __init__( # noqa: PLR0913 Too many arguments to function call (6 > 5) my_param: int = 0, **kwargs: dict[str, Any], # noqa: ARG002 ) -> None: - super().__init__(lock_file, timeout, mode, thread_local, blocking=True, is_singleton=True) + super().__init__(lock_file, timeout, mode, thread_local, is_singleton=True) + self.blocking = True self.my_param = my_param lock_path = tmp_path / "a" MyFileLock(str(lock_path), my_param=1) class MySoftFileLock(SoftFileLock): - def __init__( # noqa: PLR0913 Too many arguments to function call (6 > 5) + def __init__( self, lock_file: str | os.PathLike[str], timeout: float = -1, @@ -812,3 +815,24 @@ def __init__(self, file_path: str) -> None: lock_path = tmp_path / "a" lock = FilePathLock(str(lock_path)) assert lock.lock_file == str(lock_path) + ".lock" + + +@pytest.mark.parametrize( + ("lock_type", "expected_exc"), + [ + (SoftFileLock, TimeoutError), + (FileLock, TimeoutError) if sys.platform == "win32" else (FileLock, PermissionError), + ], +) +def test_mtime_zero_exit_branch( + lock_type: type[BaseFileLock], expected_exc: type[BaseException], tmp_path: Path +) -> None: + p = tmp_path / "z.lock" + p.touch() + Path(p).chmod(0o444) + os.utime(p, (0, 0)) + + lock = lock_type(str(p)) + + with pytest.raises(expected_exc): + lock.acquire(timeout=0) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index a7520f88..00000000 --- a/tox.ini +++ /dev/null @@ -1,110 +0,0 @@ -[tox] -requires = - tox>=4.2 -env_list = - fix - py312 - py311 - py310 - py39 - py38 - py37 - type - coverage - docs - readme -skip_missing_interpreters = true - -[testenv] -description = run tests with {basepython} -package = wheel -wheel_build_env = .pkg -extras = - testing -pass_env = - PYTEST_ADDOPTS -set_env = - COVERAGE_FILE = {toxworkdir}{/}.coverage.{envname} -commands = - pytest {tty:--color=yes} {posargs: \ - --junitxml {toxworkdir}{/}junit.{envname}.xml --cov {envsitepackagesdir}{/}filelock --cov {toxinidir}{/}tests \ - --cov-config=pyproject.toml --no-cov-on-fail --cov-report term-missing:skip-covered --cov-context=test \ - --cov-report html:{envtmpdir}{/}htmlcov --cov-report xml:{toxworkdir}{/}coverage.{envname}.xml \ - tests - diff-cover --compare-branch {env:DIFF_AGAINST:origin/main} {toxworkdir}{/}coverage.{envname}.xml} - -[testenv:fix] -description = format the code base to adhere to our styles, and complain about what we cannot do automatically -base_python = python3.10 -skip_install = true -deps = - pre-commit>=3.5 -commands = - pre-commit run --all-files --show-diff-on-failure - python -c 'import pathlib; print("hint: run \{\} install to add checks as pre-commit hook".format(pathlib.Path(r"{envdir}") / "bin" / "pre-commit"))' - -[testenv:type] -description = run type check on code base -deps = - mypy==1.7.1 -set_env = - {tty:MYPY_FORCE_COLOR = 1} -commands = - mypy --strict src/filelock - mypy --strict tests - -[testenv:coverage] -description = combine coverage files and generate diff (against DIFF_AGAINST defaulting to origin/main) -skip_install = true -deps = - covdefaults>=2.3 - coverage[toml]>=7.3.2 - diff-cover>=8.0.1 -extras = -parallel_show_output = true -pass_env = - DIFF_AGAINST -set_env = - COVERAGE_FILE = {toxworkdir}/.coverage -commands = - coverage combine - coverage report --skip-covered --show-missing - coverage xml -o {toxworkdir}/coverage.xml - coverage html -d {toxworkdir}/htmlcov - diff-cover --compare-branch {env:DIFF_AGAINST:origin/main} {toxworkdir}/coverage.xml -depends = - py311 - py310 - py39 - py38 - py37 - -[testenv:docs] -description = build documentation -extras = - docs -commands = - sphinx-build -d "{envtmpdir}{/}doctree" docs "{toxworkdir}{/}docs_out" --color -b html -W {posargs} - python -c 'print(r"documentation available under file://{toxworkdir}{/}docs_out{/}index.html")' - -[testenv:readme] -description = check that the long description is valid (need for PyPI) -skip_install = true -deps = - build[virtualenv]>=1.0.3 - twine>=4.0.2 -extras = -commands = - pyproject-build -o {envtmpdir} --wheel --sdist . - twine check {envtmpdir}/* - -[testenv:dev] -description = generate a DEV environment -package = editable -extras = - docs - testing -commands = - python -m pip list --format=columns - python -c 'import sys; print(sys.executable)' -uv_seed = true diff --git a/tox.toml b/tox.toml new file mode 100644 index 00000000..ad5d3a35 --- /dev/null +++ b/tox.toml @@ -0,0 +1,148 @@ +requires = [ "tox>=4.28.4" ] +env_list = [ "fix", "3.14t", "3.14", "3.13", "3.12", "3.11", "3.10", "3.9", "coverage", "type", "docs", "pkg_meta" ] +skip_missing_interpreters = true + +[env_run_base] +description = "run the tests with pytest under {env_name}" +package = "wheel" +wheel_build_env = ".pkg" +dependency_groups = [ "test" ] +pass_env = [ "PYTEST_*", "SSL_CERT_FILE" ] +set_env.COVERAGE_FILE = { replace = "env", name = "COVERAGE_FILE", default = "{work_dir}{/}.coverage.{env_name}" } +commands = [ + [ + "pytest", + "--durations", + "10", + "--junitxml", + "{env_tmp_dir}{/}junit.xml", + { replace = "posargs", extend = true, default = [ + "--no-cov-on-fail", + "--cov", + "{env_site_packages_dir}{/}filelock", + "--cov", + "{tox_root}{/}tests", + "--cov-config", + "{tox_root}{/}pyproject.toml", + "--cov-context", + "test", + "--cov-report", + "term-missing:skip-covered", + "--cov-report", + "html:{env_tmp_dir}{/}htmlcov", + "--cov-report", + "xml:{env_tmp_dir}{/}coverage.xml", + ] }, + + "tests", + ], + { replace = "posargs", extend = true, default = [ + [ + "diff-cover", + "--compare-branch", + { replace = "env", name = "DIFF_AGAINST", default = "origin/main" }, + "{env_tmp_dir}{/}coverage.xml", + ], + ] }, +] + +[env.coverage] +description = "combine coverage files and generate diff (against DIFF_AGAINST defaulting to origin/main)" +skip_install = true +dependency_groups = [ "coverage" ] +pass_env = [ + { replace = "ref", of = [ + "env_run_base", + "pass_env", + ], extend = true }, + "DIFF_AGAINST", +] +set_env.COVERAGE_FILE = { replace = "env", name = "COVERAGE_FILE", default = "{work_dir}{/}.coverage" } +commands = [ + [ "coverage", "combine" ], + [ "coverage", "report", "--skip-covered", "--show-missing" ], + [ "coverage", "xml", "-o", "{env_tmp_dir}{/}coverage.xml" ], + [ "coverage", "html", "-d", "{work_dir}{/}htmlcov" ], + [ + "diff-cover", + "--compare-branch", + { replace = "env", name = "DIFF_AGAINST", default = "origin/main" }, + "{env_tmp_dir}{/}coverage.xml", + ], +] +parallel_show_output = true +depends = [ "3.14t", "3.14", "3.13", "3.12", "3.11", "3.10", "3.9" ] + +[env.fix] +description = "format the code base to adhere to our styles, and complain about what we cannot do automatically" +skip_install = true +dependency_groups = [ "fix" ] +pass_env = [ + { replace = "ref", of = [ + "env_run_base", + "pass_env", + ], extend = true }, + "PROGRAMDATA", + "DISABLE_PRE_COMMIT_UV_PATCH", +] +commands = [ [ "pre-commit", "run", "--all-files", "--show-diff-on-failure", { replace = "posargs", extend = true } ] ] + +[env.type] +description = "run type check on code base" +dependency_groups = [ "type" ] +commands = [ [ "mypy", "src{/}filelock" ], [ "mypy", "tests" ] ] + +[env.docs] +description = "build documentation" +dependency_groups = [ "docs" ] +commands = [ + [ + "sphinx-build", + "-d", + "{env_tmp_dir}{/}docs_tree", + "docs", + "{env:READTHEDOCS_OUTPUT:{work_dir}{/}docs_out}/html", + "--color", + "-b", + "html", + { replace = "posargs", default = [ ], extend = true }, + "-W", + ], + [ + "python", + "-c", + 'print(r"documentation available under file://{work_dir}{/}docs_out{/}index.html")', + ], +] + +[env.pkg_meta] +description = "check that the long description is valid" +skip_install = true +dependency_groups = [ "pkg_meta" ] +commands = [ + [ + "uv", + "build", + "--sdist", + "--wheel", + "--out-dir", + "{env_tmp_dir}", + ".", + ], + [ + "twine", + "check", + "{env_tmp_dir}{/}*", + ], + [ + "check-wheel-contents", + "--no-config", + "{env_tmp_dir}", + ], +] + +[env.dev] +description = "dev environment with all deps at {envdir}" +package = "editable" +dependency_groups = [ "dev" ] +commands = [ [ "uv", "pip", "list", "--format=columns" ], [ "python", "-c", 'print(r"{env_python}")' ] ]