filelock-4.2.8/tox.toml0000644000000000000000000001572715272561277012004 0ustar00requires = [ "tox>=4.64.7" ] env_list = [ "3.15", "3.14", "3.13", "3.12", "3.11", "3.14t", "3.15t", "coverage", "docs", "fix", "pkg_meta", "type" ] skip_missing_interpreters = true [env_run_base] description = "run the tests with pytest under {env_name}" default_base_python = [ "3.15" ] 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}" } # tasks holds the coverage_pragmas plugin, which coverage imports by name in this process and in the subprocesses it # patches, so it has to resolve from the environment rather than from pytest's sys.path. set_env.PYTHONPATH = "{tox_root}{/}tasks" # No --cov-context: coverage refuses its sys.monitoring core when contexts are configured, because that core does not # implement them, but pytest-cov switches contexts behind that guard. The unsupported pair silently drops lines the # suite ran, and it cost BaseAsyncFileLock.__del__'s no-loop path a flaky 99.98% on the 3.14 jobs. commands = [ [ "pytest", "--durations", "10", "--junitxml", "{env_tmp_dir}{/}junit.xml", { replace = "posargs", default = [ "--no-cov-on-fail", "--cov", "{env_site_packages_dir}{/}filelock", "--cov", "{tox_root}{/}tests", "--cov-config", "{tox_root}{/}pyproject.toml", "--cov-report", "term-missing:skip-covered", "--cov-report", "html:{env_tmp_dir}{/}htmlcov", "--cov-report", "xml:{env_tmp_dir}{/}coverage.xml", ], extend = true }, "tests", ], { replace = "posargs", default = [ [ "diff-cover", "--compare-branch", { replace = "env", name = "DIFF_AGAINST", default = "origin/main" }, "{env_tmp_dir}{/}coverage.xml", ], ], extend = true }, ] [env_pkg_base] default_base_python = [ "3.15" ] [env."3.15"] # Set COVERAGE_FILE again: overriding set_env here drops the value inherited from env_run_base, and without it # coverage writes .coverage in the repo root instead of {work_dir}/.coverage.3.15, so the CI rename cannot find it. set_env.COVERAGE_FILE = { replace = "env", name = "COVERAGE_FILE", default = "{work_dir}{/}.coverage.{env_name}" } set_env.FILELOCK_OLD_CLIENT_PATH = "{env_tmp_dir}{/}filelock-3.20.0" set_env.PYTHONPATH = "{tox_root}{/}tasks" commands_pre = [ [ "uv", "pip", "install", "--target", "{env_tmp_dir}{/}filelock-3.20.0", "--no-deps", "--reinstall", "filelock==3.20.0", ], ] allowlist_externals = [ "uv" ] [env.coverage] description = "combine coverage files and generate diff (against DIFF_AGAINST defaulting to origin/main)" skip_install = true dependency_groups = [ "coverage" ] parallel_show_output = true 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" } # Exclusions are applied when the report is rendered, so the reporting env needs the plugin too. set_env.PYTHONPATH = "{tox_root}{/}tasks" 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", ], ] depends = [ "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15", "3.15t" ] [env.docs] description = "build documentation" dependency_groups = [ "docs", "release" ] 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{/}html{/}index.html")', ], ] [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 }, "DISABLE_PRE_COMMIT_UV_PATCH", "PROGRAMDATA", ] commands = [ [ "pre-commit", "run", "--all-files", "--show-diff-on-failure", { replace = "posargs", extend = true } ] ] [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.type] description = "run type check on code base" dependency_groups = [ "type" ] commands = [ [ "ty", "check", "--output-format", "concise", "--error-on-warning", "." ] ] [env.bench] description = "measure the performance matrix and print it as a report (a baseline, not a gate)" package = "wheel" wheel_build_env = ".pkg" commands = [ [ "python", "tasks{/}benchmark.py" ] ] [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}")' ] ] # tox invents an env on the fly only for a py or pypy factor, so GraalPy has to be declared to be selectable at all. It # stays out of env_list because, like PyPy, it is a CI-only check that runs unmeasured and feeds no coverage data: the # 100% gate remains CPython's. Its job is to keep the capability probes the suite gates on honest on a second runtime. [env."graalpy3.13"] description = "run the tests with pytest under {env_name}, unmeasured" # GraalPy can run a queued SIGALRM callback after pytest-timeout cancels its timer, interrupting a later test. # GraalPy exceeded the default timeout while creating tmp_path late in the full suite. set_env.COVERAGE_FILE = { replace = "env", name = "COVERAGE_FILE", default = "{work_dir}{/}.coverage.{env_name}" } set_env.PYTHONPATH = "{tox_root}{/}tasks" set_env.PYTEST_ADDOPTS = "{env:PYTEST_ADDOPTS:} --timeout-method=thread --timeout=60" # GraalPy 25.4.4 throws a NullPointerException from findBytecodeIndex, killing the thread, when a signal handler or # finalizer runs while a generator or coroutine executes an OSR-compiled loop. The option has to come through # GRAAL_PYTHON_VM_ARGS: GRAAL_PYTHON_ARGS lands after the script as program arguments. Set here rather than in the # workflow, since tox forwards only the variables in pass_env. set_env.GRAAL_PYTHON_VM_ARGS = "--experimental-options --engine.OSR=false" [env.release] description = "preview pending changelog fragments without selecting a release version" dependency_groups = [ "release" ] commands = [ [ "towncrier", "build", "--draft" ] ] filelock-4.2.8/docs/api.rst0000644000000000000000000000127315272561277012517 0ustar00############### API Reference ############### This section documents all public classes, exceptions, and attributes. For usage examples and task-oriented guidance, see :doc:`tutorials` and :doc:`how-to`. :class:`~filelock.FileLock` and :class:`~filelock.AsyncFileLock` are platform aliases: at import time they resolve to :class:`~filelock.UnixFileLock` or :class:`~filelock.WindowsFileLock` (and their async peers), or to the soft backends on a build without ``fcntl``. The shared ``acquire`` / ``release`` / ``timeout`` interface they expose lives on :class:`~filelock.BaseFileLock` and :class:`~filelock.BaseAsyncFileLock` below. .. automodule:: filelock :members: :show-inheritance: filelock-4.2.8/docs/changelog.rst0000644000000000000000000025442515272561277013706 0ustar00########### Changelog ########### .. towncrier-draft-entries:: Unreleased .. towncrier release notes start .. _release-4-2-8: ******************** 4.2.8 (2026-11-04) ******************** .. _release-4-2-8-documentation: Documentation ============= - Clarify the platform-specific locking primitive and range used by descriptor locks, and correct the guidance on interoperating with byte-range lock protocols such as conda's repodata lock (:pr:`905`) by :user:`boyfeiyu`. .. _release-4-2-7: ******************** 4.2.7 (2026-11-02) ******************** .. _release-4-2-7-fixed: Fixed ===== - A :class:`~filelock.SoftFileLease` reclaims a dead holder's lease marker even when that holder published another ``lease_duration``. It used to raise :class:`~filelock.LeaseSettingsMismatch` on every attempt instead, so after a holder crashed and its peers restarted with a changed duration the lock stayed wedged until :meth:`force_break() `. A live holder with another duration still raises (:pr:`900`) by :user:`dxbjavid` and :user:`gaborbernat`. .. _release-4-2-6: ******************** 4.2.6 (2026-10-31) ******************** .. _release-4-2-6-fixed: Fixed ===== - Fix a race in :class:`~filelock.SoftFileLock`, :class:`~filelock.MarkerSoftFileLock` and :class:`~filelock.SoftFileLease` when three or more contenders reclaim a dead marker. The race could strand a live holder's marker under a break name, leaving the lock unusable or admitting a second holder. Contenders now recheck the marker before exchanging names and unwind nested exchanges in order. If the filesystem refuses a return exchange, the contender leaves its marker at the lock path for later recovery (:pr:`899`) by :user:`dxbjavid` and :user:`gaborbernat`. .. _release-4-2-5: ******************** 4.2.5 (2026-10-30) ******************** .. _release-4-2-5-fixed: Fixed ===== - :meth:`~filelock.BaseFileLock.acquire` and :meth:`~filelock.BaseAsyncFileLock.acquire` raise :class:`TypeError` for a *blocking* that is not a :class:`bool`, as the constructor and the ``blocking`` setter already do. A truthy value such as the string ``"false"`` used to count as ``True``, so a call meant as a single attempt waited for the instance's ``timeout``, and without a limit at the default ``-1`` (:pr:`870`) by :user:`simpleqt` and :user:`gaborbernat`. - A :class:`~filelock.SoftFileLock` acquisition whose first read of its new marker's identity failed reads it again before rolling back. On Windows the rollback could otherwise not recognize the marker it had created, and the empty marker it left blocked the path for every later acquisition, since no contender breaks an empty marker (:pr:`869`) by :user:`simpleqt` and :user:`gaborbernat`. - A ``fork()`` child of a process holding a :class:`~filelock.SoftFileLease` starts from an unheld claim. It used to inherit the parent's token, last compromise and heartbeat, so :attr:`~filelock.SoftFileLease.token` and :attr:`~filelock.SoftFileLease.compromise` reported a claim the child never held while ``is_locked`` reported it unheld (:pr:`868`) by :user:`dxbjavid` and :user:`gaborbernat`. - A nested acquire of a held :class:`~filelock.FileLock` or :class:`~filelock.SoftFileLock` no longer creates the lock file's parent directory. After a rename of that directory during the hold, the nested acquire recreated the old name, and when a file had taken the name it raised :class:`FileExistsError` and left the hold's recursion count incremented (:pr:`878`) by :user:`0x5t4l1n` and :user:`gaborbernat`. - Fix :class:`ReadWriteLock ` failing every acquire with "link directory ... was replaced while opening" in a group-writable directory whose inherited ACL, such as an NFSv4 one on ZFS, opens the link directory to the group. The lock now closes the empty link directory to its owner, and where the filesystem refuses that, such as ZFS with ``aclmode=restricted``, it names the directory to make writable by its owner only (:pr:`898`) by :user:`gaborbernat`. - Fix :class:`~filelock.SoftFileLock`, :class:`~filelock.MarkerSoftFileLock` and :class:`~filelock.SoftFileLease` raising ``OverflowError`` out of ``acquire()``, or never reclaiming a dead holder's lock, when a stranded restore claim beside the lock names a process number outside ``1..2**31-1`` or spells it in non-ASCII digits. Such a claim is now ignored, as no filelock writes one (:pr:`871`) by :user:`dxbjavid` and :user:`gaborbernat`. - Fix soft locks on Windows keeping the marker of a holder that has exited while another process still holds a handle to it, such as a parent's ``subprocess.Popen`` after ``wait()`` or ``multiprocessing.Process`` after ``join()``. Acquiring such a lock with the default ``timeout=-1`` waited forever (:pr:`874`) by :user:`nk-batman21`. - Fix the read-only reopen of a lock file this account cannot write resolving a relative path against the working directory of the moment. A ``chdir`` between the refused ``O_RDWR`` open and the reopen made the acquisition wait on a file that is not there, or probe one in another directory. The reopen now uses the path the acquisition pinned (:pr:`889`) by :user:`dxbjavid` and :user:`gaborbernat`. - Handle ``EMLINK``, the error FreeBSD reports when a no-follow open refuses a symlink, in a soft reader/writer lock's protocol tree, so heartbeat reports lost ownership there rather than a transient refresh error (:pr:`866`) by :user:`pacocartones` and :user:`gaborbernat`. - Resolving :class:`~filelock.ReadWriteLock` or :class:`~filelock.AsyncReadWriteLock` raises the :exc:`ImportError` when a module other than Python's :mod:`sqlite3` is missing, as in a frozen application that left out a filelock submodule. The names are ``None`` only without SQLite (:pr:`880`) by :user:`pintu544` and :user:`gaborbernat`. .. _release-4-2-5-documentation: Documentation ============= - The lock-probing recipe in the how-to guide probes with ``blocking=False`` instead of a tiny ``timeout`` (:pr:`873`) by :user:`tongwentao` and :user:`gaborbernat`. .. _release-4-2-5-packaging: Packaging ========= - Releases now run on the shared tox-dev/github-workflows pipeline, with a locked release toolchain and generated GitHub Release notes (:pr:`885`) by :user:`gaborbernat`. .. _release-4-2-4: ******************** 4.2.4 (2026-10-19) ******************** .. _release-4-2-3: ******************** 4.2.3 (2026-10-19) ******************** .. _release-4-2-3-fixed: Fixed ===== - Creating a lock on a Python 3.15 interpreter without ``types.LazyImportType`` no longer triggers ``AttributeError`` at exit. :pr:`861` - by :user:`gaborbernat`. - Handle ``EFTYPE`` when a no-follow open refuses a symlink in a soft reader/writer lock's protocol tree, so heartbeat reports lost ownership rather than a transient refresh error. :pr:`859` - by :user:`edsonsims`. .. _release-4-2-2: ******************** 4.2.2 (2026-10-18) ******************** .. _release-4-2-2-fixed: Fixed ===== - Fix a lock on a relative path that stayed held after a later ``with lock:`` ended, when an earlier ``acquire()`` had failed because the working directory no longer exists. That call still raises :class:`FileNotFoundError`, and it now leaves the lock's hold count unchanged. :pr:`856` - by :user:`spicysharkco`. .. _release-4-2-1: ******************** 4.2.1 (2026-10-17) ******************** .. _release-4-2-1-fixed: Fixed ===== - On a singleton wrapper, :meth:`AsyncReadWriteLock.close() ` and :meth:`AsyncSoftReadWriteLock.close() ` end only the holds that tasks acquired through that wrapper. A reader that arrives during ``close()`` gets the closed error instead of joining the ending hold. After synchronous code closes or force-releases the shared lock, the wrappers refuse to admit readers into the ended hold, and ``close()`` on such a wrapper returns. Closing one wrapper no longer ends a task's nested hold taken through another wrapper on the same path. :pr:`832` - by :user:`gaborbernat`. - :class:`~filelock.ReadWriteLock` locks again after a ``release()`` that an exception from a signal handler interrupted. The async read-write wrappers stop admitting readers into such a level. The async read-write locks forget a hold whose backend release failed after the backend let go, and they free the memory of each acquire that times out or is canceled. :class:`~filelock.AsyncFileLock` returns from ``release()`` after a few hundred acquires gave up behind a waiting one. An exception from a signal handler delivered while an :class:`~filelock.AsyncFileLock` call joins its queue no longer blocks every later call on that lock. :pr:`833` - by :user:`gaborbernat`. - Fix :class:`~filelock.ReadWriteLock` acting on ``-journal``, ``-wal`` or ``-shm`` files another process puts beside its database: a write could land in another file, a reader could get in beside a writer, and an acquire could fail. SQLite now opens the database by name only in a directory no other account can write, and through a private hard link elsewhere. On Windows the directory's access control list decides. Where hard links are unavailable, keep the database in a directory other accounts cannot write, such as one under ``%LOCALAPPDATA%``. The lock removes link directories through a checked descriptor rather than by name, reads a link directory's name only when the locking account owns it, and leaves alone a directory with other content renamed onto a link directory's name. A database whose schema takes more than a few kilobytes to load raises :class:`ValueError` instead of running past the acquire timeout. :pr:`834` - by :user:`gaborbernat`. - Fix five bugs in opening the lock file. :class:`~filelock.UnixFileLock` no longer truncates a file it did not create, such as one renamed to the lock path, and no longer applies ``mode`` to it. An ``on_acquired`` hook that writes holder metadata now truncates the file itself. A lease (Linux) or an oplock (Windows) on the lock file or a soft lock's marker no longer holds ``acquire()`` past its ``timeout`` or ``blocking=False``, and an oplock no longer holds ``release()``; :class:`~filelock.ReadWriteLock` on Windows waits for its ``timeout`` and raises :class:`~filelock.Timeout`. On Windows, a ``mode`` without group and other bits, such as ``0o600``, now creates the lock file with an owner-only permission list; the :class:`~filelock.ReadWriteLock` database gets the same. The soft locks' writability check no longer follows a symlink swapped in at the lock path. On a filesystem without ``flock``, a contender from another account now waits on a marker it cannot write instead of raising ``PermissionError``. :pr:`835` - by :user:`gaborbernat`. - Fix :class:`~filelock.SoftFileLock`, :class:`~filelock.MarkerSoftFileLock` and :class:`~filelock.SoftFileLease` removing a live holder's marker in three cases: the holder and the contender run in Linux time namespaces with different boottime offsets; only one of them can read the boot id; a process other than the owner rewrote the marker. Soft markers no longer carry group or other write bits. A process name containing a carriage return or another line separator no longer makes the stale check raise :class:`ValueError`. When ``/proc/self/ns/time`` exists but cannot be read, the stale check no longer compares start times and keeps the marker. :pr:`836` - by :user:`gaborbernat`. - The async read-write locks record their hold under a task owner, so synchronous code on the executor thread that took the hold can no longer re-enter or release it. :class:`~filelock.AsyncSoftReadWriteLock` gives back one level on release, so synchronous readers of a shared singleton keep theirs. :class:`~filelock.SoftReadWriteLock` tracks read levels per thread: ``release()`` from a thread that holds none raises :class:`RuntimeError`. After its heartbeat recorded a lost hold, it refuses new acquires until that hold is released. :pr:`838` - by :user:`gaborbernat`. - :class:`~filelock.StrictSoftFileLock` creates ``.filelock`` and its ``claims`` directory with the lock's ``mode`` and sets the sticky bit (a directory flag that lets only a file's owner remove it) on both. On the next acquisition it narrows a directory it owns that an earlier release created wider; it leaves a directory of another account as it is, so remove such leftovers by hand. It reaches ``claims`` through a descriptor opened without following symlinks; on Windows it holds both directories open and refuses a junction (a directory that points at another directory). Under a sticky parent such as ``/tmp`` it refuses a coordination directory of another account, as :class:`~filelock.SoftReadWriteLock` does for ``.rw``. One read grace now covers a whole claim scan, a failed claim cleanup no longer leaves the sentinel descriptor open, and a non-regular node at a sentinel private-record name raises :class:`~filelock.SoftFileLockProtocolError`. On Windows, contenders read claims without blocking their owner's unlink. :pr:`839` - by :user:`gaborbernat`. - Fix a lock reporting a hold the OS had dropped after a signal handler raised during :meth:`~filelock.BaseFileLock.acquire` or :meth:`~filelock.BaseFileLock.release`. A release that a signal handler cuts short after it has started now completes before the error propagates. A handler that acquires the lock its own thread is releasing now gets :class:`RuntimeError` instead of a hold that stays held. The singleton cache no longer hands out an ``is_singleton=True`` lock or a :class:`~filelock.ReadWriteLock` whose finalizer is running, which released the new holder's lock. A signal during ``os.fork()`` no longer leaves the other threads blocked in their next lock call, and a fork from another thread no longer waits for a :class:`~filelock.ReadWriteLock` acquire on a busy database. :pr:`840` - by :user:`gaborbernat`. - Fix a lock on a relative path locking a file in the directory the process changed to while the lock waited, and leaving its marker behind on release after such a change. The path resolved when an acquisition starts stays in use until the hold ends. An ``is_singleton=True`` lookup no longer returns a cached lock whose relative path now resolves to another file; a held lock still matches the file it holds. A rename of the lock's directory during a hold no longer leaves the marker behind or reports a false lease compromise, and a waiter no longer creates a second lock file in a directory that was renamed away. :pr:`841` - by :user:`gaborbernat`. - :class:`~filelock.SoftReadWriteLock` reaches its records through descriptors of ``.rw`` and ``gen`` or ``holders`` opened without following symlinks. On Windows it holds those directories open and refuses a junction (a directory that points at another directory). A tree swapped in after an earlier acquire therefore cannot redirect them. Its heartbeat rewrites a holder record only when the name still leads to the file it created, and does not block on a FIFO (a named pipe). A heartbeat that finds ``.rw`` replaced by a symlink or junction reports the hold lost instead of ending the heartbeat thread. A live lock object repeats the ownership check whenever the tree it opens is not the one it prepared. :pr:`842` - by :user:`gaborbernat`. - Fix :class:`~filelock.SoftFileLock`, :class:`~filelock.MarkerSoftFileLock` and :class:`~filelock.SoftFileLease` removing or displacing a live holder's marker in five cases. ext4 gave a freed inode number to the next marker. A stalled :class:`~filelock.FileLock` removed its ``ENOSYS`` placeholder after a peer replaced it. A failed acquire cleaned up after a successor took the path. A stale break left the lock path empty while it checked the marker it had moved. A ``lifetime`` break judged a marker by a reused inode number. On Linux and macOS a stale marker now changes hands in one atomic exchange. On other platforms without one, except Windows, including NFS and SMB, the marker stays in place and waits for ``timeout`` or ``break_lock()``. Restoring a marker no longer follows a symlink found under the break name. A holder that releases while a peer's exchange has its marker aside leaves a record no lock parses, so the put-back lands a marker that ages out. A lease heartbeat that finds another file at the lock path once, while a peer's break has its marker aside, waits for the next refresh before it reports a compromise. A contender that finds a dead breaker's marker puts back the marker that breaker left under its break name. A marker is the breaker's own only when it names the breaker's process on this host; the same process number on another host is a holder. A contender takes a stranded marker under a name of its own before it puts the marker back, so two contenders no longer reverse each other's exchange and remove the live holder's marker. A claim left behind by a failed put-back is retried on the next poll. :pr:`843` - by :user:`gaborbernat`. - Canceling an :class:`~filelock.AsyncReadWriteLock` or :class:`~filelock.AsyncSoftReadWriteLock` acquire that waits for another process now stops the wait and raises the cancellation. It used to wait until that process released, or raise :class:`~filelock.Timeout`. A ``thread_local=True`` :class:`~filelock.AsyncFileLock` that threads with their own event loops share no longer deadlocks when one thread waits for the lock while another holds it. :pr:`844` - by :user:`gaborbernat`. - :class:`~filelock.SoftReadWriteLock` refuses a commit that lands a whole retention window behind the log, so two committers stalled at adjacent generations can no longer admit a second writer. Its stale check samples the clock before it reads a holder record, so a pause between the two no longer counts as time the record stayed unchanged. :pr:`845` - by :user:`gaborbernat`. - Fix a :class:`~filelock.SoftFileLock` contender evicting an empty marker after two seconds while its owner held the lock. The owner is a ``SoftFileLock`` before 3.22, which writes nothing into its marker, or a current one still writing its record. An empty marker now reads as held, and ``timeout``, ``lifetime`` or ``break_lock()`` clears it. A contender that finds an empty marker a dead breaker left aside puts it back instead of removing it. :pr:`846` - by :user:`gaborbernat`. - Fix :class:`~filelock.ReadWriteLock` on Windows acting on ``-journal`` and ``-wal`` files another account places beside its database: an acquire could fail with :class:`sqlite3.OperationalError`, or SQLite could replay the journal and delete the file it names. The lock now reads the database directory's access control list. Where an account other than the locking account, ``SYSTEM`` and ``Administrators`` can add or remove names, SQLite opens a hard link inside a directory only those accounts can write. A filesystem without hard links raises :class:`OSError`. :pr:`854` - by :user:`gaborbernat`. - Fix ``SoftReadWriteLock`` subclasses sharing singleton instances with the base class or sibling subclasses. Each concrete class now keeps its own cache and construction guard, including after fork, while preserving cooperative class-definition keywords. :pr:`855` - by :user:`AryanRG`. .. _release-4-2-0: ******************** 4.2.0 (2026-10-14) ******************** .. _release-4-2-0-fixed: Fixed ===== - Fix :class:`~filelock.StrictSoftFileLock` raising :class:`~filelock.SoftFileLockProtocolError` on Windows when a contender read a claim whose owner was still finishing its unlink. :pr:`829` - by :user:`gaborbernat`. .. _release-4-2-0-performance: Performance =========== - ``import filelock`` is about twice as fast: lock types other than :class:`~filelock.FileLock` load on first use. :pr:`831` - by :user:`gaborbernat`. .. _release-4-1-2: ******************** 4.1.2 (2026-10-14) ******************** .. _release-4-1-2-fixed: Fixed ===== - Fix read-lock acquisition on SQLite versions older than 3.33.0 by using ``sqlite_master`` for the schema probe. :pr:`827` - by :user:`DawnofGenX`. - Raise ``IsADirectoryError`` (or ``PermissionError`` on Windows) for a directory lock path regardless of its write permissions. :pr:`822` - by :user:`feiiiiii5`. - Fix a forked child hanging at exit on PyPy when the parent forked while an :class:`~filelock.AsyncReadWriteLock` worker thread was between two operations. :pr:`828` - by :user:`gaborbernat`. - Hash all cache keys in the lock filename recipe so short path-like keys stay in the chosen directory. The example also accepts path-derived surrogate escapes and works before the cache directory exists. :pr:`824` - by :user:`xiehuanyi`. .. _release-4-1-1: ******************** 4.1.1 (2026-10-11) ******************** .. _release-4-1-1-fixed: Fixed ===== - Avoid the macOS ``/dev/fd`` lookup implicated in a kernel panic by opening SQLite through a validated hard link. The database directory must be writable and support hard links; alias creation failure raises ``OSError``. :pr:`820` - by :user:`gaborbernat`. - Ignore non-ASCII PID or start-time fields and out-of-range PIDs in ``ReadWriteLock`` hard-link directory names so forged owners cannot abort acquisition. :pr:`821` - by :user:`dxbjavid`. .. _release-4-1-0: ******************** 4.1.0 (2026-10-09) ******************** .. _release-4-1-0-added: Added ===== - Import :data:`~filelock.OwnerMode`, :data:`~filelock.CompromiseReason` and :data:`~filelock.has_fcntl` from ``filelock``. :pr:`790` - by :user:`gaborbernat`. - :class:`~filelock.AcquireReturnProxy` is generic in the lock type, so ``with lock.acquire() as held:`` types ``held`` as the lock's own class. :pr:`803` - by :user:`gaborbernat`. - A :class:`~filelock.Timeout` from a soft lock names a holder on another host or in another PID namespace, which no contender ever reclaims. :pr:`815` - by :user:`gaborbernat`. - Every option given in seconds takes a :class:`~decimal.Decimal` or :class:`~fractions.Fraction`, as a file lock's ``timeout`` already did, and a read-write lock or lease refuses a ``bool`` or string one. :pr:`815` - by :user:`gaborbernat`. .. _release-4-1-0-fixed: Fixed ===== - :attr:`~filelock.SoftFileLock.is_lock_held_by_us` and :attr:`~filelock.MarkerSoftFileLock.is_lock_held_by_us` return ``False`` for a marker an earlier process with the same PID left behind. :pr:`774` - by :user:`lprnmns`. - :class:`~filelock.SoftFileLock` removes its marker when writing it fails and closing the descriptor fails too, so the lock path no longer stays blocked. :pr:`775` - by :user:`harbinresearcher`. - :meth:`~filelock.BaseFileLock.acquire` and :meth:`~filelock.BaseAsyncFileLock.acquire` no longer wait past ``timeout`` when ``poll_interval`` is longer than the time left. :pr:`777` - by :user:`feiiiiii5`. - :class:`~filelock.SoftReadWriteLock` ignores a generation-log entry whose name is not 20 ASCII digits, which used to stop its heartbeat thread or delete real generations. :pr:`778` - by :user:`dxbjavid`. - :class:`~filelock.ReadWriteLock`, :class:`~filelock.SoftReadWriteLock` and their async wrappers treat a :data:`math.inf` timeout like ``-1`` and raise :class:`ValueError` for a blocking ``nan`` one. :pr:`779` - by :user:`darrenhuai`. - :class:`~filelock.ReadWriteLock` and :class:`~filelock.SoftReadWriteLock` accept a timeout above :data:`threading.TIMEOUT_MAX` instead of raising :class:`OverflowError`. :pr:`780` - by :user:`darrenhuai`. - :meth:`~filelock.BaseFileLock.acquire` raises :class:`ValueError` for a blocking ``nan`` timeout or a ``poll_interval`` above :data:`threading.TIMEOUT_MAX`, and treats a :data:`math.inf` timeout like ``-1``. :pr:`781` - by :user:`gaborbernat`. - :class:`~filelock.SoftFileLock` and :class:`~filelock.MarkerSoftFileLock` treat a marker cut before its final newline as malformed, so a crash-truncated record self-heals and a read mid-write no longer breaks a live lock. :pr:`782` - by :user:`gaborbernat`. - :class:`~filelock.UnixFileLock` no longer deletes another process's live marker when it falls back to a soft lock on ``ENOSYS``, and on macOS a lock path ending in a separator raises :class:`FileNotFoundError` instead of retrying. :pr:`785` - by :user:`gaborbernat`. - :class:`~filelock.SoftFileLock` and :class:`~filelock.SoftFileLease` put back a live successor's marker that a racing stale break moved aside, so a third contender no longer acquires beside the successor. :pr:`786` - by :user:`gaborbernat`. - :class:`~filelock.StrictSoftFileLock` removes every claim a failed acquire published, counts an ``EEXIST`` link that landed as published, and no longer reports ``is_locked`` after a failed claim cleanup. :pr:`787` - by :user:`gaborbernat`. - On Python 3.10 without the ``exceptiongroup`` backport, a release failure under the default ``context_error_policy`` no longer turns into :exc:`ModuleNotFoundError`; cleanup failures chain instead of grouping, and ``on_acquired`` is allowed. :pr:`788` - by :user:`gaborbernat`. - :class:`~filelock.AsyncReadWriteLock` and :class:`~filelock.AsyncSoftReadWriteLock` drop a task's hold when a caller cancels it during a release that still completes, instead of blocking every later writer. :pr:`789` - by :user:`gaborbernat`. - :class:`~filelock.SoftFileLease` releases a lease dropped while held, at garbage collection or interpreter exit, and raises :class:`ValueError` for a ``heartbeat_interval`` above :data:`threading.TIMEOUT_MAX`. :pr:`791` - by :user:`gaborbernat`. - :class:`~filelock.StrictSoftFileLock` judges an abandoned private record's age by the filesystem's clock, so a client clock ahead of an NFS server no longer aborts live publications. :pr:`792` - by :user:`gaborbernat`. - :class:`~filelock.BaseAsyncFileLock` raises :class:`RuntimeError` instead of :class:`asyncio.CancelledError` when its executor shuts down with the call still queued, and a waiter interrupted by anything else no longer wedges later calls. :pr:`793` - by :user:`gaborbernat`. - Constructing or closing a second :class:`~filelock.ReadWriteLock` on a held database no longer drops the holder's lock, so other processes stay excluded. :pr:`794` - by :user:`gaborbernat`. - :class:`~filelock.SoftReadWriteLock` no longer admits a second writer after ``.rw`` is removed under a live instance, and flushes each snapshot before publishing it so a crash cannot leave an empty one that wedges every peer. :pr:`795` - by :user:`gaborbernat`. - :class:`~filelock.SoftFileLease` reclaims a dead :class:`~filelock.MarkerSoftFileLock` owner, and expires a lease or a malformed soft marker after it stays unchanged on the contender's own monotonic clock, so clock skew cannot break it. :pr:`796` - by :user:`gaborbernat`. - A soft lock marker written in a Linux container records its PID namespace with the hostname, so a sibling container that shares the hostname and volume no longer breaks a live lock. :pr:`797` - by :user:`gaborbernat`. - :meth:`SoftReadWriteLock.close() ` makes an acquire still waiting in another thread raise :class:`RuntimeError`, where it used to take the lock on the closed instance and hold it until the process exited. :pr:`798` - by :user:`gaborbernat`. - :class:`~filelock.SoftReadWriteLock` reads the head from ``gen/HEAD``, so a client whose cached directory listing is more than 64 generations stale no longer takes an old generation for the head and forks the log. :pr:`799` - by :user:`gaborbernat`. - When the garbage collector finalizes a held :class:`~filelock.BaseAsyncFileLock`, it releases on a ``loop`` running in another thread, or in place once no loop is left; the ``lifetime`` check and path resolution now run in the executor. :pr:`800` - by :user:`gaborbernat`. - :class:`~filelock.SoftReadWriteLock` waits out a holder's own ``stale_threshold`` before evicting it, keys its singleton on the path its log sits beside, and no longer leaks ledger entries or the holder record of a failed acquire. :pr:`801` - by :user:`gaborbernat`. - :func:`~filelock.lock_descriptor` raises :class:`ValueError` for a blocking ``poll_interval`` above :data:`threading.TIMEOUT_MAX` up front, instead of :class:`OverflowError` once the descriptor is contended. :pr:`802` - by :user:`sricharanreddycheruku`. - :class:`~filelock.BaseFileLock` rejects a ``bool`` or string timeout, an out-of-range ``mode`` and a non-``bool`` ``blocking`` set at any time, rejects unknown options on a singleton cache hit, and tracks an ``on_acquired`` reacquire. :pr:`803` - by :user:`gaborbernat`. - :class:`~filelock.StrictSoftFileLock` raises :class:`~filelock.SoftFileLockProtocolError` naming a directory, symlink or exited soft-lock owner's marker at the lock path, and ``lock_path_occupant`` shows what blocks it. :pr:`804` - by :user:`gaborbernat`. - :class:`~filelock.ReadWriteLock` raises :class:`ValueError` for a WAL-mode database instead of ``disk I/O error``, and its ``/dev/fd`` fallback links beside the database, so a lock off the TMPDIR filesystem no longer fails with ``EXDEV``. :pr:`805` - by :user:`gaborbernat`. - :meth:`ReadWriteLock.release() ` from a thread that holds no read level raises :class:`RuntimeError` instead of ending another thread's read lock and letting a writer in. :pr:`806` - by :user:`gaborbernat`. - :class:`~filelock.SoftFileLockLifetimeWarning` derives from :class:`FutureWarning` instead of :class:`DeprecationWarning`, so Python shows it by default outside ``__main__``. :pr:`807` - by :user:`gaborbernat`. - :class:`~filelock.SoftFileLock` applies an explicit ``mode`` past the umask as :class:`~filelock.UnixFileLock` does, and reads a marker number spelled other than in ASCII digits, or a lease with an empty token, as malformed. :pr:`807` - by :user:`gaborbernat`. - :class:`~filelock.ReadWriteLock` raises :class:`OSError` when its database is unlinked or replaced while an acquisition waits, instead of taking a lock that no later opener sees. :pr:`808` - by :user:`gaborbernat`. - On a thread-local synchronous lock, :meth:`~filelock.BaseFileLock.release` with ``force=True`` and garbage collection release holds exited threads left, and a ``release()`` re-entered from a signal handler closes the descriptor once. :pr:`810` - by :user:`gaborbernat`. - :class:`~filelock.SoftFileLease` records a ``lease_duration`` given as a :class:`float` subclass, such as NumPy's, in a form every contender parses, so a live lease is no longer evicted as malformed. :pr:`815` - by :user:`gaborbernat`. - :class:`~filelock.StrictSoftFileLock` raises :class:`~filelock.SoftFileLockProtocolError` for a file at the lock path it cannot parse once it stays unchanged for two seconds, instead of waiting on it forever. :pr:`815` - by :user:`gaborbernat`. - A lock finalized at interpreter exit no longer hangs when a daemon thread stopped inside filelock's fork bookkeeping. :pr:`815` - by :user:`gaborbernat`. - Setting :attr:`BaseFileLock.timeout ` from a string emits a :class:`DeprecationWarning` and from a ``bool`` raises :class:`TypeError`; pass the number of seconds instead. :pr:`815` - by :user:`gaborbernat`. - Every lock raises :class:`ValueError` for a timeout too large for a :class:`float`, and a singleton accepts a timeout that waits as long as the cached one, such as ``inf`` for ``-1``. :pr:`815` - by :user:`gaborbernat`. - On macOS, :class:`~filelock.ReadWriteLock` connects through a private hard link, so cycling a lock beside a holder no longer adds an open descriptor per connection. :pr:`815` - by :user:`gaborbernat`. - :class:`~filelock.SoftFileLock` and :class:`~filelock.SoftFileLease` re-check a stale marker right before breaking it, so a marker recreated since the check is no longer moved aside for a third contender to take the path. :pr:`815` - by :user:`gaborbernat`. - An acquire a signal handler starts while its thread is still acquiring the same lock raises :class:`RuntimeError`, where it used to hang that thread or leave the lock held after the outer release. :pr:`815` - by :user:`gaborbernat`. - A :class:`~filelock.BaseAsyncFileLock` singleton the garbage collector finalizes leaves the singleton cache first, so a new lock on the path waits for its release instead of re-entering a hold that release then drops. :pr:`815` - by :user:`gaborbernat`. - A finalized thread-local :class:`~filelock.BaseAsyncFileLock` releases every thread's hold, including one on a daemon thread's event loop at interpreter exit, instead of leaving it held. :pr:`815` - by :user:`gaborbernat`. - A soft lock in the host's PID namespace no longer breaks a live marker written from a container sharing the hostname while the kernel shows its owner running in another namespace under the recorded PID and start time. :pr:`815` - by :user:`gaborbernat`. - :class:`~filelock.SoftFileLock` and its marker and lease variants no longer leave their marker behind when a signal handler interrupts a release; the retried release, or the next acquire on Windows, removes it. :pr:`815` - by :user:`gaborbernat`. - :class:`~filelock.SoftReadWriteLock` raises :class:`ValueError` for a ``heartbeat_interval`` above :data:`threading.TIMEOUT_MAX`, which killed its heartbeat thread, and an interrupted release still removes its record. :pr:`815` - by :user:`gaborbernat`. - :class:`~filelock.AsyncReadWriteLock` releases only its own hold on a singleton it shares with synchronous users, where it used to end their read transaction too. :pr:`815` - by :user:`gaborbernat`. .. _release-4-1-0-documentation: Documentation ============= - The documentation's canonical link, ``og:url`` and sitemap entries point at the versioned page on Read the Docs instead of a domain-root URL that returns 404. :pr:`813` - by :user:`gaborbernat`. .. _release-4-0-12: ********************* 4.0.12 (2026-10-05) ********************* .. _release-4-0-12-fixed: Fixed ===== - Fix read-lock acquisition on SQLite versions older than 3.33.0 by using ``sqlite_master`` for the schema probe. :pr:`769` - by :user:`Vikram12301`. .. _release-4-0-11: ********************* 4.0.11 (2026-10-05) ********************* .. _release-4-0-11-fixed: Fixed ===== - ``ReadWriteLock`` and ``AsyncReadWriteLock`` use a validated private hard link when ``/dev/fd`` has no entry for the database descriptor, supporting NetBSD's static descriptor directory beyond descriptor 63. The temporary location must share a filesystem with the database. The symlink refusal test accepts NetBSD's error wording - by :user:`gaborbernat`. .. _release-4-0-10: ********************* 4.0.10 (2026-10-03) ********************* .. _release-4-0-10-fixed: Fixed ===== - Reusing a singleton ``AsyncFileLock`` with another ``loop``, ``executor`` or ``run_in_executor``, or a singleton ``SoftFileLease`` with another ``lease_duration``, ``heartbeat_interval`` or ``on_compromise``, now raises ``ValueError`` instead of returning the lock with its original options. :pr:`765` - by :user:`kokotatan`. .. _release-4-0-9: ******************** 4.0.9 (2026-10-01) ******************** .. _release-4-0-9-fixed: Fixed ===== - ``ReadWriteLock`` and ``AsyncReadWriteLock`` close the descriptor that checks the database path once SQLite has connected, so on PyPy a dropped lock leaves no descriptor open until garbage collection runs. :pr:`763` - by :user:`gaborbernat`. .. _release-4-0-9-security: Security ======== - ``ReadWriteLock`` and ``AsyncReadWriteLock`` refuse a symlink at the database path instead of following it, so a user who can create names in a shared lock directory cannot point the lock at another file (GHSA-j8f7-rjxc-mr56) - by :user:`gaborbernat`. .. _release-4-0-8: ******************** 4.0.8 (2026-10-01) ******************** .. _release-4-0-8-fixed: Fixed ===== - ``ReadWriteLock.release()`` and ``SoftReadWriteLock.release()`` from a thread that does not hold the write lock now raise ``RuntimeError`` instead of dropping the holder's lock and letting a second writer in. :pr:`761` - by :user:`feiiiiii5`. .. _release-4-0-7: ******************** 4.0.7 (2026-09-29) ******************** .. _release-4-0-7-fixed: Fixed ===== - File locks now raise ``ValueError`` at construction when ``mode`` denies the owner read or write, such as ``mode=0o444``, instead of failing on a later acquire and staying broken until someone deletes the lock file. :pr:`760` - by :user:`gaborbernat`. .. _release-4-0-6: ******************** 4.0.6 (2026-09-28) ******************** .. _release-4-0-6-fixed: Fixed ===== - Reject negative blocking timeouts other than ``-1`` before reentrant ``ReadWriteLock`` and ``SoftReadWriteLock`` acquisition. Preserve unlimited waits and nonblocking acquisition. :pr:`756` - by :user:`feiiiiii5`. .. _release-4-0-5: ******************** 4.0.5 (2026-09-28) ******************** .. _release-4-0-5-fixed: Fixed ===== - Fix ``MarkerSoftFileLock`` acquisition and prevent contenders from evicting live protocol-2 owners after two seconds. Reclaim recognized records after owner death; preserve unknown contracts. :pr:`751` - by :user:`DawnofGenX`. - Honor instance ``timeout`` and ``blocking`` settings in sync and async ``ReadWriteLock`` acquisition, including waits between tasks on one instance. Preserve explicit per-call overrides. :pr:`752` - by :user:`DawnofGenX`. - Skip access-denial checks when the process can read mode-``0o000`` files. Keep mode-bit checks enabled for privileged processes on filesystems that support POSIX permissions. :pr:`753` - by :user:`pasmud`. - Skip vanished ``StrictSoftFileLock`` claims after a read-permission retry expires. Recheck the directory before raising a protocol error so concurrent removal does not turn a stale claim listing into an acquisition failure. :pr:`754` - by :user:`januththedev`. - Reject negative timeouts other than ``-1`` in blocking ``AsyncReadWriteLock`` and ``AsyncSoftReadWriteLock`` acquisitions. Keep ``-1`` as an unlimited wait and ignore timeouts when ``blocking=False``. :pr:`755` - by :user:`pasmud`. .. _release-4-0-4: ******************** 4.0.4 (2026-09-26) ******************** .. _release-4-0-4-fixed: Fixed ===== - Hostnames that still differ after their first 253 escaped characters now publish distinct owners, so a soft lock no longer takes another such host's live holder for its own and reclaims its marker. :pr:`748` - by :user:`feiiiiii5`. .. _release-4-0-3: ******************** 4.0.3 (2026-09-23) ******************** .. _release-4-0-3-fixed: Fixed ===== - Importing filelock on CPython 3.10 or 3.11 no longer makes new threads fail with ``RuntimeError: Cannot install a trace function while another trace function is being installed`` under coverage or a debugger. filelock skips its fork-safety audit hook there, so forking from inside a thread's own lock-state transition no longer raises immediately on those versions. :pr:`747` - by :user:`gaborbernat`. .. _release-4-0-2: ******************** 4.0.2 (2026-09-23) ******************** .. _release-4-0-2-fixed: Fixed ===== - Concurrent ``acquire()`` and ``release()`` on a ``thread_local=False`` lock no longer leak the OS lock, close a descriptor twice, drop a lease token, or leave a false deadlock after a cross-thread release (:issue:`744`). :pr:`745` - by :user:`gaborbernat`. - :class:`~filelock.AsyncReadWriteLock` and :class:`~filelock.AsyncSoftReadWriteLock` now give each ``asyncio`` task its own hold, so tasks sharing one instance no longer enter the write lock together. :pr:`746` - by :user:`gaborbernat`. - Correct the async cache example to create its data directory and clarify automatic creation of lock-file parent directories. :pr:`740` - by :user:`agammann`. .. _release-4-0-1: ******************** 4.0.1 (2026-09-19) ******************** .. _release-4-0-1-fixed: Fixed ===== - ``poll_interval`` is now validated at construction, on the setter, and on ``acquire()``: a negative, non-finite, or non-numeric value raises :class:`ValueError`/:class:`TypeError` immediately instead of failing inside ``time.sleep``. :pr:`739` - by :user:`Pitchfork-and-Torch`. .. _release-4-0-0: ******************** 4.0.0 (2026-09-17) ******************** .. _release-4-0-0-added: Added ===== - :class:`~filelock.SoftReadWriteLock` exposes :attr:`~filelock.SoftReadWriteLock.generation` as a fencing token for the protected resource and reports a lost hold through ``on_compromise`` and :attr:`~filelock.SoftReadWriteLock.compromise`. :pr:`738` - by :user:`gaborbernat`. .. _release-4-0-0-fixed: Fixed ===== - The :class:`~filelock.SoftReadWriteLock` on-disk protocol is a generation log under ``.rw``, and a process running an earlier release does not see it: an old and a new participant on one lock path do not exclude each other. Stop every participant, upgrade them all, then restart them; the new code ignores leftover ``.state``, ``.write`` and ``.readers/`` files, and you can delete them. The filesystem must provide no-replace hard links, as it must for :class:`~filelock.StrictSoftFileLock`, so a runtime without ``os.link`` raises :class:`~filelock.SoftFileLockProtocolError` on acquire. Constructing a singleton again with a different ``on_compromise``, or with ``poll_interval`` at or above ``stale_threshold``, now raises :class:`ValueError`. :pr:`738` - by :user:`gaborbernat`. - :class:`~filelock.SoftReadWriteLock` no longer deadlocks when a holder dies on another host mid-transition, and ``release()`` no longer waits on a mutex a dead host left behind. The state mutex is gone. Each transition is one atomic snapshot commit, and liveness is a heartbeat nonce read on the observer's own clock rather than an ``mtime`` read against another host's. :pr:`738` - by :user:`gaborbernat`. .. _release-3-32-7: ********************* 3.32.7 (2026-09-16) ********************* .. _release-3-32-7-fixed: Fixed ===== - Document that :meth:`~filelock.BaseFileLock.acquire` reads ``blocking=None`` as the lock's ``blocking`` attribute and raises :class:`~filelock.Timeout` after one attempt when ``blocking=False``. :pr:`733` - by :user:`hxperl`. .. _release-3-32-6: ********************* 3.32.6 (2026-09-08) ********************* .. _release-3-32-6-added: Added ===== - Allow acquiring existing lock files that grant write access through group permissions or an ACL even when their owner-write mode bit is unset. :pr:`728` - by :user:`jackwalkerlabs`. .. _release-3-32-6-fixed: Fixed ===== - ``SoftFileLease`` and ``AsyncSoftFileLease`` now reject a boolean or non-finite ``lease_duration``, which used to publish an owner record their own ``owner`` property reads back as malformed. :pr:`723` - by :user:`lprnmns`. - Reject non-finite heartbeat, stale, and polling intervals in ``SoftReadWriteLock`` and ``AsyncSoftReadWriteLock``, including cached singleton construction and overflow in the default stale threshold. :pr:`724` - by :user:`lprnmns`. - Honor acquisition timeouts and ``blocking=False`` during ``SoftReadWriteLock`` state-mutex contention, including failed writer cleanup. Cross-host recovery of an abandoned ``.state`` marker remains unsupported. :pr:`726` - by :user:`Sohel2309`. - Preserve exception notes and custom attributes when copying or pickling ``Timeout`` and ``SoftFileLockProtocolError``. :pr:`729` - by :user:`jackwalkerlabs`. .. _release-3-32-5: ********************* 3.32.5 (2026-08-31) ********************* .. _release-3-32-5-fixed: Fixed ===== - ``SoftFileLease.token`` and ``AsyncSoftFileLease.token`` now read ``None`` after a failed acquisition, so a contender turned away by a live holder no longer reports a token for a claim it never published. :pr:`721` - by :user:`lprnmns`. - Document that ``mode`` has no setter: unlike ``poll_interval``, ``timeout``, ``blocking`` and ``lifetime``, it is fixed at construction and ``lock.mode = ...`` raises ``AttributeError``. :pr:`716` - by :user:`Gares95`. .. _release-3-32-4: ********************* 3.32.4 (2026-08-23) ********************* .. _release-3-32-4-fixed: Fixed ===== - ``StrictSoftFileLock`` always retries a claim read whose first attempt reports the claim as pending, so a first read that itself outlasts the retry grace no longer fails closed on a claim it could have read. :pr:`705` - by :user:`gaborbernat`. - ``WindowsFileLock`` waits out a transient ``STATUS_ACCESS_DENIED`` from ``NtCreateFile`` for up to half a second before raising ``PermissionError``, since a peer unlinking the lock file as it releases can answer that for a moment; a real denial still fails fast. :pr:`705` - by :user:`gaborbernat`. - Every lock class now escapes the hostname it publishes, so a host whose ``socket.gethostname()`` carries a space, a newline or a byte outside UTF-8 no longer writes a marker it reads back as malformed. Such a host used to lose a held ``SoftReadWriteLock`` read slot to a peer and could not take a write slot or a ``StrictSoftFileLock`` at all. :pr:`709` - by :user:`dxbjavid`. .. _release-3-32-3: ********************* 3.32.3 (2026-08-13) ********************* .. _release-3-32-3-fixed: Fixed ===== - The fork-safety audit hook no longer prints ``Exception ignored in audit hook`` with a ``TypeError`` when an audit event fires during interpreter shutdown, after CPython has already cleared the module globals. :pr:`703` - by :user:`gaborbernat`. .. _release-3-32-2: ********************* 3.32.2 (2026-07-29) ********************* .. _release-3-32-2-fixed: Fixed ===== - A ``SoftReadWriteLock`` or ``SoftFileLease`` acquire whose heartbeat thread fails to start now unlinks its marker and hands the claim back, instead of leaving an unrefreshed marker a peer takes while the caller believes it still holds the lock. :pr:`691` - by :user:`dxbjavid`. - Canceling an ``AsyncSoftReadWriteLock`` acquire now releases the claim instead of leaking a marker whose heartbeat wedges every contender. :pr:`686` - by :user:`dxbjavid`. .. _release-3-32-0: ********************* 3.32.0 (2026-07-21) ********************* .. _release-3-32-0-added: Added ===== - Declare support for Python 3.15 and run the test suite against it and its free-threaded build, both currently in beta. :pr:`683` - by :user:`gaborbernat`. .. _release-3-32-0-fixed: Fixed ===== - ``SoftReadWriteLock`` closes the directory handle it opens to scan for readers as soon as a scan stops early, rather than holding it until the generator is collected. :pr:`685` - by :user:`gaborbernat`. .. _release-3-31-2: ********************* 3.31.2 (2026-07-21) ********************* .. _release-3-31-2-fixed: Fixed ===== - ``filelock`` imports again on runtimes whose ``errno`` omits ``ENOTSUP``, such as GraalPy, where importing the package raised ``ImportError``. It probes the code instead, preferring ``ENOTSUP``, falling back to ``EOPNOTSUPP`` where that name is absent, and dropping to ``ENOSYS``/``EXDEV`` where neither exists. Platforms defining ``ENOTSUP`` keep their behavior. :pr:`681` - by :user:`gaborbernat`. .. _release-3-31-1: ********************* 3.31.1 (2026-07-20) ********************* .. _release-3-31-1-fixed: Fixed ===== - A ``SoftFileLease`` acquired on one thread keeps its claim when another thread fails to acquire the same lease object, so its heartbeat carries on refreshing the marker instead of being torn down and letting a peer take the live claim. :pr:`680` - by :user:`dxbjavid`. .. _release-3-31-0: ********************* 3.31.0 (2026-07-18) ********************* .. _release-3-31-0-added: Added ===== - Support Termux/Android, whose CPython ships without ``os.link`` and reports ``sys.platform == "android"``. ``import filelock`` and both ``FileLock`` and ``SoftFileLock`` now work there, ``StrictSoftFileLock`` reports its missing hard-link support only when acquired, and process liveness reads ``/proc`` on Android instead of PID-only checks. :pr:`678` - by :user:`gaborbernat`. .. _release-3-31-0-fixed: Fixed ===== - ``StrictSoftFileLock`` no longer lets two processes hold the lock at once under heavy contention. A holder now keeps its intent claim for the whole hold, so a contender whose directory scan races the holder's freshly linked claim can no longer miss it and win alongside it. A held lock now exposes both an ``intent`` and a ``held`` claim. :pr:`678` - by :user:`gaborbernat`. .. _release-3-30-3: ********************* 3.30.3 (2026-07-17) ********************* .. _release-3-30-3-fixed: Fixed ===== - ``AsyncFileLock`` and ``AsyncSoftFileLock`` no longer raise a deadlock ``RuntimeError`` when a different asyncio task waits for a lock they hold; the check now scopes holders to the owning task, so only a same-task reacquire is refused. :pr:`676` - by :user:`xt-yin`. .. _release-3-30-3-documentation: Documentation ============= - Keep both tables of contents on screen at any browser font size. The widened body column sized itself in ``em`` against the reader's font size while the breakpoints that fold the layout resolve ``em`` against a fixed 16px, so a reader whose default font is larger than 16px had the right-hand table of contents clipped off the edge. The body column now flexes instead, and the right-hand table of contents hides only at the mobile breakpoint. :pr:`673` - by :user:`gaborbernat`. - Color the mermaid diagrams from whichever theme is active. They previously carried a light palette baked into each diagram's source, which stayed light on a dark page. :pr:`674` - by :user:`gaborbernat`. .. _release-3-30-2: ********************* 3.30.2 (2026-07-16) ********************* .. _release-3-30-2-fixed: Fixed ===== - Stop :class:`~filelock.SoftFileLease` deleting a live marker whose ``mode`` it does not implement. An unrecognized mode now names its owner instead of reading as malformed, so a record written by a newer filelock survives the grace window rather than being evicted after two seconds. :pr:`672` - by :user:`gaborbernat`. - Stop :class:`~filelock.StrictSoftFileLock` and :class:`~filelock.AsyncStrictSoftFileLock` calling themselves a native OS lock when they warn that they ignore ``lifetime``; they now say a strict claim is only ever cleared by ``force_break()``. :pr:`672` - by :user:`gaborbernat`. .. _release-3-30-2-documentation: Documentation ============= - Cover every lock type in the tutorials and how-to guides, with examples drawn from projects that use filelock, and color the mermaid diagrams. Correct the claims that ``StrictSoftFileLock`` exposes ``owner``, that :class:`~filelock.SoftFileLock` evicts a strict sentinel, that :class:`~filelock.ReadWriteLock` requires a ``.db`` extension, and that every log record is ``DEBUG``. :pr:`672` - by :user:`gaborbernat`. .. _release-3-30-1: ********************* 3.30.1 (2026-07-16) ********************* .. _release-3-30-1-fixed: Fixed ===== - ``StrictSoftFileLock`` and ``AsyncStrictSoftFileLock`` no longer abort acquisition when a peer's claim vanishes as an NFSv3 stale filehandle instead of a clean removal; the reader revalidates and skips it, so strict locks hold mutual exclusion across independent NFSv3 client caches. :pr:`669` - by :user:`gaborbernat`. - ``SoftFileLease`` reads a marker whose lease duration is ``nan`` or ``inf`` as malformed rather than as a valid lease, so such a marker ages out through the grace window instead of raising ``LeaseSettingsMismatch`` on every contender. :pr:`670` - by :user:`dxbjavid`. .. _release-3-30-0: ********************* 3.30.0 (2026-07-16) ********************* .. _release-3-30-0-added: Added ===== - Add ``context_error_policy`` to surface body and release failures as a :exc:`BaseExceptionGroup`. :pr:`618` - by :user:`gaborbernat`. - Add ``close_error_policy`` to control an :func:`os.close` failure after the OS unlock. :pr:`619` - by :user:`gaborbernat`. - Add :func:`~filelock.lock_descriptor` and :func:`~filelock.unlock_descriptor` to lock a caller-owned file descriptor. :pr:`620` - by :user:`gaborbernat`. - Add ``fallback_to_soft`` to fail closed instead of downgrading to :class:`~filelock.SoftFileLock` on ``ENOSYS``. :pr:`622` - by :user:`gaborbernat`. - Add ``preserve_lock_file`` so native locks keep the lock pathname on release. :pr:`624` - by :user:`gaborbernat`. - Add an ``on_acquired`` hook that runs a callback on the locked descriptor once the lock is held. :pr:`625` - by :user:`gaborbernat`. - Add ``StrictSoftFileLock``, which treats every marker it did not publish as contention, and ``SoftFileLease``, whose claim expires and whose holder learns through ``on_compromise`` when it is lost. ``SoftFileLock(lifetime=...)`` now warns and names them. Both publish a record ``SoftFileLock`` evicts, so do not mix contracts on one path. :pr:`658` - by :user:`gaborbernat`. - Add ``StrictSoftFileLock`` and ``AsyncStrictSoftFileLock`` with owner-specific hard-linked claims. Strict mode fails closed on claim damage, exposes each recovery token, and requires an exact claim name for a force break. :pr:`659` - by :user:`gaborbernat`. .. _release-3-30-0-changes: Changes ======= - Make native lock release transactional so a failed unlock is retried, not dropped. :pr:`615` - by :user:`gaborbernat`. .. _release-3-30-0-fixed: Fixed ===== - Ignore ``lifetime`` on native locks with a warning; only :class:`~filelock.SoftFileLock` honors it. :pr:`593` - by :user:`gaborbernat`. - Stop mutating the Unix lock file before :func:`fcntl.flock` is held. :pr:`594` - by :user:`gaborbernat`. - Evict a non-regular soft lock file without reading it. :pr:`597` - by :user:`dxbjavid`. - Write a soft lock's holder record atomically so a short write cannot create overlapping holders. :pr:`614` - by :user:`gaborbernat`. - Open the Windows lock file via ``NtCreateFile`` so a real access denial is raised, not reported as a timeout. :pr:`617` - by :user:`gaborbernat`. - Canonicalize singleton keys so equivalent path spellings share an instance, without following a final symlink. :pr:`621` - by :user:`gaborbernat`. - Preserve parent lock ownership across ``fork()``. A child closes an inherited descriptor only while its identity still matches, so it neither unlocks nor unlinks the parent's lock. It clears inherited ownership state and singleton caches, then requires a new lock instance before acquiring. Fork-time construction replaces inherited cache mutexes and omits an instance whose construction crossed into the child from the cache. If ``fstat()`` cannot establish the identity, child cleanup skips the descriptor number because an earlier fork callback may have reused it. Descriptor tracking also covers third-party backends that fail after assigning a descriptor. :pr:`652` - by :user:`gaborbernat`. - Open a SQLite connection per outer acquisition and close it on final release, so a forked child can no longer close its parent's handle. A child rejects inherited databases until ``exec()``, and a PyPy child rejects read-write locks once the parent has used SQLite. :pr:`657` - by :user:`gaborbernat`. - Keep executor-backed lock operations alive until they finish when a caller cancels. Serialize acquisitions on the same async lock so cancellation rollback cannot release a later caller's hold. Roll back acquisitions that finish after cancellation and surface release failures. SQLite read-write locks keep their state until rollback ends the transaction; callers can retry cleanup with ``release(force=True)`` or another acquisition. :pr:`652` - by :user:`gaborbernat`. - Reject negative and non-finite ``lifetime`` values during lock construction and assignment. :pr:`644` - by :user:`gaborbernat`. - Read the saved Windows process-probe error before deciding that a soft-lock holder has exited. :pr:`645` - by :user:`gaborbernat`. - Reject non-default lock options that a narrow subclass constructor cannot honor, and forward all options through subclasses that accept arbitrary keyword arguments. :pr:`646` - by :user:`gaborbernat`. - Release now removes the path identity that acquisition recorded instead of resolving a symlinked parent again. When acquisition raises, its rollback no longer deletes the holder's deadlock record. :pr:`647` - by :user:`gaborbernat`. - Remove the body error from implicit context on the release error before adding both errors to an exception group. :pr:`648` - by :user:`gaborbernat`. - Relinquish a soft lock's descriptor before one close attempt so a later release cannot close a reused descriptor number, and honor ``close_error_policy`` while still attempting marker cleanup. :pr:`649` - by :user:`gaborbernat`. - Raise ``OSError(errno.ENOSYS)`` from descriptor locks when ``fcntl`` is unavailable, and reject invalid blocking poll intervals before attempting a lock. :pr:`650` - by :user:`gaborbernat`. - ``SoftFileLock`` and ``SoftFileLease`` record the holder's process start time on every platform and reclaim a stale marker only when the recorded owner is provably gone. A reused PID on Unix no longer breaks a live lock or wedges acquisition, matching the recycled-PID detection Windows already had. :pr:`660` - by :user:`gaborbernat`. - ``SoftReadWriteLock`` and ``SoftFileLease`` ride out a transient filesystem error during a heartbeat instead of dropping the lock or reporting a false compromise, and report a lost claim only once refresh cannot recover before the lease would lapse. ``SoftFileLease`` also reclaims a malformed or partial marker that used to block every contender, and a Linux marker distinguishes a PID reused across a reboot. :pr:`661` - by :user:`gaborbernat`. - ``BaseFileLock.__del__`` suppresses a release error rather than raising it during garbage collection, where it surfaced as an unraisable-exception warning attributed to unrelated code. :pr:`665` - by :user:`gaborbernat`. .. _release-3-30-0-documentation: Documentation ============= - Correct the Unix lock-file cleanup and :func:`fcntl.flock` documentation. :pr:`623` - by :user:`gaborbernat`. - Refresh the documentation to the current code: a new trust-boundaries and ownership-scope section (same-UID boundary, advisory native locks, strict claims and lease fencing, a filesystem support matrix, migration off timed stale breaking), the lock-selection flowchart and comparison tables extended with ``StrictSoftFileLock`` and ``SoftFileLease``, and the cross-platform process start token replacing the Windows-only description throughout. :pr:`663` - by :user:`gaborbernat`. - The filesystem support matrix records mutual exclusion measured across two independent client caches in CI: NFS (v4 and v3) for the native, soft, and strict locks, and SMB for the native and soft locks. :pr:`665` - by :user:`gaborbernat`. .. _release-3-30-0-security: Security ======== - Bind the Windows reparse-point check to the locked handle to close a symlink-swap race. :pr:`596` - by :user:`gaborbernat`. .. _release-3-29-7: ********************* 3.29.7 (2026-07-07) ********************* .. _release-3-29-7-fixed: Fixed ===== - Evict a non-regular write marker without reading it :pr:`588` - by :user:`gaborbernat`. - Detect cross-instance reentrant deadlocks before the poll loop https://github.com/tox-dev/filelock/commit/616400e382cdc9cc8c95823014e4d2d61a70095c - by :user:`HrachShah` .. _release-3-29-6: ********************* 3.29.6 (2026-07-06) ********************* .. _release-3-29-6-fixed: Fixed ===== - Serialize singleton construction in ``FileLockMeta`` :pr:`581` - by :user:`dxbjavid` .. _release-3-29-5: ********************* 3.29.5 (2026-07-02) ********************* .. _release-3-29-5-changes: Changes ======= - Use a private break name in ``break_lock_file`` :pr:`576` - by :user:`dxbjavid` .. _release-3-29-5-fixed: Fixed ===== - Reject negative, non-numeric, and bool values at the setter https://github.com/tox-dev/filelock/commit/be562277636140c26578a98a2a487fdb6f446ee6 - by :user:`HrachShah` - Roll back a read acquire's open transaction when its SELECT fails :pr:`575` - by :user:`dxbjavid` - Keep Unix lock files after release :pr:`577` - by :user:`itscloud0` - Don't complete a writer acquire on a peer's reclaimed marker :pr:`571` - by :user:`dxbjavid` - Don't follow symlinks in ``raise_on_not_writable_file`` :pr:`567` - by :user:`dxbjavid` - Only unlink the writer marker on release if it is still ours :pr:`566` - by :user:`dxbjavid` - Don't follow symlinks when refreshing soft read/write lock markers :pr:`565` - by :user:`dxbjavid` - Serialize read/write release rollback against a concurrent acquire :pr:`563` - by :user:`dxbjavid` .. _release-3-29-4: ********************* 3.29.4 (2026-06-13) ********************* .. _release-3-29-4-fixed: Fixed ===== - Keep the read/write heartbeat alive on a transient touch error :pr:`562` - by :user:`dxbjavid` - Verify inode in ``break_lock_file`` before unlinking a stale lock :pr:`561` - by :user:`dxbjavid` .. _release-3-29-3: ********************* 3.29.3 (2026-06-10) ********************* .. _release-3-29-3-fixed: Fixed ===== - Validate pid range in ``_parse_lock_holder`` :pr:`556` - by :user:`dxbjavid` .. _release-3-29-2: ********************* 3.29.2 (2026-06-10) ********************* .. _release-3-29-2-fixed: Fixed ===== - Check hostname in ``is_lock_held_by_us`` :pr:`553` - by :user:`dxbjavid` - Recover malformed soft-lock markers without breaking a live lock. :pr:`551` - by :user:`gaborbernat`. .. _release-3-29-2-security: Security ======== - Open marker reads non-blocking to refuse attacker-placed fifo :pr:`549` - by :user:`dxbjavid` .. _release-3-29-1: ********************* 3.29.1 (2026-06-03) ********************* .. _release-3-29-1-documentation: Documentation ============= - Clarify per-thread scope of :class:`~filelock.FileLock` configuration :pr:`543` - by :user:`Gares95` - Fix API docs of :meth:`~filelock.BaseFileLock.release` :pr:`540` - by :user:`MrAnno` .. _release-3-29-1-security: Security ======== - Refuse symlinks when reading a soft lock file. :pr:`548` - by :user:`dxbjavid`. .. _release-3-29-0: ********************* 3.29.0 (2026-04-19) ********************* .. _release-3-29-0-added: Added ===== - Detect stale soft locks on Windows. :pr:`534` - by :user:`gaborbernat`. .. _release-3-29-0-fixed: Fixed ===== - Use one executor thread for async lock operations to keep acquisition and release ordered. :pr:`533` - by :user:`gaborbernat`. .. _release-3-28-0: ********************* 3.28.0 (2026-04-14) ********************* .. _release-3-28-0-added: Added ===== - Inspect a soft lock's owner PID and break stale locks. :pr:`524` - by :user:`gaborbernat`. - Add :class:`~filelock.SoftReadWriteLock` for NFS and HPC clusters. :pr:`528` - by :user:`gaborbernat`. .. _release-3-28-0-fixed: Fixed ===== - Handle ``__exit__`` and event-loop shutdown for :class:`~filelock.BaseAsyncFileLock`. :pr:`518` - by :user:`naarob`. .. _release-3-25-2: ********************* 3.25.2 (2026-03-11) ********************* .. _release-3-25-2-fixed: Fixed ===== - Suppress ``EIO`` when closing a Unix lock on a Docker bind mount. :pr:`513` - by :user:`gaborbernat`. .. _release-3-25-1: ********************* 3.25.1 (2026-03-09) ********************* .. _release-3-25-1-fixed: Fixed ===== - Restore best-effort lock file cleanup on Windows. :pr:`511` - by :user:`gaborbernat`. .. _release-3-25-1-documentation: Documentation ============= - Add the project logo to the documentation. :pr:`507` - by :user:`gaborbernat`. .. _release-3-25-0: ********************* 3.25.0 (2026-03-01) ********************* .. _release-3-25-0-added: Added ===== - Add :class:`~filelock.AsyncReadWriteLock`. :pr:`506` - by :user:`gaborbernat`. .. _release-3-25-0-fixed: Fixed ===== - Suppress :class:`ValueError` while checking a corrupt stale-lock marker. :pr:`496` - by :user:`bysiber` - Fix nonblocking transaction-lock acquisition with a timeout. :pr:`498` - by :user:`gaborbernat` .. _release-3-24-3: ********************* 3.24.3 (2026-02-19) ********************* .. _release-3-24-3-fixed: Fixed ===== - Handle an ``ENOENT`` race while acquiring a lock on FUSE or NFS. :pr:`495` - by :user:`gaborbernat`. .. _release-3-24-2: ********************* 3.24.2 (2026-02-16) ********************* .. _release-3-24-2-fixed: Fixed ===== - Close SQLite cursors and avoid a :class:`~filelock.SoftFileLock` race on Windows. :pr:`491` - by :user:`gaborbernat`. .. _release-3-24-2-documentation: Documentation ============= - Organize the documentation into tutorials, how-to guides, reference, and explanations. :pr:`489` - by :user:`gaborbernat`. .. _release-3-24-1: ********************* 3.24.1 (2026-02-15) ********************* .. _release-3-24-1-fixed: Fixed ===== - Fix a soft-lock deadlock on Windows. :pr:`488` - by :user:`gaborbernat`. .. _release-3-24-0: ********************* 3.24.0 (2026-02-14) ********************* .. _release-3-24-0-added: Added ===== - Add a ``lifetime`` parameter for lock expiration (:issue:`68`). :pr:`486` - by :user:`gaborbernat`. - Add ``cancel_check`` to :meth:`~filelock.BaseFileLock.acquire` (:issue:`309`). :pr:`487` - by :user:`gaborbernat`. - Honor POSIX default ACLs when creating a lock file (:issue:`378`). :pr:`483` - by :user:`gaborbernat`. - Add ``poll_interval`` to the lock constructor. :pr:`482` - by :user:`gaborbernat`. .. _release-3-24-0-fixed: Fixed ===== - Detect a same-thread self-deadlock. :pr:`481` - by :user:`gaborbernat`. - Fix a lock file race between threads on Windows. :pr:`484` - by :user:`gaborbernat`. - Fall back to :class:`~filelock.SoftFileLock` when native locking fails with ``ENOSYS``. :pr:`480` - by :user:`gaborbernat`. .. _release-3-23-0: ********************* 3.23.0 (2026-02-14) ********************* .. _release-3-23-0-changes: Changes ======= - Change the project license from Unlicense to MIT. :pr:`479` - by :user:`gaborbernat`. .. _release-3-23-0-documentation: Documentation ============= - List fasteners among the similar libraries in the documentation. :pr:`478` - by :user:`gaborbernat`. .. _release-3-22-0: ********************* 3.22.0 (2026-02-14) ********************* .. _release-3-22-0-added: Added ===== - Detect and break stale soft locks. :pr:`476` - by :user:`gaborbernat`. .. _release-3-22-0-fixed: Fixed ===== - Disable stale-lock detection on Windows until its implementation is safe. :pr:`477` - by :user:`gaborbernat`. .. _release-3-21-2: ********************* 3.21.2 (2026-02-13) ********************* .. _release-3-21-2-fixed: Fixed ===== - Handle :exc:`ImportError` when the :mod:`sqlite3` C library is unavailable. :pr:`475` - by :user:`gaborbernat`. .. _release-3-21-1: ********************* 3.21.1 (2026-02-12) ********************* .. _release-3-21-1-fixed: Fixed ===== - Import :class:`~filelock.ReadWriteLock` when :mod:`sqlite3` is unavailable. :pr:`473` - by :user:`bayandin`. .. _release-3-21-0: ********************* 3.21.0 (2026-02-12) ********************* .. _release-3-20-4: ********************** v3.20.4 (2026-03-23) ********************** .. _release-3-20-4-added: Added ===== - Add a SQLite-backed :class:`~filelock.ReadWriteLock`. :pr:`399` - by :user:`leventov` - Allow Unix lock files to be removed after release. :pr:`408` - by :user:`sbc100` .. _release-3-20-3: ********************** v3.20.3 (2026-01-09) ********************** .. _release-3-20-3-security: Security ======== - Fix TOCTOU symlink vulnerability in :class:`~filelock.SoftFileLock` :pr:`465` - by :user:`gaborbernat`. .. _release-3-20-2: ********************** v3.20.2 (2026-01-02) ********************** .. _release-3-20-2-added: Added ===== - Support Unix systems without :data:`os.O_NOFOLLOW` :pr:`463` - by :user:`mwilliamson`. .. _release-3-20-1: ********************** v3.20.1 (2025-12-15) ********************** .. _release-3-20-1-security: Security ======== - Fix TOCTOU symlink vulnerability in lock file creation :pr:`461` - by :user:`gaborbernat`. .. _release-3-20-0: ********************** v3.20.0 (2025-10-08) ********************** .. _release-3-20-0-added: Added ===== - Add Python 3.14 support, drop 3.9 :pr:`448` - by :user:`gaborbernat`. .. _release-3-19-1: ********************** v3.19.1 (2025-08-14) ********************** .. _release-3-19-1-added: Added ===== - Add support for Python 3.14 :pr:`432` - by :user:`gaborbernat`. .. _release-3-18-0: ********************** v3.18.0 (2025-03-11) ********************** .. _release-3-18-0-added: Added ===== - Support :mod:`fcntl` check on Emscripten :pr:`398` - by :user:`juntyr`. .. _release-3-18-0-fixed: Fixed ===== - Indicate that locks are exclusive/write locks :pr:`394` - by :user:`bicarlsen`. .. _release-3-17-0: ********************** v3.17.0 (2025-01-21) ********************** .. _release-3-17-0-added: Added ===== - Drop Python 3.8 support :pr:`388`. .. _release-3-16-1: ********************** v3.16.1 (2024-09-17) ********************** .. _release-3-16-0: ********************** v3.16.0 (2024-09-07) ********************** .. _release-3-16-0-added: Added ===== - Add Python 3.13 to CI :pr:`359` - by :user:`gaborbernat`. .. _release-3-15-4: ********************** v3.15.4 (2024-06-22) ********************** .. _release-3-15-4-fixed: Fixed ===== - Pass ``file_lock`` as positional argument :pr:`347` - by :user:`kwist-sgr`. .. _release-3-15-3: ********************** v3.15.3 (2024-06-19) ********************** .. _release-3-15-3-fixed: Fixed ===== - Fix ``TypeError: _CountedFileLock.__init__() got an unexpected keyword argument`` :pr:`345` - by :user:`kwist-sgr`. .. _release-3-15-2: ********************** v3.15.2 (2024-06-19) ********************** .. _release-3-15-2-changes: Changes ======= - Use a metaclass to implement the singleton pattern :pr:`340` - by :user:`kwist-sgr`. .. _release-3-15-1: ********************** v3.15.1 (2024-06-12) ********************** .. _release-3-15-1-fixed: Fixed ===== - Restore ``__init__`` method; more robust initialization for singleton locks :pr:`338` - by :user:`ethanbb`. .. _release-3-15-0: ********************** v3.15.0 (2024-06-11) ********************** .. _release-3-15-0-added: Added ===== - Add asyncio support :pr:`332` - by :user:`Ovizro`. .. _release-3-15-0-fixed: Fixed ===== - Don't re-initialize :class:`~filelock.BaseFileLock` when returning existing singleton instance :pr:`334` - by :user:`ethanbb`. .. _release-3-14-0: ********************** v3.14.0 (2024-04-27) ********************** .. _release-3-14-0-added: Added ===== - Add ``blocking`` parameter on lock constructor :pr:`325` - by :user:`iamkhav`. .. _release-3-13-4: ********************** v3.13.4 (2024-04-09) ********************** .. _release-3-13-4-fixed: Fixed ===== - Raise error on incompatible singleton ``timeout`` and ``mode`` arguments :pr:`320` - by :user:`nefrob`. .. _release-3-13-3: ********************** v3.13.3 (2024-03-25) ********************** .. _release-3-13-3-changes: Changes ======= - Make singleton class instance dict unique per subclass :pr:`318` - by :user:`nefrob`. .. _release-3-13-2: ********************** v3.13.2 (2024-03-25) ********************** .. _release-3-13-2-fixed: Fixed ===== - Fix permission denied error when lock file is placed in ``/tmp`` :pr:`317` - by :user:`kota-iizuka`. .. _release-3-13-1: ********************** v3.13.1 (2023-10-30) ********************** .. _release-3-13-1-added: Added ===== - Allow users to subclass :class:`~filelock.FileLock` with custom keyword arguments :pr:`284` - by :user:`hmaarrfk`. .. _release-3-13-0: ********************** v3.13.0 (2023-10-27) ********************** .. _release-3-13-0-added: Added ===== - Support reentrant locking on lock file path via optional ``is_singleton`` instance :pr:`283` - by :user:`nefrob`. .. _release-3-12-4: ********************** v3.12.4 (2023-09-13) ********************** .. _release-3-12-4-packaging: Packaging ========= - Change ``typing-extensions`` to be installed only with the ``[typing]`` extra :pr:`276` - by :user:`asottile`. .. _release-3-12-3: ********************** v3.12.3 (2023-08-28) ********************** .. _release-3-12-3-added: Added ===== - Create parent directories if necessary :pr:`254` - by :user:`gerlero`. .. _release-3-12-2: ********************** v3.12.2 (2023-06-12) ********************** .. _release-3-12-2-fixed: Fixed ===== - Restore ``if TYPE_CHECKING`` syntax for :class:`~filelock.FileLock` definition :pr:`245` - by :user:`dlax`. .. _release-3-12-1: ********************** v3.12.1 (2023-06-10) ********************** .. _release-3-12-1-added: Added ===== - Add Python 3.12 support :pr:`237` - by :user:`gaborbernat`. .. _release-3-12-0: ********************** v3.12.0 (2023-04-18) ********************** .. _release-3-12-0-changes: Changes ======= - Allow callers to disable thread-local lock state at construction; it remains enabled by default. :pr:`232` - by :user:`csm10495`. .. _release-3-12-0-fixed: Fixed ===== - Fix a lock hang on Windows. :pr:`231` - by :user:`TheMatt2`. .. _release-3-11-0: ********************** v3.11.0 (2023-04-06) ********************** .. _release-3-11-0-changes: Changes ======= - Keep lock state local to each thread. :pr:`219` - by :user:`csm10495`. .. _release-3-10-7: ********************** v3.10.7 (2023-03-27) ********************** .. _release-3-10-7-fixed: Fixed ===== - Use :func:`os.fchmod` to avoid a :func:`os.chmod` failure on PyPy via Anaconda. :pr:`214` - by :user:`jfennick`. .. _release-3-10-6: ********************** v3.10.6 (2023-03-25) ********************** .. _release-3-10-6-fixed: Fixed ===== - Raise unexpected file-opening errors from :class:`~filelock.SoftFileLock` instead of repeatedly waiting for a lock that cannot be acquired. :pr:`213` - by :user:`jahrules` and :user:`gaborbernat`. .. _release-3-10-5: ********************** v3.10.5 (2023-03-25) ********************** .. _release-3-10-5-added: Added ===== - Raise :class:`NotImplementedError` when a Unix filesystem does not support ``flock`` instead of waiting indefinitely. :pr:`212` - by :user:`jahrules`. .. _release-3-10-4: ********************** v3.10.4 (2023-03-24) ********************** .. _release-3-10-4-fixed: Fixed ===== - Apply the requested file mode when creating a Unix lock file, including when the file cannot later be changed with ``chmod``. :pr:`211` - by :user:`jahrules`. .. _release-3-10-3: ********************** v3.10.3 (2023-03-23) ********************** .. _release-3-10-3-fixed: Fixed ===== - Fix permission issue - by :user:`jahrules`. .. _release-3-10-2: ********************** v3.10.2 (2023-03-22) ********************** .. _release-3-10-2-fixed: Fixed ===== - Bug fix for using filelock with threaded programs causing undesired file permissions - by :user:`jahrules`. .. _release-3-10-1: ********************** v3.10.1 (2023-03-22) ********************** .. _release-3-10-1-fixed: Fixed ===== - Handle pickle for :class:`~filelock.Timeout` :pr:`203` - by :user:`TheMatt2`. .. _release-3-10-0: ********************** v3.10.0 (2023-03-15) ********************** .. _release-3-10-0-added: Added ===== - Add support for explicit file modes for lockfiles :pr:`192` - by :user:`jahrules`. .. _release-3-9-1: ********************* v3.9.1 (2023-03-14) ********************* .. _release-3-9-1-changes: Changes ======= - Use :func:`time.perf_counter` instead of :func:`time.monotonic` to calculate timeouts. :pr:`194` - by :user:`zpz`. .. _release-3-9-0: ********************* v3.9.0 (2022-12-28) ********************* .. _release-3-8-2: ********************* v3.8.2 (2022-12-05) ********************* .. _release-3-8-2-packaging: Packaging ========= - Update the published license classifier to Unlicense. :pr:`180` - by :user:`jond01` .. _release-3-8-1: ********************* v3.8.1 (2022-12-04) ********************* .. _release-3-8-1-fixed: Fixed ===== - Make :class:`~filelock.FileLock` valid in mypy type annotations. :pr:`177` - by :user:`gaborbernat`. .. _release-3-8-0: ********************* v3.8.0 (2022-12-04) ********************* .. _release-3-8-0-added: Added ===== - Add Python 3.11 support. :pr:`158` - by :user:`gaborbernat`. .. _release-3-8-0-documentation: Documentation ============= - State timeout units in the API docstrings. :pr:`148` - by :user:`jnordberg`. .. _release-3-7-1: ********************* v3.7.1 (2022-05-31) ********************* .. _release-3-7-1-documentation: Documentation ============= - Point README documentation links to the site index - by :user:`gaborbernat`. .. _release-3-7-0: ********************* v3.7.0 (2022-05-13) ********************* .. _release-3-7-0-added: Added ===== - Allow a lock attempt to return immediately when the lock cannot be acquired. :pr:`142` - by :user:`myheroyuki`. .. _release-3-6-0: ********************* v3.6.0 (2022-02-17) ********************* .. _release-3-6-0-fixed: Fixed ===== - Fix pylint warning "Abstract class :class:`~filelock.WindowsFileLock` with abstract methods instantiated" :pr:`135` - by :user:`vonschultz` - Fix pylint warning "Abstract class :class:`~filelock.UnixFileLock` with abstract methods instantiated" :pr:`135` - by :user:`vonschultz` .. _release-3-5-1: ********************* v3.5.1 (2022-02-16) ********************* .. _release-3-5-1-fixed: Fixed ===== - Use a monotonic clock for timeouts so wall-clock changes do not change their duration. :pr:`136` - by :user:`avian2`. .. _release-3-5-0: ********************* v3.5.0 (2022-02-15) ********************* .. _release-3-5-0-added: Added ===== - Allow a lock to be used as a context decorator. :pr:`128` - by Hirsch Alter. .. _release-3-4-2: ********************* v3.4.2 (2021-12-16) ********************* .. _release-3-4-2-added: Added ===== - Drop Python 3.6 support. :pr:`125` - by :user:`gaborbernat`. .. _release-3-4-1: ********************* v3.4.1 (2021-12-16) ********************* .. _release-3-4-1-fixed: Fixed ===== - Point argument deprecation warnings at the caller by setting ``stacklevel``. :pr:`121` - by :user:`XuehaiPan`. .. _release-3-4-0: ********************* v3.4.0 (2021-11-16) ********************* .. _release-3-4-0-deprecated: Deprecated ========== - Add correct spelling of poll interval parameter for :meth:`~filelock.BaseFileLock.acquire` method, raise deprecation warning when using the misspelled form :pr:`119` - by :user:`XuehaiPan`. .. _release-3-3-2: ********************* v3.3.2 (2021-10-29) ********************* .. _release-3-3-2-added: Added ===== - Accept :class:`pathlib.Path` and :class:`pathlib.PurePath` in the :class:`~filelock.FileLock` constructor. :pr:`110` - by :user:`f-koehler`. .. _release-3-3-1: ********************* v3.3.1 (2021-10-15) ********************* .. _release-3-3-1-fixed: Fixed ===== - Leave the log level of the ``filelock`` logger as not set (previously was set to warning) :pr:`108` - by :user:`gaborbernat` .. _release-3-3-1-documentation: Documentation ============= - Add changelog to the documentation :pr:`108` - by :user:`gaborbernat` .. _release-3-3-0: ********************* v3.3.0 (2021-10-03) ********************* .. _release-3-3-0-added: Added ===== - Drop Python 2.7 and 3.5 support. :pr:`100` - by :user:`gaborbernat`. - Add type annotations. :pr:`100` - by :user:`gaborbernat`. .. _release-3-3-0-documentation: Documentation ============= - Document asyncio support - by :user:`gaborbernat` .. _release-3-2-1: ********************* v3.2.1 (2021-10-02) ********************* .. _release-3-2-1-fixed: Fixed ===== - Changed logger name from ``filelock._api`` to ``filelock`` :pr:`97` - by :user:`hkennyv` .. _release-3-2-1-documentation: Documentation ============= - Add a tutorial covering lock acquisition, timeouts, and recursive use - by :user:`gaborbernat`. .. _release-3-2-0: ********************* v3.2.0 (2021-09-30) ********************* .. _release-3-2-0-changes: Changes ======= - Move lock acquire/release log from INFO to DEBUG :pr:`95` - by :user:`gaborbernat` .. _release-3-2-0-fixed: Fixed ===== - Raise when trying to acquire in R/O or missing folder :pr:`96` - by :user:`gaborbernat` .. _release-3-1-0: ********************* v3.1.0 (2021-09-27) ********************* .. _release-3-1-0-documentation: Documentation ============= - Fix the LICENSE link. :pr:`63` - by :user:`sharkwouter`. - Update links for the project's move to tox-dev. :pr:`88` - by :user:`hugovk`. .. _release-3-0-12: ********************** v3.0.12 (2019-05-18) ********************** .. _release-3-0-12-fixed: Fixed ===== - Fix package setup on Python 2 - by Benedikt Schmitt. .. _release-3-0-12-documentation: Documentation ============= - Correct the timeout documentation: zero means no wait, while a negative value waits indefinitely. :issue:`49` - by Benedikt Schmitt; reported by :user:`AnkurTank`. .. _release-3-0-12-packaging: Packaging ========= - Fix package upload to PyPI by keeping the license summary on one line - by Benedikt Schmitt. - Update the published PyPI classifiers - by :user:`hugovk`. .. _release-3-0-10: ********************** v3.0.10 (2018-11-01) ********************** .. _release-3-0-10-documentation: Documentation ============= - Fix README rendering on PyPI - by :user:`adamchainz`. .. _release-3-0-9: ********************* v3.0.9 (2018-10-02) ********************* .. _release-3-0-8: ********************* v3.0.8 (2018-09-09) ********************* .. _release-3-0-8-fixed: Fixed ===== - Fix a memory leak when acquiring locks on Python 2. :issue:`37` - by Benedikt Schmitt. .. _release-3-0-6: ********************* v3.0.6 (2018-08-22) ********************* .. _release-3-0-4: ********************* v3.0.4 (2018-02-01) ********************* .. _release-3-0-3: ********************* v3.0.3 (2018-01-30) ********************* .. _release-3-0-3-documentation: Documentation ============= - Render the animated README example through a raw image URL on PyPI - by Benedikt Schmitt. .. _release-3-0-2: ********************* v3.0.2 (2018-01-30) ********************* .. _release-3-0-2-fixed: Fixed ===== - Keep the Unix lock file after release to avoid a race between processes. :issue:`31` - by Benedikt Schmitt. .. _release-3-0-2-documentation: Documentation ============= - Add navigation to the README - by Benedikt Schmitt. - Clarify when to use a separate lock file in the documentation. :issue:`22` - by Benedikt Schmitt. .. _release-3-0-0: ********************* v3.0.0 (2018-01-05) ********************* .. _release-3-0-0-changes: Changes ======= - Remove the Unix lock file after :meth:`~filelock.BaseFileLock.release` - by :user:`spokorski`. .. _release-3-0-0-fixed: Fixed ===== - Reraise lock errors correctly on Python 3 - by Benedikt Schmitt. .. _release-2-0-13: ********************** v2.0.13 (2017-11-05) ********************** .. _release-2-0-13-fixed: Fixed ===== - Acquire the logger only when first needed. :issue:`24` - by Benedikt Schmitt. .. _release-2-0-12: ********************** v2.0.12 (2017-09-02) ********************** .. _release-2-0-11: ********************** v2.0.11 (2017-07-19) ********************** .. _release-2-0-11-added: Added ===== - Add official Python 2 support. :issue:`20` - by Benedikt Schmitt. .. _release-2-0-10: ********************** v2.0.10 (2017-06-07) ********************** .. _release-2-0-9: ********************* v2.0.9 (2017-06-07) ********************* .. _release-2-0-9-fixed: Fixed ===== - Add example :pr:`16` - by :user:`pypingou`. - Update change order of the examples (:pr:`16`) - by :user:`pypingou`. .. _release-2-0-9-documentation: Documentation ============= - Explain why the protected file needs a separate lock path. :issue:`19` - by Benedikt Schmitt. - Correct the Read the Docs URL - by Benedikt Schmitt. .. _release-2-0-8: ********************* v2.0.8 (2017-01-24) ********************* .. _release-2-0-8-added: Added ===== - Add logging for lock operations - by :user:`expobrain`. .. _release-2-0-7: ********************* v2.0.7 (2016-11-05) ********************* .. _release-2-0-7-packaging: Packaging ========= - Keep the README and license out of the installed environment root while including them in the source archive. :issue:`14` - by Benedikt Schmitt. .. _release-2-0-6: ********************* v2.0.6 (2016-05-01) ********************* .. _release-2-0-6-fixed: Fixed ===== - Clear the lock descriptor before unlocking on Unix and Windows, avoiding a repeated release. - by :user:`cottsay`. .. _release-2-0-5: ********************* v2.0.5 (2015-11-11) ********************* .. _release-2-0-5-fixed: Fixed ===== - Keep the lock file after release - by :user:`kramarz`. .. _release-2-0-5-added: Added ===== - Export :class:`~filelock.BaseFileLock` as a public class - by Benedikt Schmitt. .. _release-2-0-5-documentation: Documentation ============= - Show the complete public API in the reference page - by Benedikt Schmitt. .. _release-2-0-4: ********************* v2.0.4 (2015-07-29) ********************* .. _release-2-0-4-added: Added ===== - Export the platform-specific lock classes consistently through ``__all__`` - by Benedikt Schmitt. .. _release-2-0-1: ********************* v2.0.1 (2015-06-13) ********************* .. _release-2-0-1-fixed: Fixed ===== - Treat Windows lock contention as a timeout instead of raising ``IOError``. :issue:`5` - by Benedikt Schmitt. - Avoid an extra acquisition for ``with lock.acquire(timeout=...)``, so one release frees the lock. :issue:`3` - by Benedikt Schmitt. .. _release-2-0-1-documentation: Documentation ============= - Add a documentation site with recursive locking examples and an API reference - by Benedikt Schmitt. .. _release-2-0-0: ********************* v2.0.0 (2015-05-25) ********************* .. _release-2-0-0-added: Added ===== - Allow a default timeout at lock construction. :issue:`2` - by Benedikt Schmitt. .. _release-1-0-3: ********************* v1.0.3 (2015-04-22) ********************* .. _release-1-0-3-fixed: Fixed ===== - Handle an exception while acquiring a lock - by Benedikt Schmitt. .. _release-1-0-2: ********************* v1.0.2 (2015-04-22) ********************* .. _release-1-0-2-fixed: Fixed ===== - Do not time out when the same lock object already holds the lock - by Benedikt Schmitt. .. _release-1-0-1: ********************* v1.0.1 (2015-04-22) ********************* .. _release-1-0-1-fixed: Fixed ===== - Return immediately after a successful first acquire instead of waiting one poll interval. :issue:`1` - by Benedikt Schmitt. .. _release-1-0-0: ********************* v1.0.0 (2015-04-07) ********************* .. _release-1-0-0-added: Added ===== - Add a lock counter so the same lock object can acquire recursively - by Benedikt Schmitt. .. _release-0-2-2: ********************* v0.2.2 (2015-03-25) ********************* .. _release-0-2-2-packaging: Packaging ========= - Include the renamed README and license files in the source distribution - by Benedikt Schmitt. .. _release-0-2-1: ********************* v0.2.1 (2015-03-25) ********************* .. _release-0-2-1-packaging: Packaging ========= - Publish the README and license as reStructuredText so package metadata can render them - by Benedikt Schmitt. .. _release-0-2-0: ********************* v0.2.0 (2014-07-06) ********************* .. _release-0-2-0-added: Added ===== - Add file locks for Unix and Windows - by Benedikt Schmitt. filelock-4.2.8/docs/concepts.rst0000644000000000000000000020366015272561277013570 0ustar00##################### Concepts and design ##################### This section explains the ideas behind file locking, how filelock works across different platforms, and the trade-offs involved in choosing a lock type. ***************** Why file locks? ***************** Multi-process applications often need to coordinate access to shared resources. Without coordination, processes can interfere with each other and cause data corruption or inconsistent state. For example, imagine two processes writing to the same configuration file: .. mermaid:: sequenceDiagram box rgba(21, 101, 192, 0.16) Processes participant A as Process A participant B as Process B end box rgba(46, 125, 50, 0.16) Shared state participant File as Configuration File end activate A activate B A->>+File: Read File-->>-A: 1 B->>+File: Read File-->>-B: 1 Note over A: Increment to 2 Note over B: Increment to 2 A->>File: Write 2 deactivate A B->>File: Write 2 deactivate B Note over File: Final value: 2 (should be 3!) This scenario is a **race condition**. Process B's write overwrites Process A's changes, and the final result is incorrect. File locks prevent this by ensuring only one process can access a resource at a time: .. mermaid:: sequenceDiagram box rgba(21, 101, 192, 0.16) Processes participant A as Process A participant B as Process B end box rgba(230, 81, 0, 0.16) Coordination participant Lock as File Lock end box rgba(46, 125, 50, 0.16) Shared state participant File as Configuration File end activate A A->>Lock: Acquire lock activate Lock B->>Lock: Try to acquire (waits...) A->>+File: Read File-->>-A: 1 Note over A: Increment to 2 A->>File: Write 2 A->>Lock: Release lock deactivate Lock deactivate A Lock->>B: Lock acquired! activate B activate Lock B->>+File: Read File-->>-B: 2 Note over B: Increment to 3 B->>File: Write 3 B->>Lock: Release lock deactivate Lock deactivate B Note over File: Final value: 3 ✓ Now both processes complete successfully and their changes are preserved. Self-deadlock detection ======================= A common mistake is creating two separate ``FileLock`` instances for the same file and trying to acquire both in the same thread: .. code-block:: python lock_a = FileLock("work.lock") lock_b = FileLock("work.lock") with lock_a: with lock_b: # RuntimeError: Deadlock detected! pass This would deadlock forever because ``lock_b`` waits for a lock that ``lock_a`` holds in the same thread. filelock detects this and raises ``RuntimeError`` with a message suggesting ``is_singleton=True`` as the fix. This detection only applies to blocking acquires without a limit (a negative or infinite ``timeout``) within the same thread. Non-blocking or timed acquires raise :class:`Timeout ` as usual. Async locks scope the check to the asyncio task instead of the thread, so a sync lock and an async lock do not see each other's holds. Holding a ``FileLock`` on the event loop thread and then awaiting a blocking ``AsyncFileLock`` acquire of the same file polls forever instead of raising; give that acquire a timeout. Detection compares path spellings after resolving the parent directory, not the files they name. Two spellings that one filesystem maps to the same file, such as ``Work.lock`` and ``work.lock`` on a case-insensitive volume (APFS and NTFS by default) or the NFC and NFD forms of an accented name on APFS, count as different locks: the second acquire waits forever instead of raising. ``is_singleton=True`` keys on the same spelling, so spell each lock path one way. ************************ How file locking works ************************ File locking takes two approaches: **OS-level locking** (FileLock on Windows/Unix, UnixFileLock, WindowsFileLock) The operating system tracks locks on open handles and releases them when a process exits. These locks coordinate code that follows the same locking convention; they do not prevent unrelated access to the protected resource. .. list-table:: :header-rows: 1 - - Pros - Cons - - ✓ Enforced by the kernel. - ✗ Exclusive only, with no reader/writer distinction (use ReadWriteLock for that). - - ✓ Works even if your process crashes. - ✗ Unreliable on some network filesystems. - - ✓ Lower overhead. - **Soft locks / PID locks** (SoftFileLock, the fallback on systems without fcntl) A shared marker indicates that a resource is in use. The marker contains the PID, hostname, and a process start token of the holder. A process attempts acquisition with ``O_CREAT | O_EXCL`` and releases by deleting the pathname. The filesystem must provide coherent exclusive creation and directory updates to every participant. This is the same concept as a traditional **PID lock file** (as used by Unix daemons and the deprecated `lockfile `_ library's ``PIDLockFile``). The PID stored in the file identifies the lock holder via the :attr:`~filelock.SoftFileLock.pid` property and lets a waiter detect stale locks when the holding process has died. Same-host contenders reclaim a marker only when the recorded owner is provably gone. Every platform records a process start token alongside the PID, so a live PID whose start token differs from the record is a recycled PID, not the original holder. A live PID whose token still matches, a token that cannot be read, and a marker from another host all read as held. **Strict soft locks** (:class:`StrictSoftFileLock `) use immutable owner claims instead of replacing one shared marker. Each contender writes a complete private record, publishes a unique intent through a hard link, and publishes a unique held claim only after it wins the intent order. The winner rescans before entering and keeps both its intent and its held claim for the whole hold: the intent has existed unchanged since the winner published it, so a contender's directory scan is guaranteed to observe it, whereas a scan racing the freshly linked held claim may miss it. That stable witness is what keeps two contenders from both reading themselves as the lowest claim. Release removes both claims by name, so it cannot detach or delete a successor at another pathname. There is no queue: whichever contender next finds the claim directory empty wins, so a waiter can be passed over while others re-acquire. .. versionadded:: 3.30.0 The protocol keeps ```` as a permanent compatibility sentinel and stores claims under ``.filelock/claims``. A legacy :class:`SoftFileLock ` holder can finish before the first strict acquisition. Once strict mode activates the path, the sentinel prevents legacy clients from entering again. Every strict participant needs filelock 3.30.0 or newer. Legacy clients configured with ``lifetime``, callers of ``break_lock()``, and code that deletes lock files can remove the sentinel; mixing them with strict clients voids mutual exclusion. The lock creates ``.filelock`` and its ``claims`` directory with its ``mode``, plus search permission where the mode grants read, so ``mode=0o600`` gives ``0o700``. It sets the sticky bit (a directory flag that lets only a file's owner remove it) on both, so only the account that published a claim can remove it. On each acquisition it narrows a directory it owns to the mode, which covers one an earlier release created wider. Under a sticky parent such as ``/tmp``, acquisition raises :class:`SoftFileLockProtocolError ` when ``.filelock`` belongs to an account that owns neither the lock nor the parent. Where the platform supports ``dir_fd``, the lock reaches ``claims`` through a descriptor of ``.filelock`` opened without following symlinks. A symlink swapped in for either directory then fails the operation instead of redirecting it. Strict claims require coherent directory reads and atomic no-replace hard links. The class reports a protocol error when the filesystem reports that it cannot publish hard links. NFS and SMB remain outside this guarantee until their mount and server combination passes the protocol tests. Linux documents the hard-link lock pattern in `open(2) `_; Microsoft documents NTFS hard links in `Hard Links and Junctions `_. A ``link()`` that fails with ``EEXIST`` still counts as published when the claim name now resolves to the contender's own record. NFS retransmits a request whose reply was lost, and the retry reports ``EEXIST`` for the link the first request made; ``open(2)`` and liblockfile check the same way. Strict locks do not infer that an owner died. An orphaned, damaged, or newer-version claim blocks entry. filelock therefore avoids overlapping a paused holder at the cost of operator recovery after a crash. A strict lock also removes nothing it finds at the lock path in place of its sentinel. A soft-lock marker whose owner on this host has exited, a directory or symlink, or a file it cannot parse that stays unchanged for two seconds makes acquisition raise :class:`SoftFileLockProtocolError ` naming it. See :ref:`how-to:Use fail-closed soft locks` for inspection and recovery. Private publication records are not ownership claims. Each attempt uses a fresh random name. Linked private names are discarded immediately, while unlinked records left by a crash receive a two-second grace period before reclamation. Losing a private name makes the publisher retry; it never converts an unpublished record into ownership. .. list-table:: :header-rows: 1 - - Pros - Cons - - ✓ Does not require a native file-locking API. - ✗ Not enforced; it requires cooperation. - - ✓ Uses filesystem operations available on supported platforms. - ✗ Higher overhead than OS-level locks. - - ✓ Can inspect same-host PID records. - ✗ Network filesystems require deployment-specific tests for creation and cache coherence. *************************** Platform-specific details *************************** **Windows** Uses the :class:`WindowsFileLock ` class, backed by ``LockFileEx``/``UnlockFileEx`` over a one-byte range on a handle opened with ``NtCreateFile``. Processes using the same byte-range convention contend. The lock is exclusive and works reliably on local filesystems. Network filesystem (SMB) support is available but considered less reliable. Lock file cleanup: Windows attempts to delete the lock file after release, but deletion is not guaranteed in multi-threaded scenarios. Windows cannot delete files with open handles, so if another thread acquires the lock before the previous holder finishes cleanup, the lock file persists. It does not affect lock correctness. To keep a stable pathname across releases, construct the lock with ``preserve_lock_file=True`` (see :ref:`how-to:Keep the lock file on release`). An oplock (opportunistic lock: a notice sent to a process holding a handle before another process opens the file, which the opener waits on until the holder answers) on the lock file does not hold an acquire past its ``timeout``. The open reports contention, and the retry loop keeps the deadline. :class:`~filelock.ReadWriteLock` waits for its ``timeout`` on an oplocked database and then raises :class:`~filelock.Timeout`. Release deletes the lock file through a handle that does not wait for an oplock holder either, so a holder that never answers leaves the file in place. **Unix and macOS** Uses the :class:`UnixFileLock ` class, backed by the kernel's ``fcntl.flock`` interface. The interface is common on Unix systems but is not specified by POSIX. The lock is exclusive and enforced by the kernel. Works best on local filesystems. Network filesystems (NFS) may have issues; locking isn't always reliable on NFS. Lock file cleanup: the native lock file remains after release. A persistent empty file does not indicate ownership. Keeping one pathname prevents contenders from coordinating through different inodes, which would break mutual exclusion; remove the file only after the complete lock protocol has stopped using it. Filesystems without ``flock``: when ``flock`` fails with ``ENOSYS``, the lock switches to :class:`SoftFileLock ` and emits a warning, unless constructed with ``fallback_to_soft=False``. Before switching it removes the empty file only if this acquire created it. It does so while still holding the file open, with the exchange a stale break uses, so a peer's marker that replaced it is put back rather than deleted. A file that already existed may be another process's soft marker, so it stays and the soft lock waits on it. An empty file there reads as a holder that recorded nothing, as a release before 3.22 writes. A leftover one therefore waits for ``timeout``, ``lifetime`` or ``break_lock()``. **Other platforms without fcntl** Falls back to :class:`SoftFileLock ` and emits a warning. The lock is not enforced by the OS, but filelock includes stale lock detection (though without fcntl, this detection is less reliable than on systems with full fcntl support). ******************************* Which lock type should I use? ******************************* **Start with FileLock**, the platform-aware alias that automatically chooses the best backend: .. code-block:: python from filelock import FileLock lock = FileLock("work.lock") This gives you OS-level locking on Windows and Unix/macOS, with a soft-lock alias on builds without ``fcntl``. To require the Unix backend and reject its runtime ``ENOSYS`` fallback, construct ``UnixFileLock(..., fallback_to_soft=False)`` explicitly. The option cannot change which class ``FileLock`` aliases when ``fcntl`` is unavailable. **Use UnixFileLock or WindowsFileLock** when the application requires that backend or its platform-specific behavior. **Use SoftFileLock** when: - The filesystem lacks a usable native lock, and operators have verified exclusive creation and cache behavior. - Cooperating processes can tolerate shared-marker recovery and its overlap risks. **Use StrictSoftFileLock** when: - You need mutual exclusion a shared marker cannot promise, without a native lock. - Operator recovery after a crash is acceptable: it never breaks a claim by age, and a crashed holder's claim is cleared with :meth:`force_break `. **Use SoftFileLease** when: - Overlap is acceptable and you want a claim that *expires*, so a peer can make progress if a holder wedges. - You fence the protected resource, because a lease token names a claim but does not fence one. **Use ReadWriteLock** when: - Your workload is read-heavy with occasional writes. - Multiple processes need to read simultaneously. - You want a single writer while no readers are active. - The lock file lives on a **local** filesystem. ``ReadWriteLock`` is SQLite-backed and is unsafe on NFS. **Use SoftReadWriteLock** when: - You need reader/writer semantics on a tested shared filesystem that provides no-replace hard links. - Callers can stop protected work when ``on_compromise`` reports the hold lost, or fence the protected resource with the ``generation`` the hold was granted at. Lock selection flowchart: .. mermaid:: flowchart TD start["Choose a lock type"] --> question1{"Read-heavy workload?"} question1 -->|Yes| questionRwNet{"Network
filesystem?"} questionRwNet -->|Yes| questionRwFs{"Verified marker and
cache behavior?"} questionRwFs -->|Yes| srw["Use SoftReadWriteLock"] questionRwFs -->|No| service["Use a lock service"] questionRwNet -->|No| questionAsync{"Async code?"} questionAsync -->|Yes| arw["Use AsyncReadWriteLock"] questionAsync -->|No| rw["Use ReadWriteLock"] question1 -->|No| question2{"Need network
filesystem support?"} question2 -->|Yes| questionSoftFs{"Verified exclusive create
and cache behavior?"} questionSoftFs -->|No| service questionSoftFs -->|Yes| questionContract{"Which soft-lock
contract?"} questionContract -->|"Strict, never overlap"| strict["Use StrictSoftFileLock"] questionContract -->|"Overlap OK, fence resource"| lease["Use SoftFileLease"] questionContract -->|"Cooperative best-effort"| soft["Use SoftFileLock"] question2 -->|No| question3{"Need platform
specific control?"} question3 -->|Yes| platform["Use UnixFileLock
or WindowsFileLock"] question3 -->|No| default["Use FileLock
(recommended)"] %% docs/_static/custom.css colors these classes, once per light and dark theme classDef recommended stroke-width:2px classDef alternative stroke-width:2px classDef special stroke-width:2px classDef escape stroke-width:2px class default recommended class soft,strict,lease,platform,srw alternative class rw,arw special class service escape Exclusive locks compared ======================== .. list-table:: :header-rows: 1 - - Feature - FileLock - SoftFileLock - StrictSoftFileLock - SoftFileLease - - Exclusion contract - Enforced among lockers (advisory) - Cooperative shared marker - Cooperative, never overlaps - Expiring, overlap permitted - - Platform enforcement - OS-level (Windows/Unix) - No (file-based) - No (owner claims) - No (marker + heartbeat) - - Stale recovery - N/A (OS-enforced) - Reclaims a provably-gone owner - Operator ``force_break`` only (fail-closed) - Expiry + heartbeat; ``on_compromise`` - - Owner inspection - No - ``pid``, ``is_lock_held_by_us`` - ``claims``, ``force_break`` - ``owner``, ``token``, ``compromise`` - - Network filesystem - Not reliable - If verified - If verified (atomic hard links) - If verified - - Cancel acquisition - Yes (``cancel_check``) - Yes (``cancel_check``) - Yes (``cancel_check``) - Yes (``cancel_check``) - - Async support - AsyncFileLock - AsyncSoftFileLock - AsyncStrictSoftFileLock - AsyncSoftFileLease - - Overhead - Low - High - High (claim directory) - High (heartbeat thread) Read/write locks compared ========================= .. list-table:: :header-rows: 1 - - Feature - ReadWriteLock - SoftReadWriteLock - - Readers / writers - Multiple readers, one writer - Multiple readers, one writer - - Backing - Local SQLite - Generation log of immutable snapshots, committed by no-replace hard link, plus a nonce heartbeat - - Network filesystem - Not reliable (local only) - Works, including cross-host on multi-node clusters - - Stale recovery - SQLite transactions - A holder whose nonce stays unchanged for ``stale_threshold`` on the observer's own clock is evicted; ``on_compromise`` tells it, ``generation`` fences it - - Async support - AsyncReadWriteLock - AsyncSoftReadWriteLock - - Singleton default - Yes - Yes - - Overhead - Medium (SQLite) - Medium (daemon heartbeat thread + one hard link per transition) ********************** TOCTOU vulnerability ********************** **TOCTOU** (Time-of-Check-Time-of-Use) is a potential security issue: a time gap exists between when you check something and when you act on it. For example, this code has a TOCTOU vulnerability: .. code-block:: python import os if os.path.exists("sensitive.txt"): # Check data = open("sensitive.txt").read() # Use (race window here!) An attacker with filesystem access could create a symlink between the check and the use, redirecting you to read a different file. How filelock mitigates this: The defense differs by platform: - On Unix, filelock opens lock files with ``O_NOFOLLOW``, so a symlink planted at the lock path makes the open fail instead of reaching its target. ``O_NOFOLLOW`` does not catch a hard link (a second name for the same file) or a file renamed to the lock path, and such a file is indistinguishable from a lock file. :class:`UnixFileLock ` therefore locks a file it did not create as found, without truncating it or changing its mode. - Windows has no ``O_NOFOLLOW``. :class:`WindowsFileLock ` opens the file without following reparse points and rejects a symlink or junction by inspecting the handle it locks, so a swap between check and use cannot redirect it. Only the last path component is guarded; the lock directory must not be writable by untrusted users. - :class:`SoftFileLock ` creates its marker with ``O_CREAT | O_EXCL``, which fails on any existing name. Before reading a marker it classifies the path with ``lstat`` and treats anything other than a regular file as malformed, then checks the opened handle again with ``fstat``. On Windows it reads and breaks markers through handles that do not wait for an oplock holder to answer, so an oplock on a marker ends as contention and the ``timeout`` holds. - :class:`StrictSoftFileLock ` opens ``.filelock`` with ``O_NOFOLLOW`` and reaches ``claims`` relative to that descriptor on platforms that support ``dir_fd``. A symlink swapped in for either directory after the check cannot redirect where claims are created, listed or removed. - :class:`SoftReadWriteLock ` opens ``.rw`` and then ``gen`` or ``holders`` without following symlinks for every record operation on platforms that support ``dir_fd``. A tree swapped in after an earlier acquire cannot redirect its writes. It rewrites a holder record only when the name still leads to the file it created and that file has no second name, and it opens the record without blocking. It leaves alone a hard link (a second name for the same file) or a FIFO (a named pipe) renamed over the record. :class:`ReadWriteLock ` refuses a symlink at its database path. In a directory that processes of other accounts can write, it opens SQLite through a hard link (a second name for the same file) in a directory only the locking account can read, so a link or journal file another process puts beside the database cannot point the lock at another file. On Windows the directory counts as writable by other accounts unless its owner and every entry of its access control list (ACL) that allows adding or removing names belong to the locking account, ``SYSTEM`` or ``Administrators``. *************************************** What filelock doesn't protect against *************************************** **Lock files on the actual resource** Don't use a lock on the file you want to protect: .. code-block:: python lock = FileLock("data.txt") # ⚠� Wrong! with lock: data = open("data.txt").read() This doesn't work reliably. If you delete ``data.txt``, the lock is gone too. Instead, create a separate lock file: .. code-block:: python lock = FileLock("data.txt.lock") # ✓ Right with lock: data = open("data.txt").read() **Locks on network filesystems** NFS and SMB behavior depends on protocol versions, mount options, servers, and client caching. filelock does not promise one backend across those configurations. Use a soft lock only after verifying exclusive creation, rename, unlink, timestamps, and cache visibility on the target mount. Keep SQLite-backed ``ReadWriteLock`` on a local filesystem supported by the active SQLite VFS. **Locks across different machines** Separate machines need a central lock service or a shared filesystem with a tested contract. A shared pathname alone does not establish cross-host correctness. **Read-write semantics** FileLock is exclusive only; readers block writers and vice versa. If you need multiple readers with occasional writers, use ReadWriteLock instead. **Atomicity without locks** If another process doesn't use the lock, it will still interfere: .. code-block:: python # Process A (uses lock) with lock: # Process B might still write at the same time # if Process B doesn't use the lock Path("data.txt").write_text("A") Locks require cooperation; all code accessing a resource must use the same lock. ************************************** Trust boundaries and ownership scope ************************************** Every lock in filelock coordinates *cooperating* participants that agree to use the same lock for the same resource. None of them deny direct access to the protected resource, and none of them defend against a process that refuses to cooperate. This section states, for each public lock, what it coordinates, what owns the claim, and what survives ``fork()``, descriptor duplication, task cancellation, and process exit. .. _concepts:the same-uid boundary: The same-UID boundary ===================== filelock defends one UID's cooperating processes against each other's timing, not against a hostile peer. A process running under the same effective UID as the holder can read, rewrite, or delete any lock file or marker directly, whatever its mode bits say. ``0o600`` and ``0o700`` keep *other* UIDs out; they do not make a same-UID co-tenant safe, because that co-tenant owns the files and can bypass the protocol. Treat every same-UID process on the host as trusted to follow the protocol. Where that assumption does not hold, a file lock is the wrong tool; use an OS mechanism that enforces access, such as a privilege boundary or a broker process. The directory boundary ====================== Every protection filelock gives against processes of other accounts holds only while none of them can replace the lock's directory or any of its ancestors. Each ancestor must belong to the account the lock runs as or to root, and must either not be writable by others or carry the sticky bit. A process that can rename entries in a non-sticky ancestor can move the lock file or protocol directory aside, even one it cannot read, and put its own in its place. Holders that reached the old one keep using it while new contenders use the replacement, so two of them hold the lock at once and neither learns of it. No check inside the lock can rule that out, because the owner of such a directory controls every name in it. Keep locks in a directory that only the lock's users can write, or in a sticky one such as ``/tmp``. Native locks are advisory ========================== :class:`FileLock `, :class:`UnixFileLock `, and :class:`WindowsFileLock ` place an OS lock on an open file. The lock is *advisory*: it blocks another acquirer that calls the same lock, and it does nothing to a process that opens and writes the protected file without locking. On Unix the lock lives on the open file description (``flock``), so a ``fork`` or ``dup`` shares one lock and it releases when the last descriptor sharing that description closes or the process exits. Java's ``FileLock`` draws the same advisory boundary and tells callers to hold one channel per file; because filelock's Unix backend locks the open file description rather than the POSIX ``fcntl`` process owner, a second ``open`` of the same path in the same process does not silently drop the lock, but a caller passing its own descriptor through :func:`lock_descriptor ` still owns keeping one lock per descriptor. Ownership scope per lock ======================== .. list-table:: :header-rows: 1 :widths: 24 30 46 * - Lock - Owns the claim - Survives / releases * - :class:`FileLock ` and the native backends - The acquisition object, per open file description - A forked child or a duplicated descriptor shares the same open file description and its one OS lock; unlocking, or the last shared descriptor closing or exiting, releases it for both. * - :class:`SoftFileLock ` - The acquisition object; the marker names the holder's PID, host, and process start token - A crash leaves the marker; a contender reclaims it only once the recorded owner is provably gone. A forked child does not hold the lock. Release unlinks the marker. * - :class:`StrictSoftFileLock ` - The acquisition object, through an owner-specific claim - Never reclaims a claim on its own: a crashed holder's claim persists until an operator calls :meth:`force_break `. This is the fail-closed contract. * - :class:`SoftFileLease ` - A claim that *expires*; a peer may take it while the previous holder still runs - A heartbeat refreshes the claim; ``on_compromise`` fires when it is lost. Overlap is permitted by design, so a lease is a hint about who *should* work, not a guarantee that only one worker does. A forked child holds no claim. * - :class:`ReadWriteLock ` - The acquisition object; state lives in a local SQLite database - Requires a local filesystem the SQLite VFS supports. A forked child must build a fresh instance. Crash recovery comes from SQLite's transactions. * - :class:`SoftReadWriteLock ` - The acquisition object; the latest snapshot names its token and its holder record carries the heartbeat nonce - A peer evicts a holder whose record has not changed within ``stale_threshold`` and the holder's next heartbeat reports the loss through ``on_compromise``. A forked child loses the lock; the inherited instance is fork-invalidated and its ``release()`` is a no-op. Task cancellation ================= The async wrappers (:class:`AsyncFileLock ` and the async soft, strict, lease, and read-write variants) run each blocking step on a worker thread. Canceling the awaiting task while an acquisition is in flight rolls the attempt back atomically, so a canceled ``acquire`` never leaves a half-held lock. A task holding a lock that is canceled still owns releasing it; cancellation does not release the lock for you. Strict claims, leases, and fencing ================================== A :class:`StrictSoftFileLock ` publishes an owner-specific claim: each holder writes its own claim file and removes only the claims it published, so no holder can detach a peer and no stale break can delete a live successor. :attr:`claims ` names the published owners, and :meth:`force_break ` names the exact claim it removes. Because it never breaks a claim by age, a strict lock needs no liveness guess; a claim it cannot read without risking overlap raises :class:`SoftFileLockProtocolError ` rather than reclaiming it. A :class:`SoftFileLease ` trades exclusion for progress. Its :attr:`token ` names the claim this process published; it does **not** fence the protected resource. Reporting a compromise tells the previous holder to stop, but it cannot stop a holder that paused past its expiry and resumes afterwards. For true mutual exclusion where an expired holder can still write, the protected resource must be linearizable and must reject any operation carrying a fencing generation lower than the highest it has accepted, the way Chubby's lock sequence number and ZooKeeper's ``zxid`` are checked by the resource, not the client. A token names a claim; only a generation the resource validates can fence one. A lease expires on the contender's own clock. The holder's heartbeat rewrites the marker's modification time, and a contender times how long that time stays the same on its ``time.monotonic()``, the rule :class:`SoftReadWriteLock ` applies to its nonce. The marker's ``mtime`` carries the writer's or the file server's clock, so the contender only compares it with its own earlier reading, never with its wall clock: skew between hosts cannot expire a live claim. In exchange a contender that first sees a long-abandoned marker still waits a full ``lease_duration`` before taking it, and the filesystem must store modification times finer than ``lease_duration``. Malformed and legacy markers ============================ A soft lock treats a record it cannot parse as malformed, not as a holder. An empty marker is the exception: it is the whole marker of a :class:`SoftFileLock ` before 3.22, held for as long as its owner runs, and also what a current acquirer shows before it writes its record. A contender therefore waits on it like on any live holder. A plain :class:`SoftFileLock ` self-heals any other malformed marker once it has watched it stay unchanged for a short grace window on its own monotonic clock, which absorbs the brief gap between creating a marker and writing its record. It tells markers apart by modification time, inode number and change time (the time the kernel last changed the file's metadata). An owner can set a file's modification time, and ext4 hands a freed inode number to the next file, but no process can set the change time. A new marker therefore never inherits the time spent watching an old one. A strict lock never age-breaks, so it fails closed on an unreadable claim. A marker written by an older filelock stays readable: the process start token is an integer, so a 3.29 reader parses a newer marker as well-formed and stays conservative rather than evicting a live holder. Filesystem support matrix ========================= The contracts above assume the filesystem provides coherent exclusive creation, rename, unlink, timestamps, and cache visibility to every participating process. The table records where that has been measured. .. list-table:: :header-rows: 1 :widths: 30 20 50 * - Filesystem - Status - Notes * - Local Linux (ext4, xfs, btrfs, tmpfs) - Supported - Native and soft backends verified in CI. * - Local macOS (APFS) - Supported - Native and soft backends verified in CI. * - Local Windows (NTFS) - Supported - Native and soft backends verified in CI. * - NFS (v4 and v3) - Verified in CI - Mount every client that uses :class:`StrictSoftFileLock ` with ``noac`` and ``lookupcache=none``. NFS can still serve a removed held or intent claim from a client cache; strict acquisition checks its existence on the server before waiting. CI mounts one loopback export twice, with ``nosharecache`` on the second mount, and requires every hold to finish without overlap on NFSv4 and NFSv3 (retrying transient ``ESTALE`` during NFSv3 claim churn). The other locks are unsafe or unreliable there: native ``flock`` does **not** exclude across NFS client caches, and the poll-based :class:`SoftFileLock ` can starve under contention. POSIX advisory locking is unreliable across NFS, so also keep the SQLite-backed :class:`ReadWriteLock ` off it. * - SMB / CIFS - Verified in CI - A CI lane mounts a loopback Samba share twice as independent client caches. CIFS byte-range locks are mandatory and server-enforced, so the gate enforces the native :class:`FileLock `, which excludes and finishes every hold. :class:`SoftFileLock ` excludes too but prints ungated, since its poll-acquire can starve under contention. :class:`StrictSoftFileLock ` is not supported on SMB: its claim protocol needs an atomic no-replace hard link that SMB does not provide. The CI lanes measure the mount and server settings named above. Run ``tasks/verify_filesystem.py`` against your own mount to confirm the lock under your options, since cache and locking settings change the result. Migrating from timed stale breaking =================================== Earlier soft locks broke a marker purely by age: a configured lifetime let any contender remove a marker older than the lifetime, including while its holder was still running. That is a lease, not mutual exclusion, and it silently permitted two holders. If you relied on timed breaking to recover from crashes while believing it guaranteed exclusion, move to one of the explicit contracts: :class:`StrictSoftFileLock ` for real exclusion with an operator break for crash recovery, or :class:`SoftFileLease ` when overlap is acceptable and you fence the protected resource. The age-based ``lifetime`` on :class:`SoftFileLock ` remains for backward compatibility and warns at construction with a :class:`FutureWarning`, which Python shows by default. ******************* Design trade-offs ******************* Why not use OS-level locks everywhere? ====================================== OS-level locks are fast and enforced by the kernel. Their network-filesystem behavior depends on the server, protocol, mount, and client. ``SoftFileLock`` provides a cooperative fallback when its filesystem operations have been verified. Why does SoftFileLock use a separate file instead of just checking a directory? =============================================================================== A directory can't reliably be atomically created and deleted across platforms. A file can be created with ``O_EXCL`` (atomic, all-or-nothing) to detect conflicts. This is why SoftFileLock uses files. .. _concepts:how does stale lock detection work across platforms?: How does stale lock detection work across platforms? ==================================================== Every platform records the holder's PID, hostname, and a process start token in the marker, and a contender reclaims it only when the recorded owner is provably gone. A PID that no longer exists is gone; a live PID whose start token differs is a recycled PID, so the process that wrote the marker is gone; a live PID whose token still matches, a token that cannot be read, and a marker from another host all read as held. The start token is ``kill(pid, 0)`` plus the ``starttime`` from ``/proc//stat`` folded with the boot id on Linux, ``sysctl`` process start time on macOS, and the ``GetProcessTimes`` creation time on Windows. A malformed or unparsable record follows a separate rule: a waiter may remove it once it has seen it unchanged for two seconds, so a half-written marker never wedges acquisition. An empty marker is the exception and always reads as held. This is the rule PostgreSQL, Qt ``QLockFile``, and Mercurial converge on: break a stale lock only on proof of death. Two Linux processes do not always read the same token for one task. Since Linux 5.6 the kernel adds the reader's boottime offset (the shift a time namespace applies to the boot clock) to ``starttime``. A process in a time namespace with such an offset therefore records no start token and reads none. It falls back to the PID alone, so a recycled PID keeps a dead owner's marker until ``timeout``, ``lifetime`` or ``break_lock()``. A time namespace without an offset, as container runtimes create, keeps the token. A process that cannot read ``/proc/sys/kernel/random/boot_id`` (systemd's ``ProcSubset=pid`` hides it) records ``starttime`` without the boot id. A contender then compares ``starttime`` alone whenever either side lacks the boot id. A contender never leaves the lock path empty while it breaks a marker. Right before breaking it reads the marker's inode (the kernel's identity for a file, independent of its name) and modification time again. If they moved since the stale check, it leaves the marker alone, the way Mercurial re-reads a lock under its break lock before removing it. On Linux (``renameat2``) and macOS (``renamex_np``) it then writes a complete marker of its own under a private ``.break.*`` name and exchanges the two names in one step. A contender that polls meanwhile finds a marker and waits. If the file it took is the one judged stale, it removes that file and holds the lock from then on. A peer that exchanges after that moves the holder's marker aside only while it checks what it took, and puts it back. A holder that releases while its marker is aside leaves a record no lock parses, so the put-back lands a marker that ages out. Otherwise a peer recreated the marker, and a second exchange puts the live marker back before its own is removed. Contenders that race for one marker nest, each holding what the one before it left at the path, so the exchanges back run in reverse: each contender exchanges when the path names its own marker, and one that stalls exchanges anyway to free a marker only it can move. It finishes once it holds its own marker again. If the filesystem refuses the return exchange, it leaves its marker at the lock path for a later contender to recover. The private name is ``.break..``: the breaker's PID, then 32 random hex digits. A breaker killed between its exchange and the put-back leaves its own marker at the lock path and a live holder's marker under that name. A contender that finds the breaker's marker dead first looks in the lock's directory for names that start with ``.break..``, where ```` is the dead breaker's. It first renames the record to ``.restore..`` with its own PID, so only one of several contenders acts on it; a record whose recoverer died there is claimable again, and a claim a failed put-back left in the same process is retried on the next poll. It then exchanges back a marker whose owner is alive or cannot be proven dead, and removes one whose owner is dead. Where the filesystem refuses that exchange, the lock counts as held until ``timeout`` or ``break_lock()``. The placeholder a break writes while it works carries the same PID, so a contender that evicts a placeholder a killed process left finds the same names. The contender holds the judged marker open from before it reads the record until the break ends. ext4 gives a freed inode number to the next file created, so a successor's marker could otherwise carry the number of the marker judged stale. Windows has no such exchange, but it refuses to rename a marker its live holder keeps open. There the contender renames the marker aside, checks it, and links back a marker it took by mistake; ``link`` never replaces a name. Anywhere else without an exchange, including NFS and SMB, which refuse it, a contender does not move a marker at all, because the path would stand empty beside a possibly live holder. The marker then counts as held until ``timeout`` or ``break_lock()``. The ``lifetime`` and :class:`SoftFileLease ` expiry breaks follow the same rules, and a failed acquire removes its own marker by the same exchange before it closes it. A PID means something only inside its PID namespace, and two containers in one pod share the hostname and the boot id without sharing one. On Linux the recorded hostname therefore names the namespace too, as ``?pidns-`` with the inode of ``/proc/self/ns/pid`` in hex, the way Mercurial's lock prefix does. A contender in a sibling container reads the marker as foreign and waits instead of probing a PID that names another process there. A process in the host's initial namespace records the bare hostname, as earlier releases did. The namespace cannot be folded into the start token instead: a 3.30 reader compares the whole token and would read any new one as a recycled PID. A release before the namespace was recorded wrote the bare hostname from inside a container too, so a contender in the host's namespace reads such a marker from a container that shares the hostname (host networking) as local, where its PID names another process or none. Before it calls that owner dead it looks for a task in another namespace whose innermost PID (``NSpid`` in ``/proc//status``) and start token match the record, since ``starttime`` and the boot id read the same in every PID namespace, and keeps the marker if one exists. From a time namespace with a boottime offset it matches the innermost PID alone. Three limits remain. A marker without a start token, a kernel before 4.1 without ``NSpid``, or a ``/proc`` this process cannot list leaves the check nothing to find. Where ``/proc/self/ns/pid`` cannot be read, the bare hostname is all that separates namespaces. And a contender never reclaims a marker from another namespace: a container that crashes and is replaced comes back in a new namespace. A :class:`~filelock.Timeout` names such a holder. Containers that can crash while holding a soft lock should use :class:`SoftFileLease `, set ``lifetime``, or call ``break_lock()`` at entrypoint once nothing else can hold the path. Why is ReadWriteLock backed by SQLite? ====================================== A simple file can only track "locked" or "not locked." To track multiple readers + one writer, you need state management. SQLite handles: - Atomic transactions - Multiple concurrent readers - Exclusive write transactions - Persistence across process crashes This makes it the natural choice for read-write locks without adding network dependencies. The async variant (:class:`AsyncReadWriteLock `) wraps the same SQLite-backed implementation. Because Python's :mod:`sqlite3` module has no async API, all blocking operations are dispatched to a thread pool via ``loop.run_in_executor``. This is the same approach used by :class:`BaseAsyncFileLock `. The sync lock tracks its owner per thread, and the executor thread that runs a call does not identify the calling task. The async wrappers therefore track ownership per task, as ``aiorwlock`` does. Only a task's first acquire and last release reach the sync lock; the wrapper queues or nests the calls in between. Holds on :class:`BaseAsyncFileLock ` belong to the instance instead, so tasks sharing one nest into the same hold. How does SoftReadWriteLock work on shared filesystems? ====================================================== :class:`SoftReadWriteLock ` keeps its whole state in a generation log under ``.rw``. Deploy it on a shared filesystem only after verifying its required operations and cache behavior: - ``.rw/gen/`` is an immutable snapshot: the writer token, if any, and the reader tokens holding the lock at generation ``N``. Names are zero-padded so the newest sorts last. - ``.rw/gen/epoch`` is a random token the first participant creates, naming this incarnation of the log the way restic's repository id and PostgreSQL's system identifier name theirs. Removing the directory removes it. - ``.rw/gen/HEAD`` is the epoch followed by a copy of the newest snapshot, replaced by rename after every commit. Within one epoch a crash between the link and the rename, or two committers' renames landing out of order, leaves it behind the head, never ahead. A rename that lands after the log was removed carries the old epoch, and readers ignore it. Unlike a snapshot it is not flushed to stable storage, so a crash can leave it unreadable; readers then start from the listing and the probe. - ``.rw/holders/`` is one record per participant, carrying its token, pid, hostname, its ``stale_threshold`` as a lease, and a nonce the heartbeat rewrites in place. A peer evicts the participant only once the record has stayed unchanged for the longer of its own threshold and that lease, as the `DynamoDB lock client `_ waits out the lease the holder recorded. Every transition (a reader entering, a writer entering, anyone leaving, a stale member being evicted) is one commit: the participant reads the latest snapshot, writes the successor it wants to a private temporary file, and hard-links that file to ``gen/``. A no-replace hard link is an atomic compare-and-swap: one link to that name succeeds, the loser re-reads and tries again, and the record is complete before the name exists. There is no state mutex, so there is no critical section for a crash on any host to leave half done. A dead process leaves behind either a snapshot that still names it, which a contender evicts, or an orphaned record, which a sweep collects. A participant finds the latest generation by reading the newest generation that ``gen/HEAD``, its listing of the directory, or its memory names, then probing forward by name across a window of sixty-four generations. The probe bridges a generation removed between the listing and the read and a hole left by a committer that died before it compacted. It is not a bound on how stale a listing may be: an NFS client may serve a directory listing and negative lookups from its cache for up to ``acdirmax`` (60 seconds by default), long enough for peers to compact far past the window. ``HEAD`` covers that case, because opening a file by name makes the client check it against the server (close-to-open consistency), so a stale listing can never pull the start below the head the last commit published. ``HEAD`` and memory only say where to look. The participant reads the generation they name from disk like any other, because a committer stalled between its link and its rename can put ``HEAD`` back on a generation peers have compacted since. When the generation ``HEAD`` names does read back, the window past it has no holes, so the probe stops at the first missing name. Both are bound to the epoch, so an instance never continues a removed log's numbering into its replacement. A listing, ``HEAD`` or memory that names generations of which none can be read marks a client too far behind the log to trust anything it reads; the acquire then raises :class:`SoftFileLockProtocolError ` rather than restart the sequence. A commit also re-reads the epoch and checks that the generation it extends still exists, and counts as lost otherwise, so the participant re-reads the log instead of linking into a new one or into a slot compaction freed. Readers enter as soon as no live writer is named. A writer enters as soon as no live writer is named, which blocks every new reader, then waits for the named readers to leave. New readers wait behind the named writer, which gives writers preference among cooperating participants. Generation log walkthrough =========================== The prose above states the protocol; the sequences below show it running. A writer's first commit finds no live writer and lands on the first try. The lock file is ``/var/lock/app.lock``, so the log lives at ``/var/lock/app.lock.rw``, and the writer's holder record is named after its own token, ``a10f6c2e8b3d4a1f9c7e2b6d0a4f8c31``: .. mermaid:: sequenceDiagram box rgba(21, 101, 192, 0.16) Processes participant A as Writer (token a10f6c2e...f8c31) end box rgba(46, 125, 50, 0.16) /var/lock/app.lock.rw participant FS as gen/ and holders/ end A->>+FS: list gen/, read gen/00000000000000000007 FS-->>-A: writer=None, readers={} Note over A: write successor to .commit-9e4b1a7c3f082d6e5a0c9b2f7e1d4a83, create holders/a10f6c2e...f8c31 A->>+FS: link .commit-9e4b1a7c3f082d6e5a0c9b2f7e1d4a83 -> gen/00000000000000000008 FS-->>-A: link landed Note over A: holds the lock at generation 8 Two contenders can read the same latest snapshot and both try to claim the next one. Only one hard link lands; the loser's failure is the signal to retry, not an error to report. Writer A keeps the token above; Writer B's token is ``b20e5d1f7a2c3b0e8d6f4a9c1b7e3052``: .. mermaid:: sequenceDiagram box rgba(21, 101, 192, 0.16) Processes participant A as Writer (token a10f6c2e...f8c31) participant B as Writer (token b20e5d1f...e3052) end box rgba(46, 125, 50, 0.16) /var/lock/app.lock.rw participant FS as gen/ and holders/ end A->>+FS: read gen/00000000000000000007 FS-->>-A: writer=None B->>+FS: read gen/00000000000000000007 FS-->>-B: writer=None A->>+FS: link .commit-2d8f6b0a4e7c1930b5d8a2f6c9e0b174 -> gen/00000000000000000008 FS-->>-A: landed B->>+FS: link .commit-6a1c9e3d7f0b2854c6a9d1e3f5b7c082 -> gen/00000000000000000008 FS-->>-B: name already exists Note over B: re-read the latest generation B->>+FS: read gen/00000000000000000008 FS-->>-B: writer=a10f6c2e...f8c31 Note over B: retry from gen/00000000000000000009 once A leaves or goes stale A writer that arrives while readers already hold the lock names itself immediately, which blocks every later reader before the writer itself is granted, then waits for the readers already in to leave. Reader R1's token is ``c1e9b4d2f6a08c3e7b1d5f902a6c48e7``, the writer's is ``e5a1c9f307b28d4e6a0c3f819d5b7e24``, and Reader R2's is ``d4f2a8c6e0b3719d5a2c7e8f014b6d93``: .. mermaid:: sequenceDiagram box rgba(21, 101, 192, 0.16) Processes participant R1 as Reader (token c1e9b4d2...c48e7) participant W as Writer (token e5a1c9f3...b7e24) participant R2 as Reader (token d4f2a8c6...b6d93) end box rgba(46, 125, 50, 0.16) /var/lock/app.lock.rw participant FS as gen/ and holders/ end R1->>+FS: link .commit-3f0e8c2a6d1974b0e5a8c1d3f6b9e207 -> gen/00000000000000000005 (readers={c1e9b4d2...c48e7}) FS-->>-R1: landed Note over R1: holds a read lock W->>+FS: commit gen/00000000000000000006 (writer=e5a1c9f3...b7e24, readers={c1e9b4d2...c48e7}) FS-->>-W: landed Note over W: named as writer, every new reader now blocks R2->>+FS: read gen/00000000000000000006 FS-->>-R2: writer=e5a1c9f3...b7e24 Note over R2: writer already named, waits behind W R1->>+FS: commit gen/00000000000000000007 (writer=e5a1c9f3...b7e24, readers={}) FS-->>-R1: landed Note over R1: releases, removed from readers, holders/c1e9b4d2...c48e7 unlinked W->>+FS: commit gen/00000000000000000008 (writer=e5a1c9f3...b7e24, readers={}) FS-->>-W: landed Note over W: readers drained, granted at generation 8 Cross-host stale detection ========================== On a multi-node cluster, ``kill(pid, 0)`` on ``node-17`` cannot detect that a process on ``node-42`` crashed while holding the lock, and nothing distinguishes a replacement pod under a new hostname from a stranger. Liveness rests on a nonce: - Each holder's daemon heartbeat thread rewrites the nonce in its record every ``heartbeat_interval`` seconds (default 30 s), and a contender waiting to enter rewrites its own on every poll. - A contender records the bytes it read from each member's record and the moment it read them on its own ``time.monotonic()``. Once a member's bytes have stayed unchanged for ``stale_threshold`` seconds (default 90 s), the contender evicts it by committing a snapshot without it, in the same commit that admits the contender, and removes its record. - The protocol compares no timestamp across hosts. A heartbeat's ``mtime`` would carry the file server's clock and be read against the client's, so it plays no part; the one clock in the protocol measures the gap between two observations made by one process. Clock skew between hosts, or against the file server, cannot make a live holder look dead. - The heartbeat also re-reads the latest snapshot, so an evicted holder learns it on its next tick: the thread stops, the lock sets :attr:`compromise `, and ``on_compromise`` runs. A writer on ``node-17`` that crashes mid-hold leaves a snapshot naming it and a heartbeat that stops. A contender on ``node-42`` cannot ask ``node-17`` whether the process is still alive, so it watches the nonce instead, and evicts once that nonce has sat unchanged for a full ``stale_threshold``. The writer's token is ``a10f6c2e8b3d4a1f9c7e2b6d0a4f8c31``, and the contender's is ``f30c8e1a2d4b6970c3e5a8f1b2d4e607``: .. mermaid:: sequenceDiagram box rgba(21, 101, 192, 0.16) node-17 participant A as Writer (token a10f6c2e...f8c31) end box rgba(46, 125, 50, 0.16) /var/lock/app.lock.rw participant FS as gen/ and holders/ end box rgba(230, 81, 0, 0.16) node-42 participant B as Contender (token f30c8e1a...4e607) end A->>+FS: commit gen/00000000000000000009 (writer=a10f6c2e...f8c31) FS-->>-A: landed A->>FS: heartbeat: rewrite holders/a10f6c2e...f8c31 nonce Note over A: crashes, heartbeat stops B->>+FS: read gen/00000000000000000009, read holders/a10f6c2e...f8c31 FS-->>-B: writer=a10f6c2e...f8c31, nonce=7f2e9a01 Note over B: records nonce 7f2e9a01 and its own time.monotonic() loop every poll, until stale_threshold elapses B->>+FS: read holders/a10f6c2e...f8c31 FS-->>-B: nonce still 7f2e9a01 end Note over B: nonce unchanged for stale_threshold, a10f6c2e...f8c31 counts as stale B->>+FS: commit gen/00000000000000000010 (writer=f30c8e1a...4e607, a10f6c2e...f8c31 evicted) FS-->>-B: landed B->>FS: unlink holders/a10f6c2e...f8c31 Note over B: granted at generation 10, the crashed writer's holder record collected The generation a hold was granted at is :attr:`generation `, a monotonic fencing token within one incarnation of the log: a resource that rejects writes carrying a lower generation than the highest it has accepted refuses a holder that paused past ``stale_threshold``, was evicted, and resumed. The lock itself cannot stop that holder, so ``stale_threshold`` must still exceed any realistic pause a holder might hit (GC, syscall delay, filesystem delay). The protocol needs atomic no-replace hard links, exclusive creation, and a coherent read of a file opened by name. NFSv3 and later provide all three, and Amazon EFS is NFSv4.1. Where the filesystem refuses hard links the lock raises :class:`SoftFileLockProtocolError ` rather than degrade. Fork semantics ============== Python threads do not survive ``fork()``. A process that forks while holding a ``SoftReadWriteLock`` would leave the child with the holder record, the lock-level state, and no heartbeat thread; the parent would keep refreshing while the child would not, and both would believe they hold the lock. ``SoftReadWriteLock`` registers an ``os.register_at_fork(after_in_child=...)`` hook that replaces the inherited ``threading.Lock`` objects with fresh ones and marks the instance fork-invalidated. ``release()`` on an invalidated instance is a no-op, so an inherited ``with lock.read_lock():`` block can unwind in the child without raising. The child must construct a fresh ``SoftReadWriteLock(path)`` before it can acquire again. This matches PyMongo's connection-pool semantics. **Trust boundary.** The class coordinates cooperating processes. Directory ownership, ACLs, and ``0o600`` / ``0o700`` permissions can exclude another UID. Under a sticky parent such as ``/tmp``, acquisition raises :class:`RuntimeError` when ``.rw``, ``gen`` or ``holders`` belongs to an account that owns neither the lock nor the parent. The refusal also applies to a lock object that used an earlier tree: it repeats the check whenever a directory it opens is not the one it prepared. A process with the same effective UID can alter the records. A filesystem that delays a rewrite's visibility past ``stale_threshold`` can also trigger expiry while a holder remains active. **************************** File permissions and mode **************************** By default, filelock does not set explicit permissions on the lock file (``mode=-1``). This lets the OS control permissions through umask and default ACLs. In shared directories with POSIX default ACLs, this preserves ACL inheritance so the lock file gets the directory's default permissions rather than the creating user's umask. When you pass an explicit ``mode`` value (e.g., ``mode=0o644``), filelock creates the file with it. On Unix, :class:`UnixFileLock ` and :class:`SoftFileLock ` then apply it with ``fchmod``, so the umask cannot narrow it. This overrides any default ACLs on the directory. A Unix lock file that already exists keeps the mode it has. A soft lock's marker never gets the group or other write bits, whatever the ``mode`` or umask. Contenders only read and rename it, and they act on the record its owner wrote. Windows has no mode bits. A new file inherits the ACL (access control list, the Windows permission list) of its directory. :class:`WindowsFileLock ` honors the one ``mode`` that has a Windows counterpart: a ``mode`` without group and other bits, such as ``0o600``, creates the lock file with an ACL that grants access to its owner, ``SYSTEM`` and ``Administrators`` only, the ACL ``os.mkdir(mode=0o700)`` applies. The :class:`~filelock.ReadWriteLock` database, always ``0o600``, gets the same ACL. A file that already exists keeps its own ACL. Every other ``mode`` inherits the directory's ACL, as do the files of the soft locks, which ``os.open`` creates; restrict the directory's ACL to keep other accounts out. An explicit ``mode`` must be an :class:`int` within ``0o000``-``0o777`` that grants the owner read and write; construction raises :class:`TypeError` for another type and :class:`ValueError` otherwise. A lock reopens, reads, and deletes the files it creates, so without those bits it fails later and stays broken: a native lock on its second acquire, a :class:`~filelock.StrictSoftFileLock` on its first, and a :class:`~filelock.SoftFileLock` when it tries to reclaim a crashed holder's marker. ***************************** Thread-local vs shared state ***************************** Each ``FileLock`` instance tracks its lock counter, file descriptor, and configured defaults (``poll_interval``, ``timeout``, ``blocking``, ``mode``, ``lifetime``) in a single context object. By default (``thread_local=True``), each thread gets its own context via ``threading.local``. This means: - Two threads holding the same ``FileLock`` object each maintain independent lock counters. - Thread A releasing the lock doesn't affect Thread B's counter. - Setting a configuration property (for example ``lock.poll_interval = 0.5``) affects only the thread that performed the write. Other threads continue to see the value supplied to the lock's constructor; ``threading.local`` re-applies the original constructor arguments the first time each new thread accesses the context. - ``mode`` is the one exception: it has no setter, so construction is the only place it is ever set and ``lock.mode = ...`` raises ``AttributeError``. - The instance still keeps track of every thread's hold. ``release(force=True)`` also releases holds left by threads that exited, and garbage collecting the lock releases every thread's hold. When ``thread_local=False``, all threads share the same context, including configuration values. This is useful for objects passed between threads or for cases where you want a property setter to affect all threads. The shared instance runs one ``acquire()`` or ``release()`` at a time, so threads calling them concurrently, with or without the GIL, keep the counter and the held lock consistent. It is still a single owner: a second thread's ``acquire()`` nests into the hold rather than waiting for it, and any thread's ``release()`` counts against the shared counter, so balancing them is up to the caller. Async locks default to ``thread_local=False`` because the thread that calls ``acquire()`` (via ``run_in_executor``) may differ from the thread that calls ``release()``. Using ``thread_local=True`` with ``run_in_executor=True`` raises ``ValueError``. .. mermaid:: flowchart LR subgraph "thread_local=True (default)" T1["Thread A
counter: 2"] --- L1["Lock File"] T2["Thread B
counter: 1"] --- L1 end subgraph "thread_local=False" T3["Thread A"] --- SC["Shared
counter: 3"] --- L2["Lock File"] T4["Thread B"] --- SC end %% docs/_static/custom.css colors these classes, once per light and dark theme classDef thread stroke-width:2px classDef counter stroke-width:2px classDef store stroke-width:2px class T1,T2,T3,T4 thread class SC counter class L1,L2 store **************************** When not to use file locks **************************** **High-frequency synchronization** File locks have high latency and are inefficient for protecting variables that change frequently. Use threading locks or multiprocessing synchronization primitives instead. **Distributed systems without shared filesystems** If processes are on different machines without a shared filesystem, file locks don't work. Use a centralized lock service (Redis, Consul, Zookeeper, etc.). **Real-time systems** File locking is subject to filesystem delays and network latency. Real-time systems need sub-millisecond guarantees that file locks can't provide. **Very large numbers of locks** Creating thousands of lock files consumes filesystem resources. For that scale, use an in-memory lock service. ************ Next steps ************ - New to file locking? Start with :doc:`tutorials`. - Need to cancel acquisition or force-release? See :ref:`how-to:Cancel lock acquisition` and :ref:`how-to:Force-release a lock`. - Using async locks? See :ref:`how-to:Use async locks`. - Consult :doc:`api` for complete API documentation. filelock-4.2.8/docs/conf.py0000644000000000000000000001224515272561277012514 0ustar00# ruff:ignore[implicit-namespace-package] """Configuration for Sphinx.""" from __future__ import annotations import os from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING from docutils.nodes import Element, Text, reference from sphinx.domains.python import PythonDomain from filelock import __version__ if TYPE_CHECKING: from sphinx.addnodes import pending_xref from sphinx.application import Sphinx from sphinx.builders import Builder from sphinx.environment import BuildEnvironment name, company = "filelock", "tox-dev" now = datetime.now(tz=UTC) version, release = ".".join(__version__.split(".")[:2]), __version__ copyright = f"2014-{now.date().year}, {company}" # ruff:ignore[builtin-variable-shadowing] extensions = [ "sphinx_llm.txt", "sphinx.ext.autodoc", "sphinx.ext.autosectionlabel", "sphinx.ext.viewcode", "sphinx.ext.extlinks", "sphinx.ext.intersphinx", "sphinx_autodoc_typehints", "sphinx_copybutton", "sphinx_design", "sphinx_sitemap", "sphinxcontrib.mermaid", "sphinxcontrib.towncrier.ext", # render unreleased news fragments as a draft "Unreleased" section "sphinxext.opengraph", ] # raw output lets the browser render the diagrams, so they can follow furo's light/dark toggle. The extension picks the # theme below from the page background and re-renders on every toggle; neutral pairs grays with the semantic tints that # docs/_static/custom.css applies. mermaid_output_format = "raw" mermaid_light_theme = "neutral" # sphinxcontrib-towncrier: the .. towncrier-draft-entries:: directive in changelog.rst shows the unreleased fragments. towncrier_draft_autoversion_mode = "draft" towncrier_draft_include_empty = True # keep the docs build green right after a release, when no fragments remain towncrier_draft_working_directory = Path(__file__).parent.parent # sphinx-copybutton: add copy button to code blocks copybutton_prompt_is_regexp = True copybutton_prompt_text = r">>> |\.\.\. |\$ |In \[\d*\]: | {2,5}\.\.\.: " # sphinx-design: no special config needed # Read the Docs serves pages under ///; a bare domain base points the canonical link, og:url and # sitemap at pages that 404. html_baseurl = os.environ.get("READTHEDOCS_CANONICAL_URL", "https://py-filelock.readthedocs.io/en/stable/") # sphinx-sitemap: generate sitemap.xml; html_baseurl already carries the language and version. sitemap_url_scheme = "{link}" # sphinx-notfound-page: custom 404 pages (no config needed) # sphinxext-opengraph: social media metadata ogp_site_url = html_baseurl ogp_social_cards = {"enable": False} ogp_use_first_image = True ogp_description_length = 200 html_theme = "furo" html_logo = "logo.svg" html_favicon = "logo.svg" html_static_path = ["_static"] html_css_files = ["custom.css"] html_title, html_last_updated_fmt = name, now.isoformat() pygments_style, pygments_dark_style = "sphinx", "monokai" autoclass_content, autodoc_member_order, autodoc_typehints = "both", "bysource", "none" # _typeshed exists only inside type checkers; sphinx-autodoc-typehints executes TYPE_CHECKING imports to resolve hints. autodoc_mock_imports = ["_typeshed"] autodoc_default_options = {"member-order": "bysource", "undoc-members": True, "show-inheritance": True} autosectionlabel_prefix_document = True autosectionlabel_maxdepth = 2 intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} nitpicky = True # show-inheritance renders AcquireReturnProxy's Generic[_LockT] base; the type variable is private and undocumented. nitpick_ignore = [("py:obj", "filelock._api._LockT")] # Towncrier news fragments are rST snippets folded into changelog.rst at release; they are not standalone pages. exclude_patterns = ["changelog/*"] extlinks = { "issue": ("https://github.com/tox-dev/py-filelock/issues/%s", "issue #%s"), "pr": ("https://github.com/tox-dev/py-filelock/issues/%s", "PR #%s"), "user": ("https://github.com/%s", "@%s"), } def setup(app: Sphinx) -> None: """ Setup app. :param app: the app """ class PatchedPythonDomain(PythonDomain): def resolve_xref( # ruff:ignore[too-many-arguments, too-many-positional-arguments] self, env: BuildEnvironment, fromdocname: str, builder: Builder, type: str, # ruff:ignore[builtin-argument-shadowing] target: str, node: pending_xref, contnode: Element, ) -> reference | None: mapping = { "_thread._local": ("threading.local", "local"), "asyncio.events.AbstractEventLoop": ("asyncio.AbstractEventLoop", "AbstractEventLoop"), "concurrent.futures._base.Executor": ("concurrent.futures.Executor", "Executor"), } if target in mapping: of_type, with_name = mapping[target] target = of_type node["reftarget"] = of_type contnode.replace(contnode.children[0], Text(with_name)) return super().resolve_xref(env, fromdocname, builder, type, target, node, contnode) app.add_domain(PatchedPythonDomain, override=True) markdown_http_base = os.environ.get("READTHEDOCS_CANONICAL_URL", "") filelock-4.2.8/docs/example.gif0000644000000000000000000056006015272561277013342 0ustar00GIF89a� ÷   % +2= "+ . 63;&4(>$%%$')&)+*++"&1*.0#*?.35.4925616;49>;;;CHLSCKR/8C6D+?^ b##j:@F?EH?HIEEEAFJCJMKKKFMPIMRFQRKSUNTXMYYSSSQVZTZ]ZZZW]aY]bW``]ae_ch\hhcccadheimkkkglpimqnrvotxoxysttruyuz}yz|==º"L�v€z}�}‚…‚„…‚†‰…ŠŒŠŒŽŠŽ‘�‘”�“–“–™–™œ›œ�›ž¡ž¡¤¢£¤£¦©¦©«©«­¬®±®±³²²³´·¸·¹»º¼¾¼¿Á¿ÁÃÂÄÆÄÆÈÇÉÊÊÌÍÍÏÑÏÑÒÒÔÕÕ×ÙÖØÙÙÛÜÞßáßàââäåæçèçèéêëìîïðïðñ!ùú‚!ÿ NETSCAPE2.0,� þ H° Áƒ*\Ȱ¡Ã‡#JœH±¢Å‹3jÜȱ£Ç� CŠI²¤É“(Sª\ɲ¥Ë—0cÊœI³¦Í›8sêÜɳ§ÏŸ@ƒ J´¨Ñ£H“*]Ê´©Ó§P£J�JµªÕ«X³jÝʵ«×¯`ÊK¶¬Ù³hÓª]˶­Û·pãÊ�K·®Ý»xóêÝË·¯ß¿€ L¸°áÈ+^̸±ãÇ�#KžL¹²å˘3kÞ̹³çÏ C‹Mº´éÓ¨S«^ͺµë×°cËžM»¶íÛ¸sëÞÍ»·ï›6¬xA¼¸ñãÈ�ÃH^ŒΓ;‡þ|…õëÔ¡¯˜±ûôçÜgþ|çn�û óÕW°Èþ|xíï­/o?|xrî,`ˆ‡N¾üö÷α€ß çuà~ÎÍ@žqæ·ßvë}W^zð•×à ËaØœ}Óý‡ z(Ÿ‚×Á°žwô…7Þuæ¡w]„ôµ×xÚ§!sűø� $z7b{Ì­8!Ý‘—�â™øÞ’4þG�€Ú9÷qé©(#ˆEV§ŸƒÝ�(_ŠÛI‰FÉwjçŸuTÚˆ£‡9â‚÷]‰æ•kV%šä!cu}º‰cŽVÉ¢“w ˆß (²§�\÷à 0F™Ý“Ýi7(rYâé™O’¨_£HŠ g…�ú)’å5‡!uþ¯Æz¢”jŠŸŠ *Þ ¶_ DÁ¦Äçæ±l²Iœ�عd³Jš‰]ÍnÇ貌Z˜&µòA¸f}Ëözœ“ÕfZn�VËm‰ãB;d‰Ó†ù­}É»¬^™­�Êžºî¹­Ò»¯‚¦©_šÊ^+è¸ñY—¯ˆÍ2Wî¿–¢9ñ¹¯¬æu×2:e�ͦ뮻¬j|.Å玺_ .œœ}ùJl°Ä,æÛ\푇&\¬É(¯ *y6³kìÈvëc�ý›£ÆA›»^Ãóšlõ¼àyI`¶O×gcÒ=kl3·9¶«œÎ ¤‚Ó?‹û&§›+7¥Ô®[å¬MŸ=êþșθ&Ûûê|èÀêNë÷ÆÌJš±Üªrkb¦l3oÏÆfK¡ßcF˜÷†{7Z.”þN4¼a«W-͆"|¸¥ó’�eëC×®Ã%Ö�]åÝÚ]$¥�÷(ï¦ :¤?î3¶¦‰:âòuÍ3}™j©3µ€ªy¼„_ÌbΖíàâŽ#<¹µ‘w9"ö¥£OúÎÍ|x׳xo“_ºÌpÁ°›Kõ…?¿ÞJ ×^°¶µÐkäØíê.^�_&Ù•6A�íËxŸö*È<«iðj\[`¹xfANƒá#FÀp_—+×yHˆÂšA�ƒ‚K;ˆ1îð�þ5,\ÄX¯ÀÑðt0$"ßxøÃjÙ,=Ä1  ì³¶Q{Q[âÕÄ÷Á!1Š÷N'7Fv¯‰^ôaÑx:&p]Œ` ø²+bgŠjd£ÙB>úñ�€ôcµH ʰ…ˆ¬ ñ‚8ÈB²±ŒLT¡#¹G>6r’ ¡×‰GÉaò“CÔ$¥¨C4^”}ü¤ÕÉrÅñ\4Èp@ØÀ4È¥ f@µ•Ÿü%0(Ì�Ç d2‡ L)¾�™ÐŒ¦4¯³L«ý²šÓtd1¥xÀb3›Õú%çHÌs}œÆlÖ9ÑÈ4ØÁ~°Ã–ÐM�½òŽ+þ°�ªP'ðÓ Qp‚@™à&4S�BM&Ìu¦ÓœEçªN¶’¢Íh¹(:ElV³¡Ô´f8­sQŒ½�€ ÅgH5ªRs–´‚×´hBYŠQ€ms¥M\gLSÚRÖŸËì(¯INˆæÔ¦"5jK/ÚI<¾ @i7:M�Žô¨= iC— „7â«`ýj€`Ó)ÆQ˜1Ø�Zw0˶Î2—1ˆkeÑ›òT©Ej:™JRœVô¥}¥ª`óJÒ¦^•¡~õ&P ›Ø¾.4«)`¬JïúÓ¢´Š�ÍlG3Ù¤–“¥’5æfi ÑÇ:;�%­O‹Xd†ó—©ÝþèP‹×¬^u³³¥­m{MÀ†¶§ÇÌm8Ÿ FÓ‚6™7Eíb3*ÜÚ ¹‡=­bU „>„õº€èD«Û¨Í°*H Ö:K¶¶58ˆ«xT…*4µxÌ‚àI‡+ü•»£uìBq+Ú�>t¿ø-,Ý`¨�MðP üÜþ.ø¿vð~La u¯ ¾ðùŠÛ 3—»{upwì^¡2µÃ%ÜQ”F5ªüUñ„',c3·Ä…ů‰OŒc X¿!Vp�M¼\ÿÁ%®p’ëJã'X¿&òŒwSYÆ·¶Îø@+¼aŠwX¸U`…8pwÚ^A��ë$£ Íö³Buˆ[ç{ÚM�2„³,Zl�vŠV€ÃcMbjVúáBε·s\e÷2¼Ú×u�ëÌmŒÛ×=ö·Èþ£lm#ÃËQ 3Ègíï�?àn&y´‰ÍòQ·¼Ù×2¸M 7¸Ònà¹ÂOÛÑïbú9�e[Ç „x:ΑAÇ->E8@ú«o¸yÅqÎõ®s]ë^ß5ÌÃNö²›ýìhO»Ú1ýâµÙyí8Ϭsz«àJûâö;Ø×о·Ùù}ì³ÁîÍÂn÷$'ì��¼ØÑÎ┿½ëc—|æ×.Ú9Ûùò˜/ûæ%Op‚C×Mhä—€ê@¸anøX×{Gb ¸6ú¶q ôò2ýÓÛu®Ö+�þøïE¾ò“ïõÑ/ÿùÐ;Ì??uä‹¡ YÖƒdþÝî°ÏÛëhøÂàû}qÃ#^MЃŽ¿‚„G¿ÙÎ'û陿uЇ÷ýø¿¿—¡úâ§R=ÿ�7zGðwI¶ˆsPðUMpm²V`k�?´·ˆ}Àft—gg+àVpµ_°qµ1Ð^%Uîr@|_%)¨PvPe(XgŸ÷‚Yöv7ƒhL @ƒ2ø‚˜‚ Wƒ·ü‡ƒFh§PíUƒP¨ƒ:ƒ8XgHèƒD(„Y¸…Vˆ„RX…K†`HƒJȃlׄh8…6˜‚@8„fH„^øT&˜†(…ièxiP|�^ `°mPb`Ð}€þKp §ß·b |`Ô}*pj‰g urðzŒHoA C�‚h€T¨g@pÀvÀ Õn�|€Ær°z€Š:ðV@‹mÀxfÈ„*ð'¨GÐ*°zÐ0hvàŠ¸§fànÐy�uq°{€Šìf}P°Èn|‡Œs0Žv§ƒs&†ox‡©¨…ñ8ƒŸ×†`è†uøƒôH‡lØvn�™ŠF¨PÆX†o؃<è…÷¨„b–„Ÿ·ƒí8�\ø�EØ„aøŽX„C˜‘l¨Hà (…Là¸w‘%&ƒM’)Ù_¸† e×Åþ+°x]v �Ohi'±q;Un•^qµZ°!€P&„8èaAV~`‡ •„²¶‘)�ǘ�I•yŒ i•jhƒP�a7…=i’U‰†.)�ûèŽÙ–<™�ïx†Zi•pˆ‚¢uƒ[©PO%•TÈŽÄè–W èŸ˜` …Lm) d Pî§x@o`4°p *°wue°4©6ÐY°9@w‡„‰ §\p‚hÀ>°Œ~Àx/°]3pV p°‹¦<g`n@Äø—©FÐG Aà^H°ω}ðœhP�ù¨þ Z°6>HÀÓ9iæ¨3À È}€˜x —.9�É–xÉ•ðÉ“ôÉvd(–ùb_I‘ý™–»–œuxŸ|‰„}‰†þǃuH@c–  úž-é–Y…9Ÿ\x‡YùvPà°¨¡~Zƒ¹’v–’ ºkJV€À e�×¥Œögg(±„ü×�oõièuqe‚ù–Pe]`Å”NÙ£xé…û –üÆ–ù–6h Q(•IJ–û™¤`‰¤R8¥öY�Xy¥ü·¤k¨¥>J¤VФaZ¥ÅؤVª¦SÚ¦Sš“=é¥ùRZ¦dª¥i§rÊ Z§az¥Xj¥V€þîj mÀàÉ>ˆ‚b0Æ™y€{yðu&wPž73U=/ptp¥yð‹Kðy`:Ÿg€(ÚÈ“á§eÏ©P›š •nà¦RiŒI§P5p~ŸgšYgj ™§øv|@ `q0yaà^i ™íöW0KjVP§\º¥ Ê«k*®LÚ¤Xª„lŠ‚2˜§ëŠ•æŠ®z¤v–œ9 ^Z¥[y¤�jƒ{™§Jê¦ä:ŸóЧ{*°ëê¤k°JÊ^k8’':’åX°©P$jƒÊ’þjŒ)ú€€¨.V0J¦jƒ§?WO—VUþà}Ù¥u¶‚,(çj¯ò—(¯óJ„4z®ê„Åè³_ˆ²]ª°Yš–m:Œ?»´þzyA(®k¦gÚ’õŠ´÷šdeʳv: а¼Ê°a¹¤T¥V`³v– Ç7Yb\ðU,J�Én.“ €ÀzзyíÉX�� _°PxyàG°ƒ¿š¦)p`U)°â7k q _PhŠ'�«� ZæV‡^€]×%~6(¬†{�ªo Žz)`“[gó–¶w ô»`Œ`€ŠÝçà�z@šåÚ³ê •(øµM¥| UK ¯Rº¯€þZµVû…eûvÓÛ£îõ»_»¯hš¤ïšµ`°ØË´Ð+¾r´J{µI½;K½[[�(H±+½\«Pɱ¨š’§ªƒ¿ “aˆ49“6yµu6£¼ú�W*€-ëð»uµçŠ�vuXp°œµÀúÁÊ{°Ý+ÂGkÂ<´!ŒÂ,<¥¾ê«�z³1ܼÂ4|°3¶0ì«-ü‹6(j`gyp²é¥Ž ™pU¹›»Iº­üÆ ŸºXp·ü‡Qpy0i*¯Pµª¥Ù»ùá×İ›jƒŠ˜Â;ÌCð”*à‡JÆš U÷’Ëz€`j~°þh©Å˜`ðÝ·w<ܰ7<¯6|ÃÀšÃ.<´p9°4Ã0ÌÆ[,Â*l®HÚÈ‹üÉ6èÉŸŒÃtªÂË�¾ÜꜯPÞÎÙ.ÚÞ~Ü<þÜ”>åß¾ëÎ�éÏ]ܱmƒº.ä‚^ëån¾én]ä²é\=½×Þä­æ_zê ÏÃôÌ‚`Å:ð°�Ø¤ÝæàUX@þØÌÌÛà[ñÚ^ñ þæ°-ò!_ä^îå‰^Ú)`ò�ò)ïå¹­ò!ò'òg.ó3/ƒ7oó_ó5¯ó9Ÿó6Ïï3OÜ?óF¯ô$ïô¿½òn¾ÚÉžô×®Û?oñlþô'Ÿp¯Pl©\¯óÞÎõ)Oô!Ÿ¦µMæeOátúòÄlôM¿îR_ò­Žõ(Oö -Û‰žíW¯òcäYOòQÿ/ÏÜ)LøŒOögïøM?ôißÍ0æV¿ó…ÿômOô™_ò¾}ù7_ùs¯ÛlöX�øG¯õ�OøeïóV�ö©oõ5ŸÈÀ¿úeÏøÉ¸Á�ö¬_oñ3jê)ÏÀÆÏ�üpÌÏüþPù,_ôƒÏóPÿûUÏù<ÏÜ¥ßÜ—¿æNoêMOæ-û;o湿òÍú¯ôn÷MõäOÜrÏô%ûÒþíó'?û#¿ä@ŸúâÃ@�Vâ ü  Ÿ5+&ˆ!ˆˆ'NHQ¢Æ‡F¬ø‘ ‡�3^,)åF‹;‚„ˆÑcÊ”=ªh9±CEœ/#z˜9hL˜*�ždÉ‘cÇ–KöüõC‡$!R}j2)Í¢!½²tùr&Ï�=îlšV P©H…–µ˜¢kL›n7VÌR¯L¦QµJ” D«9‹ª•¨N Æ�ÃÑ‘µ® Ê•-_ÆœY³e›;pþðÐ�phÑŸ9ˆÚYõjÖ­]¿†ý:ulÚµm߯�[÷îÝפË[øpâÅ� ߉öøræ¸g7×]˜urà± COø|8E¬ƒ‰#)cÇ�;e�¼±Yýzõ²j׎]>n¬óíßÇŸcrÕñõÿ0Àºvâ@@4ð·£Cp8ÿn›­>×Òc¯B <ðA 74NCéÌ? ?$±DOD1EWôÈ _\�Eg¤QÆŒø«qEsä‘¶{2Hâ`$23!�D2ÉáºS²I'‘{2Êä°H++Ë‘J)·Ü(ï˜Ó’K1Ç$³L¯¼ÒL5×D1LÏØ„³ªAØþQ?7§°Nµ H4­ÜíÎ8ÍOPBmÐPD3ìLQ„… R(¡“T¾?‹ltQM'ÒóÍM픸J?%uµêJ=ðR"s Õ¸QQ…ÕÉW œõÉZc…óV$U…W_vE]WÖÇãn-6ØEy}QYg�L¶ÑSmŠö¿jŸ [`¯¥�Ù µE’[`§-\sÏ·>o+D·Ý\[5Q\wç¥÷¶hS[—½Úà­wÐ0åí7`�͘¾Õò�ñ¾‚yã×¶….õáHe”bÕ ®˜ÒnÂŒ…µ¸Ç�C¾øÙ:G&¹¸[9Þì6�pØ'<ÎÃNB7®øŠ6°ƒ‚ ­þxã¸3º(qŽ#T<ã _+F9br›cºÍAYÖ,¶6.@pb°™�ãÂ17lºùƒŽÎÙ¡Ü^ø£‰î°b†#ƒŽ @þðã1èn h¡½àö7úðƒ%V{Bí–@h‰äÂg„Ä8c “žå¦35¸d{Yk8eM©6¶6´ h)ò ™¸È)â»ì‰ò¸a·5À@(?ÔÆ/ò�z°Ã‹Úü6î á§ ½39Ìxá…#jï =lè 3h{ú6ß±£rÕ0½K7‡˜|æHÇÌj-$Â8˜™*+ðèà èqóÝ£-ð࣌²­€ƒ “<ÐO¶aBþ24|€ ‚ö`‡ó°� Aƒ ØÝäÏbhà€>èa ¹#a.è@8`0 �ÊÐ=`A wè¢4ðÐËCâðï0°C>;èëË�1èAdP˜,ð¡zûÀ×ÌÀ…ÁáÁ ÑÿÌ`†3BÁ{xb%7Ê drèh†9+ a |ÈÑ,'…�dæ3äpÐw«m !í£ÈÌ<€„=ìàb8Hnò‡&ìÁ Ĩ‚@PO7*ØÃÑT ‡óÀ‡!œ“¦ùéì@†¤`7\X©ö°?œ!V +fcÉÇÄ ¾œÔÒBú¨}!²5¶=J±d:/Ð`3ÁK=ûµ7øa´¤ý$þÖà‘7@ñÿ;ˆ �Óܤ }P�.k�tá,탃8/�*ðC`i“?¬á…\Xî@� Á.ÀÖ› ´ø°³�pw ~H®*72ÈAwZ[wý®ð+ÃÖ@]Ýá ÄD«aÅšò võ+d•6=Í Jˆ’91HøqštÃoK†þÊy¼JäcZ’…jÌ5ŽÂíJ CÉ+ÈÇò|Š\›ú<èȰÑ-e¬ÖÍ„Ü8a]Ðð…$oƒ}ý€•é ÇRðƒÛx�+ôX"gàBz�=¸ñ³sTÂÕêµemáSgP‡n>p }Hî*ç†2à¤ÍùÚ@ÎP†0cpón´ÐÂß$/þ0Bòp4ðôh3Eˆ 2dÚ6ð…J¯�`‰ìÕ�­½ä '6Û'϶8IvW”1e‰XYfç¡deW 9.¥ zˆ !ʃx Kà[m¸p‡Ô®Y™|°N|€“(Ðá@AÆ�¼üþÍ` -$¦1ípÆŠ:3³~óA*kè Úž•óƒß�àQëÕ €ë V°Küy_YAh Å(í r¨Ý ’ËÆ©` rÈâ‹Q„tz z¨ÝqTöWÐ1þnÉØƒ[*‚Cl]Í3ê–¾º’�½œ^oÉMÂ&6B¬,�€v€‰ÆCWm¶Æ¸�*\ÐCÿÎ0ædÁŠê¥� �â5SqèCà`΢¯PÄ5™5㦧A€ÃµÐ“öÁ…Nðò0)¸ÙßP´7í@‡­öxä’>tHB=À¹Y�öð‡<€'*ø‚ü€‡Âbþáë3 !*â’­ûãÃÞÀrÊ¿áò¹ÙB| b ¼¡|xÃãøø†2ÒÁj*ú˜†t£çéã; µ¢ÿ‡aKRžMun+~3?XÉ÷~�ž~zÍ 8a‚f�í§ûIZ~ûIô*I¡,þ´‚þ¦¸5~ò£‹*7�ý¢&@�÷ —þëðCa“:ê[�>9DÀ <—û£@ñ‹2 ¼Àü«¾5ÑÀ ÁôþÁô?;)A ”DúÀ©Ì 4ÁÑ•l‘þ³Áœ-ŽåKn‘ÀY  Ä><’4Â$\ô„(Âxú€'| 7ñ)þ‘‰­Î€@ëð)¤–¢‚ÂÎH!$–¢º/„q TAB%tC$Ë þC€9¤Ã:´Ã;ÄÃ<ÔÃ=äÃ>ôÃ?Ä@ÄA$ÄB4ÄCDÄD€TTÅUdÅVtÅW„ÅX”ÅY¤ÅZ´Å[ÄÅ\ÔÅ]äÅ^ôÅ_Æ`Æa$Æb4F_ä Œˆ �flF€Æhd€htÆjtÆÀÆlÔÆàÆ€ÆnôFÇo„Fm,ÐÆjPj�Y;5ÚŠ¬YýT-^Írýbè]WÛ4Þp6€-VW¤\Ñ4®×_sßÝÙ~ÍÑóUÅ'ø�3x[Wþü‚{a28ÛAÖç @€ÍýdµeE Pƒ0ø@fàµU‚æçÆNƒ�è{¦çfUVƒ„þ2à�¶hþ`0‚þueìœå”Vé•–ØÖØB› Ø>Hhœi»Ôå^íå)ýåÁtVÀœÆƒ,Ó¹ÇkÔÝ{ì]ß…YàíLýÌj®â íÓoÕY �Ô.ÞH—4WCÕêžõÖ16Q‹ôÉ ¤Mß4gàTã­,Î6ŽÊ£¤Qp×çLN°XµÜH‚…nÅ|.d€&ƒTƒõÕìFßþeq ¯5¸ìNÆVþƒõå�/øX,íÔ–ò)§òT\mªimh•íê†m7%Èg–î…|fàîë&nì†mÓ=î»Ìm„,HÈî2�îÛó„Üòëîn=×óÝöî?€™VêÝnÈðLó6ïTLt`ﲞÔ¯æúΦtQB•€¤%ë§ð ïɸÈ�Po-pW_ðSpM‡ô7JØt Ÿ×)#3 “†Ü 1)x‚�Fa >`¶5/0áW€$ 1,@ XìÏþèXv)à��É.Ü!=r¨38ƒÑnÜ3`ö&ƒ#ÏvOe¼nÅ(¯òvw÷Y¾r–Éòhþ•mëöm0§÷hÍíÌŒm2mÏnŸÖa:gî8‡îÌÔòz7sƒïó?ÿs»´ËŸŽx‡$tòDgEG‡uNoPö®ô÷&Ô —pM·ïã Íp›=�Qgy8uToð�—yX_E‡XÑFœoÅ3ò“>Òwú Ýxç‰Xb7ï~¿îg.Уß÷�,óßVÞ~R„ÇLèFó§Ïm3¯÷<ßn°wøïoámȰïD/oó¶p4NÊüŽôôvoB5âÔHöÖo Ï{§þo� É–õ—gð¼�ù‘7ü˜wð™/ëU|‚{~Ø`ÿEÈWŘd(Wa¡ÇüÌGX¢G˜þ,Ð~¯nÞóÏ?zén©Oz­'óâ6]äFÌ0‡zìþmØ�}…Gó>ßó±çîŸ^XPC'EÇxU¬psÖøõ–{KõÞäe~Jut½Ÿðý6Zÿöû›]yÀ7uÁ€y}õÄŸùîMÊ–ü^ÔyUäѶüWÖüõg&åü|qÂ/Ȩ÷ú¦Ç÷ÌlóXý…7ø2‡xi"D,(È  Á‚ 2l¸à Á‚P¬˜€b‚jì8à#H� FŠÉ $ƒVª\¹̘àà&Μ8#Üäé Bƒ B‡êÔ)áh„£’&UêôhÏ¢ÜlPµªP ´ =þ�â+ ¯aQ(kö,Í´j\µÚö­T©Tãê”i÷.Þ¼z÷òíë÷/àÀ‚.lø0âÄŠ3nìø1dÇ&S®lù2eAš7sîìù3hΕ#“.mú4êÔªW³níú5ìØ²gÓ®m›/æÜºCóîÍ{ôíà‡/nü8òäÊ—3onX7t˾§SÜ9öìÚ·sïîý;øðÆ£“ÿPý|èëâ׳oïþ=üøòç#.=þÎêéóïïÿ?€ 8à`öA—‚ÖQF ƒ :ø „J›�»%ˆß~j¸!‡zø!}¹u`à…."Š)ª¸"‹-¶Van%¢—¡‹5Úx#Žþ9¢#f2žG£ŽA 9$‘E‚Çãe>V¤‘M:ù$”Q¦†¤tJNǤ”Yj¹%—]@eeV^y¢—ešy&š5‚ù�æ‰Ù–iÊ9'�uηædoÂI¦�}úù' Ùá馞 Å(¢‰*º¨iƒšŸŒJ:)¥• æè£Ÿj)§�zú'¦™êé§¥šz*š¡ŠºÙ¦¨ºú*¬8ªºª‚“Åz+®¹ª‰'­¢‘ª+°Á Ëଫ¶:,²É*ë��#öØ«fÇ.;-µÕW¬¨ÒZ»-·ÝªfŸ³ÏB«­·åš{îaØfJ.ºíºû®]ê>J™ðÚ{/¾xÉ[(»ùúû¯°ûêÙ/þÀìªÀo|0à WÊæšÐFû«Ã[üiÂb.|1ǧ¹æˆ×ú�Ç%›¬hÆVn|2Ë-™²’+»<3Í.Âì£Ì5ë¼s‡7˘3ÏA Ý Ä`Š ôÐI+ÍŸÏ%"½4ÔQ¯W^¸âöú´ÔYk-(¯c½5Øa�×õ¸‹}6ÚÜ5}á×i»ýö·d_m6ÜuÛ}ÛÚ ¶}7ß}–7‚{û=8á{žŸà…+¾øá&Úº8ä‘3ÖøŒtK~9æzQþ£å™{îùæKvþ9鑇N]⥫õéc>¾:ì—·î[ê±Û®3ŒUçéõè·ûŽöì{¾þ;ñvÿ[ïÅ+unmÚwþtòËK?ôñ�?=öYWohôÙ{ïòöšvÿ=ù%‡ïYíå«�.¸–}ýúñ·|þ¨ðË¿Çôûj?þýW�î¬F«ôù¯€“ÛÇgÀžK¬R #È-NŒ¼`»(82 rÐ^$`CH©BP„&¼Õ�wÂn‹„d! uåB’Ű†Ãš¡ s,éz/Ô!=Å#çe†w? "Gˆ@c•0‰N¬Ÿ(EK�ŒPs;â³h§(j±‹ˆâ¢ÃØ'0бŒr"£ÓX&4ª±�Zb£ãø$8ʱŽ/[b¶šhÇ=~ˆj2" ù(H!Ñq�†\Q!©È>âþq]z\$$”ÈHRB“¬$& tÉLrò?åñ@‹ø¾@v²”Äjä¼iÊUvg“¬|åÔPÉ/U²–Ëq¥-s©\ê²—Ìá¥/ƒy` ³˜Â‰iÌer-bÊd&4o)Ë�Ñ2šÖ, 1¯©MÔds›ÞŒL7¿)ÎÉMSaÕ':/UN��3�îìK8ß)Ï¿ÄsžöÔÜ:UÖÎ{Þ³žüü'LŠF%’ -P>c¶Ïƒ¦ÓŸ �§CúΈJ´¡ ÅÙB+êMŠjTœíèF/ú³Œ‚Ôš-é5OŠÒh6+FÏ\)H«HP˜–T¥45¦Mo*ÌœêÔ—<í©. T[ uþ¨°Ä Ë3S£3¡tß ÊÔ~ŠÔi$�j-5HD¬´¨\í¤W¿šÉ°Šµ’d-k$ÏŠÖEªu­‡l«[ ׸òq®tµ£]ï*ǼêÕ�È|ÞKûºÍf%U”Qì7ùŠX3*v±bl¬c½ÙÈjq²”�¢e/ûÄÌj6‰œíl? Z�ÒŽ{\}Íîr›ßì’ÃPÁ'øžºj–‡ÐåóÕ82gNsJÜ 0Wy·wNkË8{á8zº…Îî€ß|ÛHw¸Òu+`”{Ú’Žzoõ+ð”SüêXÏö¡©.r«’üëʱ±¹nux›=âsþûjËÞößûØU7ŸçnÀ“ûÜé]g»ÞXwc3üéý~xà½7øŒß½JyO<þNþ…Âÿ}å�ø£_ÝðÃ_ó•þµá ŽøÎ/oðÏÆ;¨Iÿq�n]í¨7µêåW÷ùú}í–�ýê+c÷£W>è¸Ï=eÄÍ&géü÷Ù³ùé5ßä/zãÃDîæKoöÉ—þã©?½b#:ô½öWÇåÓ»÷I¿âß¾yÑ£?ý£v½òaßþê«ßû#ÿüAWóC=ÿô¿$[öùßí´Ûú}–žÂýÿq^ÏøéïÙžï= Ý-`í½ž½Y íHœÀÁ�Þ°ŠÍ‹þ9ŽŽ ظüa_ê© êô\ –üÁ` Rƈxáižý‘þÙ ßôØj`ÅaéØRàùáRÙž ⤠.�ä%!zÝê_æÍ`޽`Jιõ`Üýà¾Íø‰ÝÞ_jaeì ÃÉÜô­!áX²å\ÊaîŸöšŽ^!àõ!ãüaüm  Îaª¥¡"ä„a²#*Ž#î¡F""¾_bZbß´[ùâíqb'NÝõÑ !ŠbÝ a&º` ¢b*²Ùj"ºâ+ :ÒbÒ°`ô™â&ê¢Û¸œÀ­¢/Î"0¦M³=¡"Žá1Öâþçõ],²â)6cØ€Ø0ÞbåÈ5² Ú¡tx€ÕË6"ãÔ¥6rŽ6Ž£ùh�±¥×9ŠN:ªcÇXãäõb¶¢²N†}à2†`<þãÿ°#>"äÖH.Ú/2$ X;Ú#D£D*��Q%BbF2!JcD~¤ÐСEIò ßY¡,bJR� ¤d.¾¤ÅXKNFÈÜ¡M‚Ïó¥G‚bödÐàäIò$QžŒ å&¥OÚâ@¢`A:%¼L¢P.!UÖÌì%dG.dVjåOš`TFáT~%ûäC¢¤YæOH£K®¥R&âLJeMÂ%¾xþâ\’e]Ú¥µe?Ž$_^ ½è)¤f\Be^â"b&æ3–â_bdcäûE£[†âd ¦_^ä[f¦ÃtÚ'^%zæMÊ¥a."ij¦bž&3¦fi®fW¦k~æþ�ZÎf¾XeKb&nL±^.f6îeoV —µÞ;¢Ž' •cZ"%sfZz�¢]æPF§¿è¦HJ&vV%ÓM\l¢fwfgLæ¤ubåxÞ‹äån^gzzçöâN6å{6P†‰¥p¢#qÖçQ$WŠf%òç»Ð£;Ž%c (|êÞ2¥"h¹,{ngg:¨·Xcß%§ëì'…ÂÊFZfdNè†þ¶�B&gòfˆN�ex@ë1(>ž¨µ ßsÒ§‹.K‡b(í,çŒ.ÊJòc‰ºgŽR „zh�¢ç�éó©—y~¨‰©²¡� �†2)§hçyަ”ÒèfÞæ•Ê�i†gkn)–Â&€z$˜& •*©�–iŽZ�æ',Ä‚ H(¿€@›�xE‡dlÄv¬Ç~,ȆlÄ&¬È–¬Éž,ʦ¬Êî+É®¬¿’ìP,5�È$¬ºº,¾¶,Îîl¼>¬Îò,ЭÐ-Ñþ,ÑmÁÆl•žÒꨫÃ"­ÔN-ÕV­Õ^-Öf­}­Övmn4­§2mtìÍzíÈš-Ú¦­Æª­ÇF-Û¾-Üj-Øþ`Fp�\�Þî­ÞVÁÞ*Aܪ-×.á–ìàíá®âºlâ.lâÎ- �­NÁT®åâæbn Ä äÀâ~.è†nÚ6îâ’®èžîÊBî(IîdT.ì@æj.l.íj€éB¬ÛÞkîvìîjlõ.À/ž/Ã/êïѯñ:nÃj­ÃªnT±î\nìÆÐíª@ üIíò"oÖºøÊ÷ mù’Çù6/t¤¯ÂÞ.“±-ûâ+ôB™ôºnæŽ�Ì.ín®±~®û¶TèÆ/Ò p¾z�ì�è�±žÁ*Þ@ p,y A�ڇĕàÁ®-�/þwíü"™ô¾nìÁìÀþÆ@lo¯0 ßëÿÂÈ ü� ¨Lì0pn80GS�ÀÁgp óì wï‹ìcœXìjî�� £ðæîðW±G,Ž-�\F_†Èieüð®¼îók_qÖ&±˜…0æb/ `ïx�þ†€ ôïŸBl/ìÇãê¤èþqîÞ@X�ðÁ¬(ðd è1ÜÁä�8°±šÁ¶YÁô�ÜÀ|1|A'»AL† ¬Aô�Üðt ÐAŒ1X†¼AèAÔi¨àÁÀ(Ï€$2þÌÀàAÐÐA°1ó*nùþ±½R³e¸ñ›Á1 èïæî€ ° ¬€ „4—³9Š�� Ì”ÁÜÁLƈAÕ0L†Ë3�Aò)ƒ�ñÄÁ ¨Àä�±ÊÁ¼À €2¯€L0e ÁØ2 DA(ë� +Á €€˜Á|ˆ×3'ó 3Ü3 [3øö±ã`ÕÖíÒŠ^[/övó7{@›Œ3÷«ø2k½ª4εÂ*¯ç«ÛÀ#ïÁlA|€ ôX†=£r ,ð¥ÍódÐ@ Ô&¿óPF,� T»A+“‡ÔrÌ}�Øþ²|@èÁWOF à�LFÂ9‡¬P—¬J³4¸ô16ÛLsn7c� Ë«÷µ_®`{í BY@<ð ø DÁ|@ ôÁLu(?°+ô�¬vøÀBTOFXAôŸð$lSF1 �AÄÁñ ÁäÁLÆìj>«¯cG7+naw›ôÖ4í:A ´ (qÀÆF¶t‡÷çN6(À(õHð̲e,J±&§wÕ}qXS† ÁeW¶<—AôÁ¼¯-çe¸õ¸Aë¼Ae�ÄlìªðµxO8…WwºIïþþ¦° à4(}7N­JÿuŠŠ¸S òÕN¶¬€C“ÁdÁðÁ ì0Ô�x}1Cò¯À3±?´Ü�±ÒA¨8tÀ"îºó‚€€äÁÄÒA^¯À± ó ØÁWç�ÒF8…?v˜WmÍÆ4FÑ,fÄÀ Ð.o¸p›´ œ�9�³± Â$rŠS†¼õe<Á{‚âãýß ~â7~àã;¾ã/¾äW¾áW>â_~ãæw¾Ûs¾äS¾ç�~á>éÓýé§¾çC¾ê¯¾çG~ëw¾æ»ýìë}l›ùý‘/dC<ïâ|Û*=î:�îTý¾š}Ë?¬øPë«ñc1ñ½î-ð�Ä„¦Î n¾6ÿÒ?�k¿Ès|Ôw€È°€9‡¼Î†ûô³ü¸{sØwíO€ÈP 4öûßÿ¿ãÉó»?°ò?þÿ?@|8�`Aƒì0á� ‡!F”8‘bE‹1fԸȑcG�A†9’dI“'Q¦T¹’eK—/aÆ”9“fM›7qæÔ¹“gOŸ?�:”hQ£G‘&Uº”iS§O¡F•:•jU«W±fÕº•kW¯_Á†;–lY³gѦU»–m[·oáÆ•;—n]»wñæÕ»—o_¿<˜paÇ'V¼˜qcÇ�!G–<™reË—1gÖ¼™sgÏŸA‡=štiÓ§Q§V½šukׯaÇ–=›vmÛ·qçÖ½›woß¿�>œxqãÇ‘'W>4 !ùú»,� ‡     ' 8* % 3= $$++44<<&7$$$+++%)+.3633316;49><<<&+22KCYj##b x((ALU\!c!#k#&t&){)6D,>²;;ÆAA×GGæLLñOOôPPýTTÀ??+‚+.‹.1”13œ3/�/5¡59¬97¨7>¼>;³;?À?AÄACËCG×GJâJMëMOñOPôPTþTy}€v€"L�+‚‚.ŒŒ1””3œœ/��5¢¢8ªª;²²>¼¼}‚…?ÀÀAÄÄBÊÊGØØMêêKååOññPôôTþþƒƒƒ…ŠŒ‹ŒŒ‚†‰ŠŽ‘�‘”‘“–“–™–™œœœœ›ž¡ž¢¤£¤¤¦©«««¬£¦©¬®°®±³²²²´·¸·¹»¹¼¾¼¿Á¿ÁÃÂÄÅÆÈÊÊÌÍÅÆÈÍÎÐÏÑÒÒÔÕÙÛÜÖ×ÙÞßáßàáâäåæçèçèéêëìîïðïðñþw H° Áƒ*\Ȱ¡Ã‡#JœH±¢Å‹3jÜȱ£Ç� CŠI²¤É“(Sª\ɲ¥Ë—0cÊœI³¦Í›8sêÜɳ§ÏŸ@ƒ J´¨Ñ£H“*]Ê´©Ó§P£J�JµªÕ«X³jÝʵ«×¯`ÊK¶¬Ù³hÓª]˶­Û·pãÊ�K·®Ý»xóêÝË·¯ß¿€ L¸°áÈ+^̸±ãÇ�#KžL¹²å˘3kÞ̹³çÏ C‹Mº´éÓ¨S«^ͺµë×°cËžM»¶íÛ¸sëÞÍ»·ï›"bÈÈ�‡ 9r /Μ9qåʇ'—.½yóèÈ¡‘Á}ÆîÛ�þ̘žcûñðÔ¡Kbþ<òǹŸñ~x{ùÄ‘s//úëÑÇ\|á�ÃvÐi'{à)¸Ýš‡à~2Ì áqäµ^}èWŸ€ÿ9·_r† ƒ W ƒç-ÇÜ„êµW^‡0ŠÈ_ˆ8¾ø"xñ)^† ö˜ƒ…ê‡ÝpÞxÚ1X$…ø #yõÅç�‘ì]ÈÞ ö!˜%zúi˜œ”Hö÷!x!H]q1šy ’æxàŽjúˆ¡‚<Èc…N‚Gd™ZÂW\™ÓꦜÅ- 䓞�$N8„�ŒF'ßyFö7¡}„n‡èuû5—§–ºè– ¢Øœy Îà§þ‚ù¨¥°ªâvã)ê�à1ø¦|ߥçé˜H&˜¤ 4€17¨qò5›¦£òA{¨›HBËež—–z©�ÝúIë¶�^J+‚ƒöJ�¹~~jf¶¢†kí‚äHî|ÝÖK¡�÷õ.­Uš›.¢e^ú_…´r7ç¼ßÞ«°¨éš‹k¹óÆëá§þ=\n¸÷»§Ç2ÂyŸ~ò9¼/œÕlpÁýšìí¥.ÿ™p�.R(^š[<`™ô xð»sä·åRÚgŒ.Íj±?ŒqƒÝ‚+tÄAãW²†']!ÑV§‹]Ï>ã/�øv ¶u0猭ÊÖ¶ç²z”šœfŸ ¿ËnÊCËëïþÝL;J䦻mŠ8ÓJøÏ¢÷¬ÐÃ)5…Tß=¤ºÝ’ȶ¥Ða<0ÚU«ŸÅïù¯Ò¢¶ ó…„üùçÿux5ã„üìÒliéjŽ#‘…ú)#Š\s]â¼–V]¨ÇÑý'Ö2üÆ �L´ôÝî<6ç:]eœlv ñ¼Ÿûg—*c-úèZŽäÖV+®9Çdö+Þï?;3òê=è}gþþ¡é ÚÈp%€ÍÎjYö*×O9‹i‡›Zâd7ÁòqI@~Z“Õº…’çq“–õœW·”E vB› ñ;ü­vÖŠaqšU=^†/ÌáÊŠ“´óM­„þ@dÜø<(ñ v*4˜Üô†7hဆADb¾t˜7&…�;bÁ®HD*Zqe,Ï»cEç‘~Sô¢¹˜ÅV¬ZÐââèJfDªŒq÷ÂÜÞz(¯76çƒvôb ë¥3‚Q�æ1¤óô؇ RŒBD¤ P2ÃÊG:ÀA&5)¶™ ñ�rT£(G‰C½ù'”¤Lå ù8FYkrf„V»8JT–RvÒ¡*wÉË^ú’�¿Ì¡!™ÌbŠÒŒ¹œ¡1£¸ÌHöñ‰ Z£ÆœèJ`Öð–Ìl&ãl¹ÂaʧƒÒ ׈€IL¡œAH§:?àMmº³—Œç;ƒ ÈQ>Q‚Bþƒâ<éUÌ\6KŸû h>»UO�ô Õa%¡YЄÂs…ÿ¬dC»9ƒrl¢Í(w¨ XÜâ²ørØAeVK|èƒJWš…+ôá VˆiL�—}ð¦ Å(Aû Q5æ”9:ÕhP­|ÎЃ¼饄3Ô�:UM jSŸ(B¤^´¨DŪ;ëÙT/25\-hW‡*U¨Â®«=-*W³êÓ´.U«BüÀ?à ׊•”keZ¯JUº¾‡÷tªN¿ª×¦’°ÜÉk/•Њ\èâ±�ÕE+š0TpZõ’J8‚f‹ÀÙsr6�å´$A/+Ø¥†õ®E¬YËZ~5µX�þg`[»Z³¢6§ƒ]- sû×Ú&•«kEêkÁzÕ²^Õ¶È5íqwÛڹʶ·Ä…îSi«Z«2÷¸Ø5®a¥KÛèb7¶9„mO¯[×òv7Zƒ%¯nQ(ÞëÞ´¯T+j¹k^µž×­pUjbIU†*ö­ˆ®ïiÈ»ú½¾%-k÷zÖ�â¶¶€¬‚-ráØÈBÖR`n=-{TgiöEqgÏL"G¸PUj³*±Š�²¢Ä0‚Œ\œ®×ºûÅqŠs,Ïx2ÕÇ?¶m³Ø‰v~`¿)¶®~e<Ü$ ÷²AÆé�{å×8ÉNöï=›,` 'Ë`–²“§Ü\1/øÉÀ}/þ‘�l-4#ÇnV2� ä+/yÊ@Æ3z»ìã7ÛÅ;&3•ã5½þ ýñSYšÀs¹%�ð?×àO¸¹Uî�wÚÝW9ÌM^ðËç9ÿ¹½ƒ.ò–çùâG¿·«Yík}/èòll.R� c£B^ÅΛíã∸³›xB‰Ë©ƒôõÚÚ6¶.T±s¢»ýípWw×­½ñ¶Çýîw§;Þ÷Îw·YÝgï{ž›p "@•”…ÄMq»û8ã‚�|¼/�ŠxŽÂ’÷ Ê3ÏùÎ/š�s÷¼èGOúÒs>¦4Ïsˆ­ TÈÀ�ÍE)d€ ç‚ íæz׿ÎÙ#p¢ cO—¸+¡vÈ"Þôȯy‘õžüæ;ÿùDÿ›™Ïy*À‚Ç¿v*þžsÈC?î–¨| BÁvÎoþûè�·‘©Ÿþö»?òQÈ𢫠ÿ�[á±V¸w(b?{Øëbö¥{Ÿn$}ñ$Wì4béb™på ðDGwÀ Å÷X«@�G&Ø�ìdàAà�Òg‚rå�è�(肘bè�x€z'} 7dr·ƒ0dèc#xmAX‚êÆ8Ø‚G†€¶5p5è‚÷�Mhm׃Txƒh‚XHƒBH‚6èc'ˆ€Qøƒ=ø‚cƒ8øwb‚-h±€³P ¢Uðp$µ�d[ ¸ ˜�¹UŠç�ˆ µÀm@þøC@ o TE¥p ¶À 2 ˜Að “àc|0 @& ág mˆT¢ µÐ IðlX ±` ¨0ĪP ±` á„@d–€y˜° Æ!šÐЍ šŠ£øZ ´ ›| ¶à ùçç¬P ®Pr'W�6�Ô—†Mȅ∋%x„`¸ƒ/˜ƒx†Oȃg¸|Û¸~ò˜gฃäÈ‚Gè‚FU˜†Lx†T�çè„fˆƒÒ×�5˜Ž�y6„ñä�1@„Ù�^ø�'·p;(Vp òG†bx·S·€<ȃ²`aX €‘ 8Øþ…͆ 8båT–pN1 -@U7èA·YLÀ‘u 'i�i�ÖFŽ5g„ø¨„ÛH�ñ…f(�܈”™Ž ¹�¹… É †IHƒÞH†e¨n4é…VY…á8�hi„HÙ�÷“'Ù–GÙ…k�liŽG  ¡pª 1 [ yž wV𠄈P R v˜2  «Ûǃy¦P ´ qF@�˜€¬` 2€‹R0€éAŸp LR� °#¤0€F� ¢P¨À ˜X ˆ—°P ‰‹âV‚—p –à 4åA™À ßa ±U ` N ›¸þ08€ ”G › '›·@Y�µ�1Бµi…ëÈ�ñˆ–òÙ•9÷…li•fé‚ð…W©”>ØŸs…€\©�[ˆ…ª–aX‘Å“QéŽ ™ny) A‚÷æ–LØ…6è“(’Óè‚W` &éƒá¸ž#éAÙ¡qéAÃY¹` Gæ’E ż“GÖYcgNˆ  üè„5ŠƒA YC£J9–4‰�©Ùš#‘–Ix¤@¥ï ƒPŸþY�J¹|GÚ¥I–Uɤ ‘Mz€P‚PŠ‚É�A:¥AÚŽoê¦^Ú¥Zª¦ø(—­I‚AX£ZʧMŠ•6êŸo:¨þszdgº›D‘UP 1°‹Íb ”õ�V �Yq™@ ”C� D°›2€ˆ@0‚~ˆ~ëˆrªœL™gŸ ­Y N@†|� ئ G0‚•Py|À 0ˆ¬†È‰§MJ µ0 ²@S# #U‚±`’¢«.X OP˜¡0š) À)W£À °yÀJ‚° a‰–tz®U™–zw¦O*‚†ª¦Sê„\:¨n:†[*¥…j£é¦À®âø…]j‚I‰•r*¡Y¯�š¯}Z°RºhþZ¦¸¨•ïY¥…І'ê‚;±^š±ru¢ Š€#8a*ù¢Æv +}4 ’'Z9Zþ} ð£l:±ê¶ ¨ ¤ ›¥Nú¤Ah‚Pzdòé�:«²€Ú§‹¯|:° K�LÙ³ìj„娝öi¯x)¨€Ê LK±J³…êc!ð�âH‚úú±ë˜§DK³E+§VË—ïªèAMPqA€ D�¾j æ™Wð gš™1™1� ¶Àp³p JªQ` ?› IŒJ�¢ð†¶È…ä„ç¸Rpˆ¡° « ªà—Ü猡‹ ,)WT r§·¶‡ ¹€ ¬— —„—ð 8� ¤0‚€ LФ«€x›·¬›«œ@ ¥�¨¨0 A¨ ° y˜€ †k ͨ»C¦´õþz®è¯c;¦{ª³Ek½fK“X;¯]û„qнëú�x¦I¯æê½›´[{Žò›µk«•1À¶¹˜§h‹µï[¥lÛ±#i¸`{J¿X#y¡)qi›’‘µ’8£�E©›²t Z7ù²à,} ´G* 7+ ù:§M UMZ¦LjŽ$œÂÒ±d+Ã6|Ã*,¦.LÂ1,ìÃ8“Ôû§A\ÄHÙÂH<¦­‰Ã4¬»FüÄ@º l@³– h Ê›§°˜#Hq.™�q+Å2°›Êê‡2pª Ÿ °©Ãû›¿1ð šÐ¤³€ Ÿà�’  þ‡˜ˆ¤ëá–’Ð SÚÄr<‚Ž*³`œ0 T€ÇfÇù³°¹I~ßʪ™ª c|Lj€È<|ÉO¬°?\ÃÙ;¦©)WD|ʰË^úÃï*˶|Ëþ©»a[¦ú«È?\¦¸Ü¯÷‡ ¸çÄùU ”M*Õ*¥ð ‘e à’žé�õ sªÁ]:m: WÀ�|¦a ÄH™ Åg«L,°ã¼ÃJlÌgº¤*ìÌLjÌGJÎÁ̶–Û®…úÎKËN ÌO<³) ¥Š<Žæ*ËÄölÃØ–  Ð¬�˜¬` OÔÂëɘQ¬ aœav˜�2›�ç­˜2@ ¹þ® ÆAŒ¢p‰| AÀ¤O@˜>ö '¼ˆÂ2  €ì˜rÅ¡ÂF ˆwÄ‹¬ÃŽêN` LðÑ2` ²�š/­»büÒ' ¾j‡±)¶ž–Œž|ÀL�bôlËNè�ì*Ä\<Ãý|à ݥ ´u�ÃbÚ¯y�ƒ}Ì3ìÏôé¿ ‘FÜÃ/ˆ¬Ê �½„Ú¯² Y§¥PaÿU÷XšLµ™²“A˜Ý ÂÐÂâìל-}C`α§ ©‹�­ÌµÍ×CX®âüË´¬Û«=Ãu ±¯ì×öŒ×vÏ­½ÎTËÛâ̽ɾãÌ×ÎþMÜÀýÜ­}Û�Ýû|Üþëgzµp³�U ©ì°èmξɑIûÕ¯áLéðÎí¸ Ý»MÐõLïñÞôc¿ôfÏî¥^öbÏôÞÎó_ÿô?ô=OíC ®ðó>ïôŸßD¯õvÞÜúøq¾õxõñ¾âr/ôOøŒ�ÄYÞëgŸ÷®œôNõz/÷cïåðŽÛëžßçž÷’ßõF¿ókùTŸô‰ón�æ›öp¯÷«_ùYOùH/ú oõ�¿ôžßúFïú™ô_ÿö��÷» ¹–ml²Šú«½èC߯pþ®pç! °Öoý@û–Îûqû°¿ø=Oë^ßû‰ÏïT?û³oúdû‰oû¤ûìÞüïßûÝÏ÷AÿöÝ_ö}þgOúúÏÿáA ‡ 48ð Â�c |H$×C‡Tj±:²ðÃB† rtØP¤HŠJ–ä#e – [&TØðãHš&CÖ|X’ H�"5ÒÜæP™�ÎLøSaP 2kÞôS)È L‰~¹“!¬V¯ÝŠ´cRŸT¿†�‰6)Gª{’¤yRèÒ´\µÖÝØUêÞ¨$±DOD1E‘4T±EÔD”ð;ícQÅgÜðFÔqGùzŒ Æáò©"}D2I%‡;rÉ›¼Ê�„P4²2ÒI-·ä²K/¿Ô�ÊÿÀMJ2Gë`À4Ïd³ÍÍ4“<1Ùsþ³N;ïÄsEónÎõø4P6ã´ÐýTÏPEÝÄlQGõÑè ¥CB!½´Òþ2uRRèrÔ4Ôð8Í�Ô<{4UT=•NUW_…µÔX+KuCVL­ÖYwåu7]$ô×Ò„¥µÀ8×´îVÉT+"<�¥¹áPAă-Nñí TìÓ"•Ü>Áä·8]‰">p{E·7baVÙÈH;¥0KbØB he³$}sÙV$T$ñ ˆr­•a5 nAl Xøbàà,Ñ—[déDƧøðV6PÂÕÍSn±…*žÂ¢`€Y *žµn’~âä“§DáxSíÖåçtwÈ]Èàþµ$/‘b‘6KLÙè;jEš%ÖJÑd %n)Øb~=Z $`©y4>TÉmãÝVù‡¢(â©Y†™·›Ä”†8å©O2áI�ÝÓyï:}žp´S&i¨] "Z >d©%"VËZ$Jb±%T*ñ€�Va‹&J@ð€j‘„œðãÉŒ¹R@pˆi´E<Š ¢ÑF âe©)¿U'„�!Íÿf±K%ápÐ*(ôþ°\8Í�¹ ÌE1LhUKˆ1ÐÅUÓ ™Q¡vÉË+–…#Ü"Rh'Â… F1¸À….8aFm DÚäC;³™9TdB&[Ü@p!­GŠDŒÜk ¸T5�,Gšªáƒ+d0î¥Lz&Ñ¡@ú)�Ä}ât©EEAPl±£¡8(j,� Zìr·�Y<·9»påS¢PüP-g9SY‰ç–Œ!�)0„ !¾D\sTq “ÚB Q,eC.7�ens1ˆ(kšP �‚ šó&rQÔjÍ �°„Xƒ[„´4‘ B)zg‰¤z  ±pb[¥W>Ô¢ºsê@nqþTdât@Å*V!‹ÔÝK ¢°+ò¬¨‰Z�¬§q‚,6˜ ãÁ¯v�”èÛ Å?¼Q´\±cjµ€ ‰Y”â±£±Ä)xÚSOÔ ¸(*æX™9 ¤©£inu{�›ŽH4¨xã@†�‹ Î®³­‰Ü@6:W8lP=mi`‘„)nsÿlÈ'. ĬInp1Oë’׈¸®+~ ä Q FH&d†ŠO˜1cѪ(B‘„Z8¡!2nY3 U| q.‰ª&4+Éã΢\Ë+ª%xbsQÄ«8Á< än™ƒ+ÌF±[—¸5@êm.ƒ+�áW“M”A1,4þÄp…j´¡{mÑ>ÈÉBžXÍ�-è%, ®øîì\á ð&5­`ëbeaÃ.ÚP‹9[nÑD¬¾—¯™¨›„q¨ŠŒx•áBD,€ÿž·¶¨‘ÁØv„Vl‚…­`¶c�L¢Iœ‹rÅðqUÈ"b¬8yŠ4à ŽAjaÃÙ>£ �`‹+˜ØÓŸÒMsJÂ/Nd³ÉÂ+’Ãä([N, 'D‘ã Wb—�LÍpI¨… Ï�{rAÐ-ä‚Ç"='lIaL&´Â°˜E¼÷lÙ¹—³‚{=`‹&ÄWfR€…+È™:3ŸË¤0E Š UþðΨ¨.b¡‰�h‚r±`h%b1 "¡xLfð f‰Š"’��*vÙî$fi‚*~�ÈK_ ¨5¾ñÔ¤˜ãKZE§µ£í�—Üä õÉ[&<„ eÝN—U>s•{œæ'"ÂkñŠc_‡ä7:ºâdó L2ÍÒÇ‘ž ¥‹„èF‡ú£šÞ¦©«¦ê&zzÔµ¾u®s)ë]{ØÅ~¢¯�ÝìØ¹úÙ‹^vµ·ÝíoŸÛñƒ%·Ó½äoŸr�üçGúÕ¿~ö·ßýï‡üå?ú×ßþâg î�/ û÷ßÿÿÀÀ X€ÀDÀTÀÌ=ü[ ý#>P€ðL$Ƚ€‚MOȽ Àôû  ࣄ Ààû�8ÁdÀ´Á<¿K0@äÁôÁT>T ¾È€M,Ø \P ô@'$… ˜èSþ«ãLð$ØÁÞ…€R˜>! „-ìB TÃ5dÃÝÄ4,¾+lÃ9¤Ã:ü>!ì8òóó#>O€Ô=LЂ'œÁ)¬Bßc(Ð0Ì=(�Á(DRhÄG\ÄMÐ`I>OÞé½LÝ>PE;dÅV„>ONAOX€ôHAIlÁBÄ‚ È€HÁ@‚ `€E$JHCX”Eà#Æ 8Æd$þ#$øWÄÆlÔÆ|¼ñ»¥†àÃá“à=AÔ‚xÂ)Áßc€k$E'Ä'Ü„Áw$AtB Ð@-€Dþ>I€ßÃ@Ý€ >�ÜÆ…dHßë€ؘàIhBÐ@R¸züG,èPI˜! …RØ„HDJ CÝë!`%XEÞ#I“„IKôÄ‘DņÔÉ�¬A,XI(DKÜ(€OȽhdÄJ¬IJ耀d«ÜPLÄ® >O`„ô=H„@Fß#K³äÉ·´CP”ÂCR_Ô½KÈJ¤,¸FÈ-(IJÜÀETFÝ“ËÜ£Ëß#LC$…%¸ÆÃ„ËɤÌþóIˆLÐLLx¢üÆ�0Ê„HR€Å%hÊ%@þ¨LD*tÇ›\€„¬EK¤AÀ„LÇXKÞ X‚à[Áø pKÞôÍÊDÎ5¤`€æ\€ß$ÜDÆØÌ½ZôK§LœÌ=€NÔ½ålNxNƼÉøMMP@‚‹LÎötÏó»ÌIØLL 5š ±MØNÓ,EZÔM°œÊJì=aÌ=ÛÜ„ÑðÄN¬N-üÀ>IÞô=�DR�ÂÜ }ϵA?ô= ‰´ªÑü„­Ä‚’ÌsI„Ô€E|CÝã„ ¾²Ì½ A-X€KðÐõQê»ÌùÌ„ ªŒû ¾áäßlJR�ÃçüIþýdÄ›$€y€J�@¥Í%~-€ å½€HPÈÐKd<ÃM ÈKµÔ=3uB2ýÑ;­¿@4Õ‚LxLt\8õCJØR-HQìTQ J(€MP+?\E=uBIðSŒ”ÓêTTJQ„‚æÄÓOUã ÒùÄ„—°Ï¢4>P(A�%äC-�Óà\€ÝÜ=%àQ,�A` C%…+5ÉMôÕÞÅÆ£AL ÎH4A Ä@Èbe¼œ ÕiU?X\€ЀMÐAQÔ€0ÔÕàÕëd€M8Tb€pK)0Áô„ƒU^¼1äþ1(ƒ.X<20e?€3ø00bn<_^¼3ƒ+Næenf8�3¸bF~<.ffb6n€01(�S6å€sƒl`æ1`çkgÇþÃåöä}æçö\åEb–Üò#€m60@g3v€\v<03€3ƒX>€1¨æpf\&0�7Žâ‡€¶aM¾âƒY^à=,€xè1€bÆSiÆ ƒ.&jYc2 ƒpdÀeŸVë.®a1ˆg^¼/>c½gŸ^ëÅKg(€…&æ3ì3`g1ð‚Iþf‰i¾~çˆl}&ã¹ncl&dþ@†bÅölÆ^<ø‚™>ä­fíÖ–ÞêRë�ÞCu6eg2ÐçÅ[h}æã‰öë0f¾¦ë—f¼Þ^<`^鉿^<4.ízFnŠVgXþáè¦hƒJþie^nHçDFÆÛl´âÀo jèìÆKo׆ïøvÞ‘ˆ“UÞ;fp|2è.0ƒØín¼.ànÊÆedv2ã|ÆçJ®a ^è.vkóÆnº®ätæ‚ Øaö6í…Îa^în½Žpõ¦æ]&oÉnçóflÐþã3m·ëRqù¶ñ§]ú>âK(`æ-ʈ&.ÐkÆ+å³6q»&3àþ6ã00”Þdg¼0Ÿ&i=&gð–ècgg™öé€k@�/xc/ïå›Þíî.é,'ã3Xê¾>ƒ.8�¥öò,Žê©¾îÏL6æ5Çñ@tÒ-aQž’¢äÜþã2àexìÆ;�™žp/`c2äâ^sHo¼Öc3èâÅû‚›¶n÷ìHg¼`æsã=7íâFõÉf[&rÒ>îi6mWggyžikör˜fj¶æA'öbÇ\ÆoO¡`ÊëèÓkvÓ{öÕóæÆSec·ök‡Þ Ža‹iÞÉ#XíÍûöp×<àr'½iWï˜æ¹Æöw‡wÛÅ_ëþ…Ü$kÕUw@.�Ž÷~÷wÔ¥ãùLvIYö7øƒÿ÷ hWb„wø‡ÇöBxD)xˆ·ø‹�ï 6ôžá_Œ÷ø�Ïê¦ØÙFaù“GyNÖø‰÷“ŠOù—‡ù^y’ÏÞŽù›Çy y~eù9qùœú ¯_�W‰®y“ú¤WzùußP¶ {§í¥—ú©?ßêßž“Ÿ§ú­çzàÝÛÍìñýmø®'û²—÷‘@Û´-Unïx³wû·'t�° �°Þ³�a€…ú’¿Z¸çû¾Ïܹè+ååyšŸ`›÷ûÃGü­^´|£/|¤OüÈ—üº%zÂÇcßüÌ×ü°•xþËÏïþƒH¨€Ö€€ÍGýÔ=úÎ߯Ïû£ßûз€Ö#„HHÕÇýܧ<Öå…û{_¼¸ýÖ+Û×ýãGþÅ£c¾Åz*ÙÙFˆ„H˜}øƒEp„E0„F°ˆ„@H„F„Ϋ€D€F �Å „HØGˆ°�è‡ÿó‡¼�Pð„%€ €V(˜攄OOK,hð „ 2lèð!Ĉ'R¬hñ"ÆŒ7rìèñ#È�"G’,iò$J�V²ô€éÒ¥L˜0µ¬¹rΜ:wòìéSgK‚7 E²@ðO¤‘-*¤ R$Dm `ôè£HŠþÔÈ� v,ŠV�B,PÐ@pS‡·n§¬à6%Þ¼z÷òíë÷/àÀ‚.l˜£M1/½Ì#Fâ›?'SþôhQ‚=ˆdƒ�¢§‡Pˆ$¨F¤,DúÃ5�ƒ…"%`8e˃H°È°‰à,¹/nü8òäÊ—3oîÜoâL11I�"²‡ÊÚ·ïº ©Ñï�:Û´è)!"Rj@‚Hé»R�M›¡€)ž6� ÈÀÓP¬ÀúmòÃs *¸ ƒ :ø „—Xc0]‚�dÜiØ“w=DrÁQœyFÈy‘(@¦¡¦k®Ñg_m�JA”t°IþÍ(!�=úø#�A 9¤^6ö†Ùm¸$P,dC$‡ìpAã�X¢ieÝ0€#WeµÕ‹I ; •PÐ�' A]Ai®É ‘uÚy'žyê¹ça‰16ÓLI29hwND$òýaåg¡±—$�T�"�0ÛkõD�"‰bŠÐž|‚I\-@Ê@…:j©|ºú*¬±Ê:+žFJ†„2é�CO ¢×' Ð:,±Å{,²ƒÙ (dØéº$¯ ùš×—${-¶Ùj»-·-hb!@öì†Ñ24mJ›lrW·íºû.¼ñ:ø-®Î’Ë�¹òê»/¿ýú˧‘^˜ëþ½Ûåû/ +¼0ÃÅù9ÓŸ‚¬�w’(ˆ ©iЩgäáV! ÀCQ²_Ã)«¼2Ë%Ñ+ñÄ”y§” f‚$Ä Gà‰DÈ}ùÖ2ÑE}4CËÂöÆ®‹†©Ú"(C)Ê"Ùd:/©WÅÈ2ÄŒ+âJèóh�7Y!Z€L, †4zˆ�Â(=åD):Í[”)=dGHü�©~Y%"Dv´á(³©Ím"Æ&“ &cÓ!„Èu™›™¬--¤¸/)˜@��±·7þÉUˆ$°;‘øn·Àî“7!†$ÊV¦Ñ&Üû_…¼÷!nÈé‘FÚÆ|íïA*|�WäÂ>1Šù2Y—ª2(j[[Û"Q: èbtûƒÝðÖˆÑUÓ¤dàã# P;µ¦9& ¯¶j ›f_ l!’›orŒ9Í‘ RT�¦ºr¸ñàtkÛ›AÔrÁá²½ÓŒäH̱JŒê.p2(´ e>R9G)þ3 GÒÝö8y;˜MóÐò¼èUÔ«Àû°· dâ�KrA¨@L„k™Q°Jä{îóB†2_¤\/~P‘ŠgýhüE�ûþãAÿ¢æ7ù¿üOnä\£O§ —(õ©=áç@3»Ùy˜‚cÀ‹¦] á/°”*™§ �°¥ù–\%†Rt3ãà€¯R1ÍŽc× W½FÐðÐW¯d „h�ÙD7!XOC‘Tu£[†4ä™°Ó½±Lôç¯Á»�-ñ‰S„^èbMÀ«WñjFjçT(A ùžøB–�št¥oÙkב¢’™Ø#º@ŠLó¬Í4ü%Ro÷ =êa�!o€È«Dk Á t®‰-×ÄËù¨@æ4)’JCOTBçÿ:ØÂZŒg¼ÞAJA€{·†ÛWBþ9÷šN iŠ» ÓÝe\Úýp$À­JH0mÚ¨b¢È‡, ¢ Âá"z� $u]r@÷*Rýîì†wùî÷°sžóß²¡«ÍÁÀ_•KuûÉBeyBŽ¢I•8êÑÙ�"©ž^(Ô´„c¯iϹLï?£4%“R~æIЬ¿Dt0Ô¡þÐAæiT´Š±ççùÎQëw4÷¢æ}|àw¾üΦ—´©­Ak«]n4}êI4éD¢ÂfXkÏ8Ç}b­n+�H(dB“mBÿù^Ù’B0!Ä_TMÕ¤ÌÍè$itJê<ß·ùØ…UEÑ)øŸ$à)4þ D¨¨AhÐ�š þÙÃü M�Kh\lKfÅ öàrø‰À ’n5Øe%Ëú &aaT €ƒåUË*!Vá^¨Ä´– fš–~!NPÙU&ˆ‹úO†¡®!ËTÐt´Ö"–²!ÖáʨÔ9ážMÚ¡þỀKm¡’ìÖ""&¢»@³äÖw™]*¢$Nâ±ÔÄcÀ xWÙ"ÇQ¢'~"­´„uÄ€)a‚¾Vv"(®"+æ úÅa¡b+Î"-úÈŠ¡"Fb-î"/*ˆÒá#rb/#1>…Ð 0nb*#36£þq£‚…Þúé¢3V£5æÅ²H‡4¢!5^£7~cHtÓˆâ´qa.Ê"8¦£:n„8"vã:Æ£<¦T)YЂíáÓôá<î#?,ŠžiU×€Q�Æ&¨‹ud§-[G¸À<¤L„ÄÁC /ñ#F Ê6ò!‰XCLG`�BßzÕC$\D˜AtÀ÷ܸ�L^D°@ìADJÄÐÁAÄäLÖ$A ôe¤QÆŠJÑ`GæãGN˜…aSF¨Åˆ…˜SŽ˜BZ˜BjÁ[ÑdPjèäDôäAx¥WN€¥Z¾Ê ‚!â2¢[Õ™ »àY”eþ ÚE`�„�Ù\‘9Ž&@ŽHD ‚&Ø™“ ^êåã„WeÔÁÔÁäÁ 8d ¨‡ÔÁB„åNÌAgªÀ~­Áä�D¤Ì°Á¸ÀW¥A8ÜZÞæ�àaÄüã4z¡Á^÷é©uÔ©U™EüO¸AžAüš'd߈­Exߨ-q6Q �Ÿ1ÀäMÀ w@èÁ ìAÔ� €È¨À¼ÀgŠ%AÜ�¤�ì�ÀìA¤ìÁÀÀÀA|çw@%n.¨�$å)-eÌxÇëXžxé� †ÏB„OÜ ¦ÀñÔÀþÉ]ÇÂUžºIŸm&à'°‹ý5Dà�ìÁ ¸�kÊ� lú§B€&A¤À€€ìA€ØÁAèAì�h%ƒ6iƒŠ")Vˆ&¶Ä;úfrY]!HÖ„¨UãÙ�҅饥YÒ-]AfrÝu¨ %–^Öi]™U igCÀ €Œº�OŽç à�O¾çN¶€Œ(iŒ¾Á‘îAÜAh.„(¨“N*„¼">F(1ÀD)A0ÜaÞuõÝß�•ß|(|BùÏ—>&§QVN.]ÞA„êæ9Þ!Ä‹ÆèŒz¦tf{2l"@�þh�igÄÔÁþì�8DŠR*´2È-á9v¢äÙÞ§�B¨í^D‰ùM_˜*ݘ>\QRWë1Žþ­àõÍž$Á‡q_|‚î�Ÿ·n W2Ä‹êé °�g@Xî�°.Ä (i @È'}î�Á¦ð§N$Øh0zUe´^¬sü"„N ¯p`Ž,�%& >™$ ÄaÄ|©ý�Âú�°ˆß€ ’`% `š a×É®ÉK* 8êž*{êB @èÁ�Biw€¬è°¦OBÄjO¹b¬Ör—7!ãÆŒ>º %0$D,«lêLJXä¸n-Û4>(or#:Æþ‹à¤ìäÀ>äCªgÛöí¬hlÜzäÜÂK�êÁfH€â*îÄú­ã²e‚ÍDu4 .Ê¡*>.æ†a;^*ÇÂcæ~.ÚÊnr.Øv£‡„Ç~‘LA@Âh4„@*WV‚®ìnÑ´c\îœAADÉC€äÑÅîì¯Õ®2V+C\ 3ëöîU6K /ôF�è‚ éÞKP8‚kjPÀ•…‡™AEl°®!D CÈ"¦b>YªbAäåfýÇ[E¯üÍô&#•Bâ^BöT@ð$œQû„­•®¡TBV,Bü&tJ'ï™Z �^V(Hêü^°ÊÉÙï þâ¯xÂ"°†€0f .è.D#€DB ,Ä„âÒ ZhiÁŠ¢-çpÚÄSo寢xí$ð@#‚!B ï[}-/\`B¼Î›ZQœÎª'äœ_qÑX")~L|­õ:‰¶B0#¨FyœâÕï61š¡$§®[Ã�*­¶ üb±' #ú0µZ.t†VÀ ¦@I!e CÙ ;ñ#5ç° Àx¶’ZïÀðÑømŒvZðgr F.G.Sš–#4…¦,U¢¤ihUê¬1"#„Ç Íެ âe„Åj2.w ñÞ¯0æ²/£VOi÷ò/sþd¡_9î133?– Ö`õ’KØ635Ón)Ñ 0›#W37›…€“)z±4{nE4†í@¿ÀØJ$ED(,Eè}:ˆ�fMºA‘γF—¼†Ä¸®óq �q-„ãô¬zhÂ¥Yä­vsïŒát¸–2äåöÌ|- ó¬A¸�‘zeÞê-Eê'®Ú'E¼³>3 ´gã"{²ChôW.Ä„Â-k΄K&Ç%P(x]Ä“n”+Q2ôð®Dn]L8åFtoNôFd˜C,Þ v¥Lze ¬Á$kÈæÕÄìD|5„8¤Á(êB˜¥‘ÎßHl�AÜþ+r,�Îi@ ù´A;PU,QCK”M%e¢8?KØnŽ"xÎ ¹ ™@÷{\Œ¤|oê «š¥P.+�„ÔÚè<¤>§@ )K+Dg‹t�ædŒ6B*ÄAl6~îA D­lŽfg6í´�|ÆADJÀÒÚAÓ‚6£.)€¶h3„€6nAØß*e/Ä&ø�•`�!% Atÿ€á ŽâävÿMøÆ'|�aÝ-ë ú0ä zóÔ'!v“7ädÀugw¢j%tÀêåìPfm{ëP×&æ=ë^ ¾a`ëÊ`¿ÚTÐZXpEC$‚"QÀ þ°Í0À'Äw¦@Óz§�ÄC®A}Nì È�i€ÜAf¶ÁB¤øŠÛ�°@ À6BÀC¾€Šwg~âÁ ¸ °x$¬Áæç~ÒA@ØÁ Ä�HÓxf¶”ßøB€�>*P&X#„�†ø·¥œõýžšŒÔ€ôÒ÷„ÏøôÆ ¡×ƸµÌ\šÕÅ]œà§6�ðš¸y^‚ÂZ @n Ýü)ä�Ÿ)²¨emÊÚô€_Ý[cò��¥þ°DGD½™†¾¥ð‰4ñ €T·¨C,ë�b¶g¬i‚çŠß´@—'I´ºŽ&„ŽS¤Ž³t~¦úz+³éZÄþxº€@yl«ƒ5«»úB8ä�D -Z_¥Ù݉†7)°—.i ñFH‚º�‚ŸÁy^n*p‡AÈ5¦QC|{¸çÈõWºn DˆyN#D€ÄNëA²o¤óàF³`“3Aø!-ߦ2¦Hµ�Žú³)E:dªÇ:A¨@}î�jÃúŠcüCn¼Aèø’ê©¢æ§rjwªê´@G?¤¢z¼Æ_ü|¼BÀ„6ˆ­ ÔºE€[œ_]ÇÑ.õRyäü¹?¼ÝˆîT¬€ÌŽÓ/Ò˧½ñW ͘ºľD¿s©‚FjÀï])Ø=RzS[þz$$Báý ’+7|}IöB{ùˆHS¤+'” Ž z¢ë»O7ºÕxÙ‡ÍÙcó‚Jƒ›FV¼ ò”¤ðh°1Aò™À5C�zA@,ßÀxÆA þ¸€Œ—uB¤ÿú@û¯@VÃÈ|NúxãÂÚ@è�€Í9iæ 0€çŒo\€ãbÅ›=!®�³þ'�?¾¸ž7 WìIC’eRB@jÀ’ < �aHXdbÐA‹PL!ÕÁãJ$µüÙAgO…`êé‘*«]J`T¥Ly’Ä2eˆ¥¡Hšùq‰M�IÙú| r�§|ý²<˜paÇ'V¼˜qcÇ�!G–<™reË—1gÖ\ØCgÏ0]Ê$úÒgÓ�w¥V½šukׯWV¬ ’!E�y ‰É… E"Ò!<"ÑðHAQpå^µž òãž8\ìQñÑMž=nâÙÓа={Øp#ž¼à{ÞÐѳÆãŽ !È·³Âãþ€5xô Ã$ˆ#�' È»öØ`†ÂZ؃¿�Ð#½�$È#¢Â2ð„˜2áKP2a ¶j (¡~à‡MHÑ ˜@OrâJ�–ðäLL€R�  G ?bÀ±\ô-´€ E’¨Ä,(1QR–àÑ.�„ EÌ—>ê`G��øÒ£47kÓÍ7áŒSÎ9é¬ÓÎ;í< ´KDÃ=?ƒMÐA•-1Úy“’ ð„ì½ ìñìðÈ�=ôØ.RÈ= ÞÜ„ÑÁ�˜1öê/“îZuÓWa�UÖYi­ÕÖ6õ$-´LbˆP VØ] E Ñ[#þ}Ù:àDv2!.ÙŒP5ŒTOB,ŒŠM¢“VÜqÉ-×ÜsÑELÏÑ2Ád´BøuØya+ö°cÓ}ó=ðòí—°M453=Á¤,NXá…nø2=wå³´_�¥×âØ{Ä#€)�IqsI&2¡‰M !$Jà�w0,‰ŸÌàç‘åá�ƒôà‡¶´½ÎƒÒ^a@`¼Æ�Ï‚|QáèÖ'•€Â{ KHBî�‡=tXàø \¯„õàaºò�T„ÂÐPiá‰Ðþ¨6! ¸äsQñMè ø‘¾ŒÏ‡e4ãÑ+ òjmz*¢ �h˜�IXq¨9PT�z”¡à N0‰( Ó˜8Ǧ4.’‘�t$e/Ó„†4ÖÔ G9‰J0C#LÑŽöHQŽ’”¥ÌU»¨gI7bò5't'KKYÎ’– i£gF5²’5®l,kLa³Œ§¬žÔˆÈKÕø’˜Ítæ3…9½va¢WÉT&±4 Mmn“›�\—®¨)ÉU^s™Ù”Ì"Qà ` ‰'ïÊnΓžõœÓib0ê�¬äL 3ƒN †‘HÀTÎä6Ü� MÁþ ÚÈ,T= ýˆ"í™Q�n2øL¥>wIN€2f5L! /r‡¡}è`@íDÆ ­)_>AFŽî”§=ýHë�yIþÓœ,¡M �›¿ìfŽˆЈHDb ©M" ‘L•«Ê™a ozÓ‡Fä¡/È j¤‡:hzØaÂZ©Ñ•Χuµ+=§ç.Užf¨DíÌl¦êCDÂØ�#|ÀƒÀƒ­*qžªˆt*à‹xê€â‘düé, ƒ¨A;Ѓ ‚=¼`!wxAö �Á|6hU)çîš[Ý6ˆ—¨¦P‡:RÚçX»yÎGþú‘H(â8‘¨�GPjÒ]¥0…èÔà‘8èa‰@J\`ì¡ �™ànÑ›^Z¶ŽWâäk_…+œÕư¼!Ir=Rœæ>¥¡Üluѳ‚6¬)Yè‘\>�Ù z!aF¶n¯¦é+6ÿz¨H$ˆD¢vÓ�ô P®q�Ãß”~„†ÞP€`Ú:˜{€A p<�@@§ÿ¢«„�dB-•!½f|…cÙ†˜$[=ÄL 'Ç#€ˆD 0«A& æ¥.¸¿àQĘ!0€ÁD<¢ÚìfŒBvó›9T#+3¾†HÄ#±TûÞ…ÁI”~Sþ¼œE8GG†(äp€4ï!Á)Ð�ê [iè0…ó¥1Í< Ï™—ñM”›à™˜élH2ÈD(3�jU÷Mû‹¬I碒_²2 ê ‡h­š×½Ž³£Æ�[Z¾³I­c%LµÀ×Ívvôz;lb×ØÏ¶öµ9Ú:Kpš•²Ó·±nqר¯Žu§3öí1�›ÝívæÍöù'i{æÂÞV7)Ü�o}Ë2Wû¤·1iouï›àŸðiúä§m÷ÓŸ·Á!ñ~³z¾�7jŠý×{ã[â÷¸ýN©ÏLîÜÝN÷½?žr•káì ÍÅ1Nm�£|å5·9ß¾™þË…—<à'øÍ�t°åUm0¯·Ï.t¥/}f­V›{§Ýp¤¯›éU·úÓ>Ó+>á¬Â�ʸ:Gó«��ì Ë:áމ�Wê3ÿyÙá÷t•›á"�º˜äžw½‹KÎu?rµ÷xÁ7 ˆAgÛÁ>xÅ/þU‹·áß+óÄ3žò•—“ã� ð7ŽÔò�÷üb7Í‘óŸ7ýéYBa!fÖ1G<êa{u»z¿=¼Ý3,{ÝïÞ#Æ,²ße�{Þõ›>º…O|åwÞ˜‚ãùæ¿|éÇý›ñ} K?}íWÞÓ„yÊ^¿}ñ>çû¼¾ÿ²?~õ×¼úû;?öÒ¿~þù{Ü4Z�X×éýõùïßêt>é£�ÿâúîÿ°/0õ­ðÞvâO!Û0ÏùØ÷&/1�ÑÆß /ê,0AÐàðøL.ùBðÇ-ô|kˆ*ðïLaÐÚTÏèô/mðÚˆŒ ýð}P °ƒï°3-oωЙÐÍr°½Nòšp ßì Ðp� µÐžJ&— 0ÿ¤p Å0½(Ž“pÇ0 u«þb@Wøä Û& Õp‡Éé¾Ðõ>�õ0ÛhO…ù–p‘›�0òÂo‘.jð•0!Q›Ê­õÎ#ñyK„¬�Ãþ=q½.ÞüòðMÑ”ÐfšÔ©FOñï'äúÄö ±añÓÈ3Ä)•ø„WX°K0qq'®3Ú�¸ŽwƉ±Q§ßúd�î�±c±©‰{Ž­±¡gwþzÐÉçêï˜ø)p˱ÉŸö§z”‘gØÑíÑi�]¤±ï±MÇñn«†ý± Åæ”>Š=ð ²!Í&çô ^Ô ëÑ!-ë&©’ö±/²#—FšÒñƒÑ#IRiìP -†Kr%ý¥ Y2&_ÆÿDr…Q&qrc\ÒHno2'�rav 1(þ�Òì4Qµúò(�’u1Qòpšò)­’ï°q*çE%¯²+é$Ú~±Ò+Ér\ÖH!£°(Ër-i%’6°’´2v*’-ér3|/$�Ñ&ër/cEÚ¼Ðó’)’/ “dZ.ó–«²0Sz4Ñû8Q-s2ÏÆ4ôñé&2ç’29Ó0ðIŸ”23[q3;³4S¯þ>ê óÓ4]sö&i„V“[ó5ms0òJôfSkó6}ó§h/ˆ"Ó³89£ÛK4—‘4�³1�O9é±7›ó5sp#%s:±s(&±³;{¯­“8½³9[�zÌ­&s<½¨„ :rþ0ÕÓ5£m7×Q:ãÓ9ûpíS1áó>;sÝ3%™Ó?ÉÞrf8Ç’@}³ß3@©²??M#íôS,¹3Bm³ …PbÀ'1tA[NäHNO:”?T>[.¿ÏCO”:Oã2+ô%-±EQ46ã -‰R±ô)eÑ<ôB¿TJ³]|t?�´L%ô3ré÷t+”MK GÃIs4Aé´.›0-tF÷t/)Îüè“"½4PqRRiEMQ••#µ,ŸÓHiI'þ5'{‹K åJ3Õ µ“õôS¯2TY”T½²<©iLUKR¡îIGÕU�ÒT•Vkuu©P5óPqÕ!'ÐOet$LÓýxu4}µX r]Ð1R¯“Y³tU4,‡U/¥Õ(MíôJXe•L³uZ=£WP©]ðt;[5\Åõ3(©zFTM/U]�² w…SáhYåµÏ2Y—_óÕUZ¡ô_/2XïPTÁ•`;²Rá•71UaÝÑ`í5“üb›ÑV%Õb-!sib�¨b5»pë<¶•æ4dO±üÞ…_£óaQÖrWvV_¶16ZkÖ‰.e0·@»c“?a{ C¾k›¼Ÿ;t ‡ÅG0Ö$ìwµÛµÅ„¶é¤¹÷›ªe„·Œ$�F„"»·[¥Ôí…¿ïDÁ¡»ž›bª=bs�b14¼?„@§úâ|�…Ã¥øzw¢[#ÂIaÂ!Cv7a~È¥K¼¢hãz¢|I!*<|Ž\0Š)�W4vÚI‰Ä-Ü82°³?b ZX@ † ¼ÂÁâ¿à» C¼7åÉ· ¤\~ÀHšºÞ‰nÆBÌ£TƒHÚ†iÛÎkÊÕ\ƒïÎãü#>ÛÔXÛ2 Hƒ{¬QÐþ¼ÁÉþ[pãE�¢†%øÜ½¡è(ÂH12ÝNÐ<ÊÇ�Sø“ð‚1>}0dB\BýϹ¦qŸ¤ZƒÒ£èÒƒÓ5,§\–€Â †ÂÇ@v…\0ä¶È�œ%’œaŽö/¡Cš;�ÀÀ§zÚ$ò(rvb>¡¾�F0\b|$&²}äÜq0Gs’N¥oA �Þ×sFœ$¦ Âǹ{b(!—ÄE`äsx¢n7áÛË]ΩÄs.çCrˆŠ€'º}àœ2ž½o„£w Zø‚ì]¿{"–`<�FrÌÝ#4`ßAþC@ ´½0@A`Çp|NEvßý)Ü8×þA|sHBÆ~Ðâ ãJgX~,‡Ëƒ5 àA�èëdâ£ýÛJ½0n((~T×Gºê‰þEÞ#4Aà�¦ClÌë¤Ý«ž¼Ä7¾ œáwøÝàAâå=BºdF¤D<"æg¾QrYnÍYªÕ­'ªxÈuû¤b�*!î6»ÅüžD*�/·:=7®{Þn*}ïuè×DíwBÌÇ$A |Ç>h6(à¼<¿ÕMœ¹œÁûË;àÆ?`GÈ&Æ÷#tûôA¢w~‡%¶ä#ø^ë=ÂF%ÕÛdã X‚‡7Ƨà÷—v$ŠBrŠþõKT†¿FXEùaßåK‡vÆ"óßdã,ØÞ�\0 À~Ibî¥ù=¥tÔ[bÓ-KD2Å Â… :|©‰+’"òƒTDPȤÁ '°@ äA� I-X¨!ÓÂM nR¹1§Î�'‚úXÑ¡R$‚™€Pm*)O?H:ÙÁ“€’'Sîü –á’B´d˜¢¡'©M@M<ëp©­.UÍ„Å)¨… XBÔ*VPÂ> ÖƒâÅ0]ÂãÉŠwY¾Œ9³æÍœ13–Ø ƒ‰ œl‚íGOBäÒÅ"deÄ ¤<Ù&Õz®AþØ3¨¶  ¤ªˆ‹«•4tb$AhA²D’�„Kù2t¹¡Â ^Çn˜U¡–“GŒ”]¬Z-–,éå;CçÐ¥ŸÜÜ|éî§ÄÞ Šu,ànò9DJRýañRzé©…‰SP2‘C£Ñ¥Pyç ]NŸ¨Ä�u¼qD %Ù±äàajU$DFA‘×G*y’Ô 2ÔaK Svyù–b� YÄ–'A5äC’D›€Õæ ( PJƒ˜gãŽB:8–$ ,�EjùŠ ]h%UÑ$ ü„’KC5˜I~[Þ©e�E™žŒuh €~v\rž€ÐCxb�þÏѶI&›pÂI˜ÉÐh l:‰KE*i¥@*tÓn•âÉSO• ¹œC`"Z&ñ0êuZæ˜V&’RêÐR IRÐ’è¡Ú„€� BœÆÐ«±BÁ\yœ0°”¤›lœ¿ÑJŠ&ÖžêPT 0…'›GlDj]À&K¼©QCC=eÓŒEå‘'*AjmS£qbº:­HQ‹ñBÔQ½m¢Tî¹ ákv iᙞ E¢ï¤ÖO™ÉмJÚKë¶Ov€áh¿VE®¹èn¬ÓB�Ób*h(l¹• ¦êVךö.ª�K* w ÌÄê™É%º:ƒb1pÃÔ1„þƒ Zo½ ¡=]ü1)?h¼]`%!±À'AÜ,)ÖI—¶…Œ’šŸtJûu»¤Å²'— DIMÚ†{+KÀ‘\E9Œ‰Jârè3± @[H"”ßhx»3fÀ]Cƒo§6Ç8)ÂÁwÝ»o!¡úL/Ð8<Îv¸ "¾�$åî˜ûêODVì aáQímá { «(Zö¸%©1¿ú“j10È 9¼�ò��Ñã%I»�Íóž½CcQd³ZŒ™ ß Eוhbï›| §RäO}í’ž.�@§]‚jÀX5®I°3^ëÉ´õ!²)d ’`À€eþ•% `(ÊL‘&¥‰9ÀaŠr¸§l9�^‡d üÀS@é2C†I&3ˆþP‡BƒŒg„%|%¦ €f‰33aa¶¢¤%XÙΆ(£…]ÅvM¬•pD8€Bäv°ÊžªÅDùÛ$²Ã6j )&@ rB�€'-$!I’ a�W,àz¶€î9¤BÀÀ×Ç?¾1ŽÍkdú`R1…2¹¡öz2…P’%C~°‰ �0)TLPÉrb¾4A!�üÙäÜL)–ŒL$~jù+PD› ¡°¡†’‚‰Ø@;” †€é þ=A¦i¼ÚÔ°f5x`‚äÌL-ò‰J 5i™'´ð`!J•(ÊmsM…ÐæW±‘g”:(Ê(‘dPp™)WÄ¿0@ ÆVâE>˜D‰8�>Q2OPÔó! X%›œ’‰O,! ªèE7¶" À.‘  ã%­¤ø“•$‰(FÿI¯ðUd+KðÄ'”çÈžDˆgHÈiv6AŠdÑ*¤#-©Eµ‘�+£õÌ€'t‰ïܪAµHãÅ 5¤£ ðß2� O4UˆQªI¹ª”(}‡zeÓ 6 ¶„(Ô•8B*&¤’ ¦l¢¦ÉdH.T‰Pd¢&lP±i�÷þM$L+ºDR"ÙÒ \b-ÄÖ«ñtè ÁYÎƒÞØ�#’h=Ç™ÏÒ}ôƒ7,$P,wÆ?æ™+cæ­yí€×ßýƒÙ)„ÑÄ[£_2�7“¥6RÅ �†Zˆ"˜0mXê|¼Š0Á ~t’ƒJk|¢64rÏä“KøZ'ÇnñD’m$2Õ/.@³‹ƒb IXD`7ž®]j·L»ÐJ,�u" pûÕ17oÑ ¯53då…"݆¾;þ'ñnH±�Yq_LLÛ[ˆý MŠÒêäZêq§ƒüéP‹º»¤€ÂR��:ܤîø¥’~0Bvo$xîxZ@¾ó€b—&‡¶üÈÃhú!X�BÊUÞ›ÂÞÍ夀9n`·Û±’"˜À]%ôæ;#�ÏЇ U‡…åÄfãÁÂuã FwËAAn‡Œp�@ÈsvG¢¥è!ïÉC¦@ ¬sÓ_{ÛÞ�‚÷]3 Ã-R îEÜ-Ü«ÓqYñ_üý͸ÆõJX•¢øÁ4?A€Kh@’ÏÄËÀ‰� T°”˜o>¡³M<}¾—˜ ñ PõŸÀw4þ|ÍP¢såG›ÐY~^%R`3Ä$¯Dê5ÍèOM�X–¥¦){ž5”÷¶³å扛@�Y×ç¶�°‰O„?²Å×_¢?}`jžgׯzÏoD”c •Ø„|–�~îFí¯{O7HS%|(Q*%·Gŧ~²w'šg}Q@ôl†… @Z�Å‚§­·#›@’à%ó ¸ß·€´B èG0C�×6Mv'/f³’ðSÓ8±•ðSÖ;òöœ 1}a‚ßá(-¸%cQ9HpxÁXÀe�&¾'Sàw|˜À6ôƒŽ%„vqÖEf—‡y·"P”þ-U~0!{X 1G9²F$mA PTsVè6°C[á;0/‘+U™à€yýÁ,:²fä;`U‰¨c+I—){(PõˆS1Py±Wt Á„ÈS” WÀ›¸!ìÕöJGjÒ.y¡l|±¦8À¸E#Ð (=ĉpô†‰w*B0P ¸RåP¼i Ȉ�zaø¢ÂxKq’¸%8ÆI¿²Kð HHã˜s�'o93‚´È¾ã«$cQg…1…Uè{؇P ÚHÑô�1FÍ4{ë5†’1fzb†g††j!H÷!eþ½È˜ÐHrxŒó‘Ûñ1»6ç1"`‚H•n�‚ƒËWV¶ñrX+rA’.v£ÁŒr1ƒõ3…þÑ.uCfd’×bK¸¥’Þqo­he‚AóŒu•!$3‡"¤À“#—pEÃ3ÒR9£wÖk!�Eƒ!‹% ’#r‘•%Y CWe°D�¬a–ñ§Î8'F㱎1‚P¹qy']ò%”µ=VÀWz˜“¢å6ò&*¹yÉi”fÛt5eØ�ô�=Ñ?pIlØ›mI£‘‘ÓrJYV˱}nã('Aºr-Ìq(õB>rèp þ‚šÑ£%µ)‘Â+(E0¹ÆŒ¼WQ´I ¤$)�[pŠ ‘›ž#ôR-ß’”u¸lQñpç4å',;gŸã‹ጵi›•2â•êA0 –Q¥GcI#�y„¼¨ 9¢ž¡¢QwnUF4À÷.S Z·—õy0vãoW,;²$ º M˜5s3ÂÅ=œ° QÂmŒ9Fü**˜ö9™9“À'ˆP5žù™›�d Bv©3‘ª6 ¥%Ê÷žåÕ‘n‘ Èd!üñ&›RsSÑ(ƒT™q"‰ÌÁù¡“=Ã3w±ÉE7ß“ :�ñ¨’!!•òCÆ•7ô³PþB!1úßí2mâs/¬¹À(¥ ñ�›K:'wV'*ÊR— ŸVf1j(Fq Õ‹ÔY—r�9H «Ÿ‚ðÕacb‡¤àI°kÖ1VÁ.4ŠQÁo¤P†8�›6>sD�¢\¿‚FMdœ+"=r¶ktA›³Ki4P4DqI Cl7 P·ø�ŠéªFfÆ$ BêØ’ÎD2ô G|�<03 Ī�â�¯$þ¬²*Œ´d+¢Œ|J– QMÍWˆ½ºjvòLë°Ð SŠeSPOTk�ô©±'»Z� kD"kžWa² ?÷HÜ C›æzK¤®}1�/QMð4+†z"“0 ±*«´*£¡Y0‡7âE��‘h;” ‹ò õT‰^ÆÊ3ȱ¬ìÁŒ±4¬.üÂ0üD¬ÄKÌÄMìÄO ÅóeÄ~ÞÑ]QŒÅY¬Å[ÌÅ]¬ÅSÌ�UÃþŠáPÆbÆ^¬ÆkÌÆmìÆoœ`<b<ư²úr¬Ç{ÌÇ}ìÇ È�,ÈSœÇƒlȇŒÈ‰¬È‹ÌÈ�ìÈ \È�,É�<É•lÉ{|Ç‹AÇ]³QpÉŸ Ê¡,Ê£LÊ¥lʧŒÊ©¬Ê«ÌÊ­¼Èž¬Ét̰ìʵlË·ŒË¹¬Ë»Ì˽ìË¿üË´ ¿1:ËÀlÌÇŒÌɬ̹,NËìÌÏ Í�,Ì›\̲º�þÍÙ¬ÍÛÌÍÝìÍß Îº<ͲÜÉz‚ˆ@µç¬Îë|ÎSÎ`ÜÌï,ÏóLÏõlÏ÷ÜÊã,ÆÕ<Opÿ ÐE ÐE:PÐG€Ï ­Ð ÍþÐ íÐmÄú\ÅüÌ}�Ñ­Íðñ,Ç mÏ"]É$=É&-É(ýÈ* Ñ-íÂ,ÝÈ0ÍÈ2ý¾4}Ê0mÓ~œÓz,ÑH\ΔÐ].0½ÑSóÑ‘ìÒKÍÔ¤|ÍM Õ}ÜQ Í=íÀ½ÿŒÑ�ÐEmÔFý½ÓTMÖ‹1ÖPM²0 Yà NàJf²`µïË©ÐÍgÍÊxMÏz­'|½ÍV�µX­EpÑý¢ F :1�ÔJíÊ9í×·<Ùe]Ê~= Xp]T ð ™×sí¾|  –ÍÀ•mÚ© Æ€¿‹z‚Ñ:P:°�þp 8 OíȨ­ÚÞ ÜÝŒº“! ’á'rí¤½È¼}ÙÚ<ÜÎ<ÝøÌ¬MÌ?=-Û³­J€ ³�� ÝÁmÞQ- Y ¶� H€· Š!¸Ð‹‘«  ±€  ±úÙŠÁ­` ®«1ÀÜ1� ²P í-5œ0 ·Ð P׊! ȺJ0 õ½Z �0 ³à Q¢ Ö¨@Š! |à µà L @ ³P ŸÐÙL]ݺ|ãçMØý™‚íÞ½ÑG0 :€2  :ÎäT- ­À ¯ðŸÐ Š¡â“QÜÇÍßRÀþëë@` wL¶pÜÌ� ­ 1@ ³5œÀ D I áv ¦pÍC CÀ|� .:³`ŠQk> Ÿ€â¬pÜ˜à Š¡ !.îÖä“Õ<Þ�>.ÐmÔE€: àØ�Méœã£nÈ6 Y 5¹�LP �� •0?  8€â±šÜÎ ‹ ªÎܲ@‹1 Á>ì}ž ˜� åÝç±°¤Py‹Á¨5)5I€ 0¹°æp¹`ëÇ\ê½î¦È–n†˜þÕ²]äf ¢>ãNîäï{ÌÜŠQ ±Ê Y¸�L  ºP °åŠÑßàX��þ > ¶€ÀܸßŠÑ |à¹ñX^ µ` tá.Aà «° °`×nµD� àꋹpâñó mî—çãé®W âÔÀàî1ôόұ@ñ º@ç|p “P ”1ðüíÉŸ  €¥= Á®³«Åí®€°ðìŒÒÎíñ€ ¡P5ˆ`×_à(Ÿ¬Ú®Ý®ìAo÷Ú<óWóF�}ŸÇSMéóN‚ï'$Mø«|øŸ ¬ ¯ 5³À UàôÆ�âîüß@P |P5M`ëŸæAÀæ²ðæ«01ßÑîJ` n½A°ç}þnõo} À ©�ìn¯D  ¯ö2ÀyÒΉßÊá~Ö‡Ÿ÷¡¶÷ºÍØ2�Ç=?Õ<¯ÐƯÚÖïʱ` °P &¾ž0 zòô±àÉpíM� ¶°àŸ_àšðá§ðò1þ·PÚf�±ÀåÉ: tí³ŸÜ1R`±BÅ'•²¤ $¢+† ¤jÅ %áEŒ5näØÑãG�!EŽ$YÒäI”)U®dÙÒåÉ(wͤYÓæMœ9kbt²‡AqÄøP#‡/•.UÊ�éS¨Q¥¢t:ÕªÂ�¢4½ì`Õkɪ_ÅŽ%[ÖìY´c^ÔÙÖm[Œk3rÐPwƒÝ þ2ì囬_À�&œkÆ(·‚fÜØñcÈ‘%o”ëáíeÌ»âNæÜÙógÐc-¼dV,*¡U¯fÝÚµ×°+g¦­sók•±qûÕ½Û÷oàÁ…Ùû¤q±³k/¯#FˆL§^ÝúuìÙµo§®œ9sè180AÎÝüyôéÕ¯g�Þû÷ÚâÅK™ÞÞþõò÷õßßß¿Ë÷àËŒƒçbhb±ÿTpAtð¼ ˜�IÀÚσ&¢p‚ &¢ððCCÔpDKÔÐ ��"E?„Åal‘E=”qF_ıD)`¼QÄw1ȉdQÈQ4²Ä%IDÒF'Ÿþ”G%Ÿl²I«œ2É¡8rK0‘üQÇ0·$óÉ'ž0Ë(ÎóÊ-µÒ 6¥”sG,ë¤ÑÊkärF7¡ SO1ñœQÉ 51P•Ȉ =€ÎB¦«ïAL3Õ”°K7õ´?Hi“/¿OK5õTTSU•ƒP3›A>Uc•uVZkmƒV1óãU[{õõW`ƒí r5öXd“UvYf›uöYh£•vZj«µöZl³Õv[n»õö[pÃw\rË5÷\tÓUw]vÛu÷]xã•w^zëµ÷^|óÕw_~ûõ÷_€x`‚ 6ø`„Vxa†vøaˆ#–xbŠ+¶øbŒ3ÖxcŽe;öøc�Cyd’K6ùd”SVye–[vùe˜c–yfšk¶ùfœsÖygž{öùg ƒzh¢‹6úh¤“Vzi¦›vúi¨£–zjª«¶új¬³Özk®»öúk°Ã{l²Ë6ûl´ÓV›Â€!ùú±,� ‡       % ( $%*4;= #+*47;?2###&*+*++#&)*.0"'5+04.5925616;49><<<&7EHALS[a!d!"i"DHLT[6D##U,^»úïàß§÷îzôåÅ qžtÅå—ß|š·�uà‰Ç]xü•'!sÐÙ¡�Ïm7ßxøAHávÅu¸áöçÜ|ðy·>À÷„ê¹Ç`|ç¥7Ÿ‰×�8ÝtÍ�W_�å½—âv+ú¢ŒÎ 8$|ÄÝx݈Ù(ä} Þˆ]†f‰%r+ª·œ•Þù¨à/¶‡Ýy7:xbŒò]øáu2ZGæ}X2iä™^Ù"ˆjˆ]uÐ…×"yß a¤�†hà…užˆe~JX¤vSަ‰K6êg„Ö)e‚"ú§Þt©ŽMvy ˜1ŽWåwþÆ‘g Ý©ù¥¦Øãš9b &’·¾×äž Šª„&9žuì](B(¥€BrR›Ü±Ç’jä�䕨!¶‚ß±ßvw'�àþ)¥Ÿßò×.¶Ä�ká¹á¦k/¶Æh¦¼Møî½TÞ«èw²Žºn»wK¯ˆÓ{æ~àRwg¼+z¯ÂÒìå¥F #¼»«±„Wj|«Î<2¹,g¬1Ä»®œ.zàrÜ/Ê·NLÇéB<êŒ&Y2ÃŒ'•ȵ,³ÏêV÷1•–4É)çŒnȰÕãâ‹­¡Ê58jº-†ìµ½ÿÖë2º3-Ê+w˜ž(aíÝÔrJhþÓ^^/ÍøŠq¡lª}ì¾Cz·m‹<—,ë~{“í0‹>4.ƒA*$ÓߎûnªÇ\î„)/jðÎ*KHqçCßÇú} f¾¶ê-3˜³qŸ3»ÊDsj¸Ó]«¬�å¢óg#ÚT'mºŸ+MxÚgŠˆëæ:é»Ñȳ,¢Öà¯9~µJò¸Ø—Þi¶'ítäD·õ¶è6ênŠ{v†Ú� «ÖÌ•^8ÕTÛß�FG,þ¨jp,3’€x5¤qOi$Áè4»qá n>¼Ô…ƒ  Àn8˜�Ìš'³šÐ„‹3ÛÙ쨯eëo'ôV(1ÚËm#3— =F¿úÐpþš“ø:v1–oeÝ"Ð ‹°ôœË†.<\ G÷Ã*–çˆ+£×±h?ÿý©‡óbßFE–Ï\+ƒœŸÁÚp‰O³¢�Åű�¬Ž Jˆ£Áæ�ƒA~`·æ 2‹ØÂãÉH²5ò‘�|$É*²’˜Œá$3ÉÉ^²“ ¼×&ÛÊR’ñO.ƒ£)QiEU®}C †Ó¼$ A J(B.qY#á—E B8ÌWó˜ÈL¦2—ÉÌf:3ÅÄV4¡iÌižÐšÖ4e6ŸÉÍcMs›Ý”ãQŠV¸¢ŒCÕ#K>z3 mpƒÌ Ï4¤Á øþÄgÆ€aÚ œÚ¤¦:²h4]Û$(&¿yM�–0„ }¨2#êMNRT=%&¸(zÑp.2£mhBÿÙÑ{�ó ÿtè5é5ËŠ"4›%edL+Ó–šÐ  x,vÊSX€â (ÅNQM›BNHª”°\òò—.ª£QºÑ—ªÔ¦)¥ê0OêRjn5«+S(H1ªRšzó«BØHË*Mµ²¦E5+X¯šV“ ´˜ ]kW½JUiŽ5®)e([ûZU´’4°m-l[±ªX¾Ö•«†å¨[±©Uƒ®†%¬\¹ÖÎ.V¢&¤ì]56ÖÒ¶«x=-Y]ªWlF6±ªþªiËÚR”Ž�¯ÐiOwúŠU„ {œ¥N0Ü Á MpšêT`ð™%D³éQ´¢¢ðFkË]‡fô»^�nxÑjT¬–vªµ�ëw×[Té~uº[¯zã+[ÙÆ¾êMk^í+]û†÷®ï 0YÅÛ]ýò¼îïc¹+`üØÁ &p{SK`Á>ë„_+ß÷7Âÿ]¯…ÙËàíÖÃ屈õ»b‡ø½íUð…ÓkÚý’¸Áÿ�ñ„eœãþ²Ø¿!o… _úÖ7Æ1{{œa«x8Ivlwqüá&ËwÃBnò†œ×ûòwÀÖq˜× …UäV·»Ímþ+ €^jÊRºÄÁ †ƒÜå:u—Ïöä^¼â ŸÈ-O_ñ $(8Ê[î³¢ûœèE3:ÉŽŽô¢·+鳸ң…t¥§\äMÓ×Ó‘n´£O|ßIƒÔ'Ʋ¨MÍ_Tç«E01ËTŒÁÑm…�ÜçTüÔ�8¯7ÝXyÓ”õ©1=Ì\«¸Çžn5³¥�ìü.;ÔÌVr›5­èù"¹ÓؾöЇ­l-»ÚÔC®öª5­ãczÝ!,�‰ÝÂ�~(DýæYž`Îŵ³S¡…<ëYV’þo ‹OÀ[Ü�†ø©×ýðlKüâϸÆ7qŽ{Ô~vþ�ðkSܺÛÎ>µ¯‘=ËEâŦöµÁÀ éúÁH`ù²Snñjüç@ºÐ‡ŽîŽýèžC>žÒ®à)$^­Û“w»ßÒýw ŽK'â Cð�žï$i?Ø{Þ¹õÒ×Îö¶»ýíp�{Ÿ¡E¯|Ñ<÷´)~íé`C Æ–ù©i>Ë6¤"ç?ÏA>!÷Æ;þñ��ü¢ÃÀ÷>� ;Ïm<½ݾbêõ†ÅÔ!ÑÓº;Z–ÿκpï¬ Á „p‚ž�€‚ÚÐßsöw(ÎÎÓPä÷À—sîS/]ÌÒø’&¾œU/|«þ÷Ð>óQÀ|åCúÁ'¾ö‡þÿû>_ßùÍ·>óÝû}ê�_üÝÿòÁoþíg?ý8@þú›¯~ñƒßþ8hÿ÷×�ÿýK¿ðŽ�«� ‚€… ÒU‘à^…  ¬` e°xÐdr!t©° Š L)‡�p�8K``]©€8p ¿e©�ë׎�u'À‰€ ©P ›‡P ƒªàØ�Sª� �ðg®Ð§° ˜ LÁçi&�­€c� lVx¤À £prŒ0˜Ð ©Ð³Äƒ>8Kj@ ­0 5¸x ( «@ V7Kæ7þ÷|Üw„‹¦}äw„ÿ§†l˜ukHó‡m¨~.X}u˜~Èç„x~tø‡þõwˆîGݧï‡}ØÁ×~ˆØ~cÐ 5x|8P­0ч›Ø hÀ‰™ˆ¥°[‘��€€‘°[¥ ‡þ†us6‹>Ð\À„TàH|¨|ë× =eVÐSK¨‡HXiÆg|Ý÷}Ëè|×7‹s¸|Ð�÷÷†óÇqÒ7�ð'�üG}Ë8�šè‡à8‹Öè‹'Þ7‡ÞH�ux‡×ȌѸˆ‹8Kqð Zª€Kà H0g§@‚î…‰@�Œ‡{‡† ð¡ …€)§ а‘€€HÐ Ú…9‡ a&H~óתT—˜sDð[D` Zø  V€ÅÆþØ� ˆ°hèX|蘬©ð’³ªð’·õ’�° /üˆŸ’9Ta`”8­TdH«@‚œh„帕 ¸lÍȈs˜�é�cÉŽf™ˆ¾‹Ò¸�éŽîèŒü–ïø‡_ jnyjuÙŽð8~âØg¤è^é‡Ñø—'˜ˆ8KÀØSŸ7K¤·[5w³„ÿ6™©w9€g§q gkX™”ùo‰¹SÂHŒ“)gÞè™äè–7iÉ–¥I™kÙ‹î'‡¨i}¯)|ÿ¶Žd‰{©‰›¾YšÇ‡|Éh›bù™kùšÚø{&à™¿É›»ÉˆÊˆœ½y›îÅšá×›× ›ÒþI™ÂI–Ò‰›¹Y™ü—�Ëgm` þv€8 �pa� íe|‡P“Y�§à‰÷9gi€žÎ†Æ6KPð >У~§�§PÛç–�Àñ‡  víÕƒ€Šð V0‹þٳ䴶œäˆŒ³”¯` ®`ʸÂ&gŽ lŒà µ†yê“8àwñçw΢ào¥�๛@Ê�³xšÓÙšºœ?*¤IÊ} ª|Öé�Æù£Ÿù™º)žA*]$€$К¨I~§yœV žÆ§›à¹–`:¥h ›¿Ù¥Ê©‰Úy›oj¥Ìé™­‰|œx³T¬°ylÊœ†Š ¤ ™fþ¸žöÖ !J™’Y�söT³§n%Pùwœ÷·{¼ ¾'¤ÓÉœñ'œ«œ¾¹¦£ú‡ò7¥zhœ]*gʸ¦}Ú©³ˆŽFšš±:ªLʈ‰šzÖ |·)›¼éª°êªô·«n¬:žØù‰«�ê‚°¢… úŒW¯¨Â¹…€ˆ@ŸÎf x�§à�º«°|9 M0 (¨� ­ ‚jŠ›ÆÇ+Š�@'`�  ° ƒ0g}` ¢€y1Wx)g‚pf¹å û ƒ YÀ�ƒ` Fˆ �z‡Ð&€¯Èב8psk˜ v³Ô±æŠƒàþ ©ð²¬àM*¬ß™«Ø¹¬¥º�ãi«ÀYªH œm:ˆ�˜�–Šœ7ŽÇ:™»�=û�ëתÉi³p¤M¬J‹³ÈJ´¤:›Ø™´ñGІY³¹‡”e`¤8p�X³§ØS£‡€�ÉS¯8§' ™iŠ¿4{µ©%ðo”Z›ŸÙ™ qÀ�ij´Fº´GÜù …Û¸uÚ¸i ¢¥9œ�[¹sv¸Ÿ*¥¨J¹–;¹GZ«ªÚ¹¢;¥ÉX§×)¹—{¦u[ºšÈ´£Û¹Æ'„ÂwŽà�èò'Ÿ5y¹æ�ap¾¢… 'àŸD0gš!Wž'p Bx¼…Œðƒ³¡9þ° Zx�À¯ÊØ­àþ)|dˆºŸûo�ûo_ð˜˜ð³äwËé/Ç/—¥&˜ºmà 9à¾5Z¼û 'þûºLº­«¸�«¹Ÿ‰º·i�®««?ë·º‰kºË µ ¹®kÀŒªœ¹Ý¹Á̺Ñ)]M 58œl`à‰‹ka…«l«Š;5zƒ¦…K·iz·³W@p[ê“:© ¬´J9oö¦ fz¹ l¾Çʺœ¸™[¾�Å \¹Îy\ Â-|¸«kº¯;ÁœÀÌÅÆÉÀOlÀH*|Ë ªì¹VŒÀE<ÇÑkžöhƒWɰ   š²»û­þõ[ `’lÖ»' ©Š?X‘ZHý¨‘'à’à›~°±Nܬ€¢%I°ÀfH °'9`Pà Ž Çן Œ й‰;™VÀ ËI©à.Ü“QÉ *¿ÇW¿V�'PÊ@ð”(*•Ppjà{I€½[Ìa,ºÂ‹ÆW<ÇM¬ÅqjÍÜ›Ã鯡k¹MÜÊÇ*Æhü©Øl¯…Íä8Í|ÅÕ¼¸GÊ´í\Æ„¹iÌÀ¹W›‡ÆhJ¹e¬¸rìžN‡o+z8z¯ð»iJ·­lÍAPC0vµGCl%@@L©mœÀH  :ÕѰ  HÀšZœÜ™ÑÖ<Ò Ìþš ú¤æ«ÒbªÅ’ÓN\º%�Í'mÒæ ÓöœÓJ Ó¤›Ñä\º»jÓ¤ëÓ'�Ó8­ÒGMÔ®+Ò2½Ô¨‹ÓP}Æ)-ÔÒlÔ6Õ>ý©mÜÕ=ýÕ•+Ò_»š €§Ðžÿ†®`ËïlóÉš¹¶œ&h9P©ð€}€Ì«|Dð˜Î"x' ‘&à¦b½‹ÐÒ;k«P c°œ�� ¡Ð…0±˜ ¤ Å‹Êp½×1-Ô\�± ËPÙ¥k¿õ±„`ô&°Ù¤à£¹Ú¢ Û¼K¼&`ŸÐ «ð D ÓÄ­ÕÄ�Õ%½´O�Ñß¼ÔŽ]Ó^Ý—þ‹Ü„ëÔνÏ7­ÓV-Åb=Ý lÒß­Ò4}Ý£]ÔÝ�Õ ¬Õ0ݺÍ�Ý5 ÖÝmÝJÍÕÚ½ÜûßîÍÓË©p ' ö† òÖpR*Ò’ÙÎ{Æ™@àA=© NÄÔâlãù-ÞO<ÖÆ�Í ýàZlÈ<­ÖÃ%älä\͸|ä÷=ãº:ÆÑL¹ø­âFlÏŽÞ<.ÍQ~äNnát\ÍêÝÊO­ßN\ÚäÍ-Õ4þ¹ö½â[®â^½â`æþV½ÍPýÝ%Þ�Oªæ1~çG ÏžÓ¥=èDmç,ÞÒ9NÁ;-è~®ãxèJ ¬À{‚&®P°Ð¨[àÚmÍıg$]“JÑ“:N]ã­^ãõmÔJÍܯ.Þ¶®á)]ÚŽ-Þ».ÍUmëÏíÓ¶Ô³Në(~ëÖ]߯~áÇŽëyìÅnÕëíê°^í±®ìÊ~ë\ë£þêc¾è‹ìÉnäÑmìn Ú. ‰0ÞÈ®ÓÂÞêî~ë½ïÔÎëõ¾ëÂ~Ó­>æ³îîF>ÓNŽìÍíÇîïµ^íÖnìô®ÔÙïúí¯æ ÏëïþðÏ>ñöþî Îì¿è¯ððÞíñ /þßÚ½¥oòòñÍ®ï$ííò-ð(Ž®ko«�ÀîîÞò%#Fÿ�ô à `ïßë4=ò¿ÞnÎïÿñ²>ßWïìÝ^ìÙŽð"ïíéý.ð(½ò^/ßÃnó%oìXïÚ½íWOöôþõ¿ÎíN¿ì!ïqíÔ…° SñOîåŽõÝ>óýnñ¯÷ËNøÐ^øloõŸâYŸöi�øŒïö•_ónßò_?ê Ÿ÷s/úoõ6?ùœŸøy/îxÏòjßóŸï2ñÿñõþôvOæfOøøNóÿòÖé¼ M å ?·'±õª>Ä >$þ0íüÊ?ýÔ/ð¬^ýØŸýÚ¿ýÜßýÞÿýàþâ?þä_þæþèŸþê¿þìßþîÿþðÿòoëa åÔ ¥ ²Mý(1ÿþÿÿaBà@‚ D˜PáB† >„QâDŠ-^ĘQãFŽ=~RäH’%KÆB™RåJ–-]ª4SæLš5mÞÄ™SçNž=-’ðTèЄ/�5JTéR¦M�>…UêTªUi�ˆTëÖXV½~VìX²eÍžÊUíK´mݾ…W!ÖœtåÞÅ»síÞ•yíæ|ðo`“ƒ öÛÔ0ÂÅëä¹+ãƒ%_Žú8¤æ�œ1 þ¶LÒógÒ¥=JæËq´i¼«Y¿†MÑ5ÑÙ±ßÎF½×ön¡µy;þÝ[upâ's«}í› òºÅï2§íüuèŠÐ¥_<ŽüzOëN»oø\Ùm¡ŸˆÞ`v®âÒUï^¾óø? ÖŸŸ;{­úGâ—ÀáüóI@Œ3P ¿Z.¬øë�@ “ó®ª5ºpÂŒ2¬‰Ã¢ íèK�BQ„ˆÀ@"¡Tâ5¡�[uW CI¨sa›Xˆ'a["6‚”€å„&-mÔU0¡—¢|‰9´�Opøä•TL1¢¢2N(~=ÞV”UFy2…Mh$•mW±(ß}·e•Sfþ6ÁŠO|žÝF1¡�FYE?Ò(…•HÅ�ST…ÞS¬Å$Ôý�?L@£UN»¢T\CRQ%NЖSLécVL9ÄSždDI|nc @NYe‘E¤„:•Rþ¨¶‹QdÛPV!% H`DW¥!,‚‡ú•ÀÑÍ4í`–ˆ�Ãa�vÐ0R‰´�Pr¦• 2hO–ÂYÎᕊ)ÂAEqHåI–‰XÅÏ1X!Â�*‘Å yw�š ÅOæÓ0‰Rú áûÄï#é.‰¤Ó6`q–©}�WŸmä^SL£ÑÀs…ÂpàŠ( jx)ªU)þ,%B€‚ +„ r zâ^&8àVa½0¸"VcPE¤Þ×:P{ Úóœ×ŠXù«w~‚B*ÎP°ô\ät¨ûBjDCÑìGu)y&Z‘ "Z {ç Á*X¡"€xEa!šõM “JÚ'œ‹#`·ƒÍ²˜4Q ¤h€B+pPBW˜hƒëÂå :‚SdHF‘´Û $·k&¡ dµeq…¥(g9òO~ÀJç>Á+*â€Ñ£)Òv Tõ©[DÂæ Q9�üÍ‹à—@Ra½FXÑ¥h),‰‘@´BDL…+�Ç+ú MIû¤@ þ‘=ÂÜÐ!r*N} dÌè€ä‡0qH$a"á|H4¥&Xá3U´¢€ËÝ@0‘6`"}mÅ»x+°� $Ëá \²"}«LD À0 ´â’iÖ Š‰ì ãõ*�0 ¬ «XE$\‡Î�xS ƒØ%ŠP”B‹~³çT± û Ä…HÅ#þ)‘,”L¯hOq,. ó‚4#(‰Á'�SœR"J±R‡D(R±Ëyârcëâ‡�‰‡WÅj…dòL :� Ë†SGüë„iÄNG!%–áÖºH)ÒЊH�1Uˆ"qþ (œ e»H¾Æà ^&-�m…ëâpXR�E«ªEM@ˆ]bBîû(NÂE¤±L ÂA"º”‘?|‚ɈëܺÎQ§‹0W*•Ö�ˆBJ -^%2¹]²,UQWÄx‚B -«YeæqsÂU”rëÃLUdáX֫ȬžåºÝ5Áex[Ói‚U°M"t „)Ê(Š2¯ s»B£Ð0Š`6b‡�c¸rGHhQ…8_áþ`õ™ ­°^ÆkÉbÔkuÖ'>º¬œ‚É�ÌB§â°Þ‰à  GÀÁÚ8Ù¬™Àue…« d™2ª9…¢Â¯þ1À"8°‚ÃþxV¤PïÅA|Oé¹Wî ¬ðk’«\&¿…¹“iH$ž+$¼"v–‚š-/˜�ÆJO’È^W‘ˆÅV ~ ¢w-‚WLÙ·QøD+Vñ  ê”D,Ì®WÐ1¥Ep˜>±·?¸Žk«0E¡ œ´jù–ÁߤìtK1 Mø–Â� "ÍÚ7´T|â›É&Vá SÎ*Ä)4•>øáó"ÂÐÁÓ!»VQ×C\#à2Ÿ}»ÈäH)49ƒ¢Q°Uè~Ödf7›0O&‹zòë–%{$IÞ C|;m�!ÓÙ%úvD ÍÃj�¥VèT¼¦Gþ‡ÛÝïöŠaÆ-’rÃ#HèÙËölŠÆ¤Þ6ЋƃÀc \1Ù8ÁÎ�Õ 7̵wÄåòo‰W1ó¶xÆ5¾qŽ;ã×ÈE>r�œä'G9q(žr†˜œå/‡yÌOîr™×Üæ7G:‡qû0>÷ Ðc"tœÝèÁ8”¾t¦7ÝéO‡zÔ¥>uªWÝêWÇzÖµ¾u®wÝë_{ØÅ>v²—ÝìgG{ÚÕ¾v¶·Ýío‡{×�ñ�ÄÝîwÇ{Þõ¾w¾÷Ýï|à?xÂêsßyÝ ïtT½ñ‹oúãŸ.y€ò[¿<×3¿øËoòŸýÒ1zÒ?þ"ô§G}êU�vÄ?“ /€AìcïÚ×Þö²Ç½ìe°{Þ÷þ¿~ðiÿ‚á×þ2ˆAòc°ûåÇÉ�½òcßÚS¿×wA €ï‚íÿÁîs?ò—_8ÿ Ô¿~öCÀý*`ùÓ_þ¨Aþõ_äŸÿÀ´�À$@°¸�PÀ´�þë?þÛ¿¤À d@LÀ LÀàÀô@û›?ú A $AÐ À€LAÔ˜þ›pA¤@°À˜ ˜� ˜�ìA Àü B¬�ôA#ìAPÂ%dÂ?(±¦3„X=AHþCÈ;Óc:-\½.ôÂ/æ3ˆ‡K<�p¾Ø{�°½â£=ñýރC >á¾5ü=X>äC¾æƒ¾3”=ç˾룾ìË>ð£Ãð>7”=ô#¿ó+¿ `¿H\ø£Ä°¿K´¿ý‹À ÀNä"4@ Ä@¼ ”Áüë?´@\ÀÅQô@ü¿¬Z¤?RÅÄ€]ÔÅ]ŒAtÁŒÁ¬@ÌAÔ€ ÐA Â$ÂTFÔA&¤F0„3@°31`:B B­ƒ¥"„lÄ»ÀB·C@pn.T:w Ãx”Çy¼Öû¡×‹�4LCÛ#¾Û˽4þŒ½8ì½ï3DîãÇâKC=l¾=t¾4ìÃç DBDí»CC´ÈDTDX�ósDF„DI\?J¤DLÄDýF ¬O@PdIRlÀRDITä?b¤À ÅŒÅX¬¿Kœ¿ °Id@]ä J”AaFT¬ÀDÆ|A"Bý“Æ#ÌA¨Æ%ìƒ'8°{vTºBøÆ¬ûx„(;˳:s|»(�3`Gx„Gz´Ë»ÄK·³GÕ!ˆ|ÌÇïcÃ5DC7HÞ«Hî›Ã~4È5TH†ÜÃçóCç‹�A´>ÊDÌC¼HïËÈŽ|DòÉ�É÷Až¤¿ä¿|@þPIlIPüI4EþƒÀ¨@V¬À×ÄÀœüÀZÁž´€›Ô@`ÁáDA F¤ìA ÉDB¨¼J!lFÜÁªDƬ\Â/P©3„/„Bè€Dx¼/sdF0CˆBCxCH#P:èƒ+üÆBx‚ù„O¦ë€FÈΧ€3¸Âl€)€H*4CxC@HG@èƒ30„€íìÎïT:·\:Pƒ�: õNðO¥#O©+ÑvlººÌËeÑ•;r½�€=€„�5ÌïK¾ß;ÃÂ̜ܽC¥=åc>…Œ¾"õˉ”Hê¿ËôQØËÈÎ4¿�üþÌЄ€€L Aú3ÅSÔ¿Õü¿PlI|ɘÍؤ IpżÝìÀÑÜÉùEø‚(�DˆBBèJ`µü‚¥¢Ô§ë1ˆT(õT)ÈÐ(„Ô³ì8P#ø‚ }ÔXÏsT:O}ÕDhÔ§»F€TI¥T�º/èJÝBµÖkÅÖ©«�À8¿tR ­½Œ„•C&­Ã5È„\È…,ÒÜSCÊ´>ãKÌŠ¼Hq�½F|DGüLõþ£ÒX’ìMMLMþóÒOÓ¼@1uFœÍþ#FV,A tS´E8�X ,Ê^DÁ @Σ¤AÛ´@ç Ô«Â’=Ô@�FE}„> Ge%Ç>øN)àÆð\ºK]:B Td€GèÏq€BÈÙþ|U) µl:8U¥;ƒüüA„¥ë€>HÏGøèJC`Pƒ }ÙüÔPfMK¥ƒüt:-Ö˜�ÙD˜º¥ËluÛ·½Ö½<‚?pmC'%ÌÝWÞ#ÈyEL íG½•�q]¾?TÄÊŒÈë£×yõQqÍ×|U˜R*½ÒúËR--S”,Øè E „MdX\ΈÍÀþ‰í8ÍRŸÎ äÅ:µS�ÍSÑ¥ÉÜe|ÊtF©Ì?D5ÆDUT1�‚…:h=ƒ'S€/øU¥³Y¥ƒÖDèàB˜oT:èm:³uºxB(÷4Þã} åUrœB«­P(�­%ÞðýÚý$BHÏð-Ûo<[E^å]Û¹LQ¸õßÿµK¹ºÍ=»u�çÌÜãÛ½\Ì„=yeÃqe`=܆lמÚÃ>¿í¾ÌüGÙ{�ÄWÏ”\�¤ÜÊØ àR””Á‚eÆÖ\Ó5eÀãÜRå\@�mÅ9-]pSÔ½? ð\ ¬ÓJ] ÆØÆà4 ÙÚåþÁªÜ¿fÀª¬JEÕ�/`àÞ—ÅP@˜U e^°¬Þo„Þ�UËAÈÆçMÖ¥35ÿd„§„TÍNXUº�­Ú®„¬eß®ÍPbEÚª3 ¥^ðâ Ýßwì_VäE^=ÊpŒ3€H&€H6€I&€I–dL¦dJnèäødPåK¶äJ&eR6åSv€UfeVeePJ>åZödXn€H~eYNeZNåK€S€a&æbåcåPæefæ¥{<ÉK€h–æ 8i¶æk>€lÖæmN€j¾æh®ÅpçqîÉžüfpÎænVçjÞævÖfLÜEúÀzÎæzþ¾gËÓç}–fv¶fŽfvöfoîçuþf@@€h^h„FF„–Í^@ÐTFèO#b€!xZ6&ãF�c@Qãëíx€'V§óTËû€F½O˜é`„€/x„@_¬�Ë íƒè‹®cFXcO-ÐHÖx²5„ ê‹Îè©Ëé3h[FÖê­^<®‚ÐäL.åK®dIöeOöäXFëN¶å¶¶åV†ëW^ëÈå\vkLþdNÎdSNå`&€fR.æÁNkdNffFlÉ£<8çÆ®€vgm.h€&çqæÞg hìÉènŽìwÆþ míÐng|®ç}VmÆh�VgÏ~íŽí€¦m‚Fèph‡¾ÂåUÖà ä }ú-Ðù$iëåU U@ЩoTã˜OF„±tºÿ\*@`€ðÌNø‚¥ê50ô%=®ÐTýí¥3‚¥¢Pêf„>XêDhYCðmòU€à–:#(=üNd®æïþî;¯>‚ødL¶d².p´ÞdKåG뻾d¶&e¶†k¸ÖeX¾å\l·†ð¼>ë²6ð`æÀ&€ÁlÃ>fÄFìÊsf¥k¼Í>çÇèÏNglçʶqqžmÙNgÉ–ñlÖ€KüñÑÞæÔÖæÕæg‚žqƒ¦þñÖvqoÎí…ViŽhí”î¥CÖ04ä°ƒV¦€+¿º¬öï0s¸pkGåZ6åNNðÏkQk±Öp˜ðVÆåM¦ä ×ðc®ð8÷ð»&ñ7qPFñfVñ€‚ÞlïgŸlÚ¾ñp¾lËnr�îlG§¿·¿x&ò!§g#·<ØFò×Þq‚>g€†í×nè'G€3¨òì}u#èh0Üò¯«u¥“õ¬ó1çõ^7»2‘3gó²þó²fk»Öå´¾óµ¶å^¾ë:wå ¯p»ÖðJeX6k/kqatbtB/teVñÆ£¼ÆöfFÇfGGõiŽô—íu÷ìþßf íÐFAúã€"¿ç@€OõSïì…îìo6õØ6u‡ÆmÜFhªËò¥KÏW_½[ï:ì•BŠŸºÒ#=_çøŽ;`‡ (�p Oóe ÿócÏkŸó·†vÀó\Öe=?ðçð/ð_îv`öëoæpo€q'wÆ«<ÚÎñÍÆçȦqu~wqžôZlr¤·t—gx¶€¥·¼"õ­õ‚Gøu.ø±/èÛ^õ†öø´Wûµ/<�ç‚Àsg×öl�û;—öf?y5§ó˜§ùM¦ù».lïû¿×vTÎp¿l ¡'ú�:¯ôÏÎñt'ç çÞœ¿ƒõR¯úþȶ¸€Ð¾úyÖz{îúP7õK¯mÈOzÈfx³G¶—ýÙ§ý¸s{ö€{<�óS®{b§ù¾7ù?ÇûZæûi|Ÿy½ëRÖseF|Åg|¢7÷rWñx‡üÇîñF/èp¶ü§çMÈßñŸ÷ÈÆ€ mѦ¿Ðç÷Óîú�&ûTŸñøßüÚhØGûÚÇÿü×ÿ�‡Ñ{ˆ¸ Hpà@ƒ60Ðaƒ†'N$XÑâD7rtÈð#F� DôÈPâAƒ" P Ë–Ьi3"Μ ^òìY€Bƒ=�à(ÒF“2Uzà)T¨M“V¨jõ*V¬S‘]þÚÕiÔ°0Œ%K–ÃQÕBàö-\¯J�~]Êu+Þ¦ìU`�ÐÀ‚.lø0âÄŠ3nìø1äÈ’'S®lù2æÌš7sî,ÙèТG“ë4êÔªW³n�Z´çزgw&J”6PÌ·ïÆíû7ðà‡/nü8òä—K3oîú9ôç°•S¯ný:öìÚ·sïîý;øäÍÇ�Žnþ|¬éá׳oïþ=üøòçÓ¯?˜<~è÷»Voÿ?€ 8 �x`lù‘ǃªù‡ „J8!…Zxaf 2G‚~ zø †!Š8"‰%šx"r rÈœ‡††"Œ1Ê8#�5’¨¡sþ-2¢�=úø#�A IŽ,êÈ�C*¹$“M:9d‘¥‰ä‹OZy%–Yj a”¤M¹_’[Š9&™ešId—¢}‰^˜gºù&œqÊYXšj®i^›sê¹'Ÿ} Y§iwF—§Ÿ…z(¢ š ƒV™(¤‘J:)}‹vØh�Rº)§�zšâh+jˆ©tš~z*ª©ªJ™¥¤f Úª±Ê:+­´ê*k„Öº+¯½Æy+®šê+±Å+f�+»š®Ç:û,´2»ìiÍF{-¶ÙJ8-µÖjû-¸áÊ·á¨Ô¢æ­¸éª».vÜ.‹.»ñÊ;¯oî /½ùê»ïdöâŠ/¿ <°þ`þº 0Á +,¯Á¤"¼0ÄkÛ0¦K|1ÆÅRܨÅ{üq¬8Šj§¹é 2Ê)ûº± «ü2̆²|§Ë1Û|3œ3¯Y3Î=û¬¥Î_òü3ÑE+ô”C½4Ó4–62~%W{rÓU[Í$ÒG*}5×]o»¨Ô&Ãê5Ùe; ¶Ô[›½6Ûðe­£ÚmË=wwo·7Ýyë­œÝ.޽7à�Ç×wƒx ~8â�¾#Õ‰;þøq‹Sù7ä•[>œä`6~9ç�k–9››{>:é��~žá¥«Žøéxо:ì± Õº£”Ë~;î´C—:î½— (‡aóî;ñW«˜cÉÿüÒº—jþ;óÑ?îü«&H=äÔ·¦<öÝ«¬}®¯{?>Ùà3+>ùé[m¾°Ð«ÿ~×쿆>üõû,ÿ¹ôÛ¿ÌøOí>ÿØ3ÿ‰Íz<àÏÈ=2\ Ô_#˜°P‚Œ xÁ ^Œ4$ÀAñ0à`Aiƒ SȮ҄AƒxaL˜<ª°†á*M \øB?ÈÐ\ ´!SUšq$€É|Hà 2ñYC,â p€Ä@Ͱ‚M¼âÄHEAô°[KÄ"wõÄ"vñ]_ #C¦E(’ëRJ´bãØ«1±Œ÷:£óȩ҂Eœ"ߨA=’WCÔá C3Eááþ±�Ž<9FÂñ‘–ÜT$aÇ5ò’ž”S&ɨVò“¦”ÙÉ8IRò”®„T&7y°N¾²–YŠå*«ØJ[òrO‡,¢‘'È^“O¡%%wYÌeš)“Á4’.™)M7=‘G¦K9Ím.鉄Ðá±iFmr³œ@ª¦�ˆLVš³�V  ˆ¨CY:Œ–î¼ç…Hƒƒ-Ò³böÄ'@¿6(ªSœw$g@*"g®3š }èŒpiPN"¢��D¡9Ì‹rôF©|!s¹ÑŽ’4ŸMdCGZÒ• T4 %â55šMe²´¦b( “iÓ�(’!�è,+ÊÓ¡‚'þ£Rb'Q“ZŸjÖQ¤3U*TÇEšoª¨õjT³Ê7Òô±�/ŒéQªÕ±~§4TÕá3êR²²5;¡2�W›jUbµ­vÅÍh¤øMªjÒ©ã¤é]‹¦�BQ”n|ª`¹“öu®û§b#ë£z ©’½l½ÛÏÇÖ³ž�ìIÖÊŠõ³¦Ý CÛ2Èžö´LýjJÛÚÙZ&µ2ý+msË*Íúõ €Õ-pCÙòX6¸ÆMÌp“(Ûã2—N¼U-ÍXÛÜ»† ˆ”$tw&Ýé¶žÝìj;ËÝËzwˆl£NÇ;ÞêÑ…à�®xÕ«X>¶÷½¥èoåK[tbw‘þÅÕ¯qÇ(ˆ>¾W»ñ0uרÇoXhÛE°RͪC÷Þ7¨ù…ðgék^ìÞÖ·ðTLÕ'íÁæ) ±×Æv¿'î[¡HkÍÄ/®)Úb ß8·‡ü&�+|Õ ÷8±)^°ŽÕºÜ"g˜«é¤pv|`&'UÂUÝñ�©ìZ'ï5ɤ]«–%+᮲Xɸ ³gC)dºÍd}­Kˆe6»³¶5³‡ë,fÆŽ–¸¥Õ³`“È%:Á„Ýp™¿LèBw÷¹o=ì™XsÏ.–ô¤¹ÊÆ5s¶Í˜ª GùçO�õ—N´ŸÁLê¬qŨVn¤W]jÒ¢ˆþ 1Ül,kn²ð¥1ätx=½ë•2' Dì lø {Ø$Í ³µ íhGuÚÔŽ0ÚF}í�Z{Û MŸM`XQ«ºÎ¨LoÓ›t'†Ýšqwcà=”v?FÞ…±·½“oz;fß‚Á÷dü�ÆœÝG.hÒº¢p.ÛÀ&@Cs”=šHÜR¿8Æ3®ñ�s¼ãÿ8ÈC^$4€! Y0ùÉM†•—\ ap¹Ë[†™Ÿ< )¯ùÍMNó™ë\ç6ßùÏ}¾ó¡ãè9Ï9ÑQntš§¼ç=/ºÏ�>ô§+=ê4wùÔ�®u g}éV¹°Þô­½æ]/{ÕÃ.vž“êfwþzÛ«þö±Sýç[¿ûÒñnu½�ïJçûÍo^r–³Üê+ƒ¨(I‚_‹¼ñŽ<ä#/ùÉS¾òøÉ F ù„`óž BÐùÏ�~ó¥×üé9/zÕžó¤'ýêWÿzϧ^ö²§ýìM?úÛ›>÷¨�}ê]�{Úï>øµ‡}çy�úÖs>ó¬þð{Oüß·>øÍÿ}ò�ï{Õg¿úÛ·ýî™|ë“_ü¯/?îÑß{õ›ŸùËï=¬}›…��Ûòö¿?þEf©úƒœÿôÏXó…žû  & . 6 > F N V ^ f`ëÅ~�Aþ} >ZŽ hþø ž ¥ †€ói  ¾ Æ  Î Ö  Þ r …y`iT\È™` ’jÈ‚›¢ &a²(á¢`^’@ 8ŸVàâ`ûÉ`Ò`Z¡n!~! êà�ñàh¸AüÁœa¬!®¡�Æ¡Î!Öa—d�û¥¦a²¡Æ€¡ "!¢!"": ²І8$:�Lâ$�%ZâØas¡&v¢'~bÆá!ó=b$R¢)Á��^ â "à+ â,‚a-B`,Þ`.&"/~Þ"vZ#†F$–b%^"#(&#Çq¢26£þÅ1#yˆbë £)N"Á1�œžòõ¢ î¢Šc’ãæ�£7¦£:à/[0‚F)J"%Zb6ú8#>öÎ!4æ£Çi�ûA¢Èc1*A6Á¸â:Ú 9.ä6¤CFd¶#³½£ ¤"h=# ø€ø£HŽÇ ¤Â*XA*ä€ 0ÂŒ•¼‚œå#¼Zi´Á'¤É) öã3ZÞ‘€4~ž@R""\�AÖ#HJ¤RºbÁ$lÐÁŒ@tB+z^ÈÁV%VÂ< ðÁRŽeR¤Ã1žh ¤)j�#8�Gc ÀxðäH.!Ç!þA+ à@€K2ÇKÆ$~̤†Ødš ZIÒå2&Ž¥ç-�dZ#[ºe6¢ ÀB$YzŸCÞ€žUZ¥ûeåVvBW~e†@Xv&lN YJ™ hAi˜bä$‚eÆefÖ%p†Æ”iü¥K¾Bhø`i0‚—5‡aŠÛ\ ¦ ääã•€O2áAææ-Á$AF� ‚[y¢@\el¦§," ðÁhžó �jž¦V‚ó‰€éÉgWV�}® kŠ¥ð©§€²ã ý•ERâ5b£*ÂQÆ¥rê#vR B˜$#ü@œB �P*Ä$˜‚+þœÂ ,”�q¶*¬& � ¦ œ!¤(&@h$¬B+ÂJjŒBˆFh(�bB"¬B*BÂ5‚)¤(  äÀ#¤“ÁtúÁ(¬Â(X�]gå9aëU£‚FA!Œ' ˜g (šŽ@¬�lÂ%`Áð žœžctÂ&T fUfäÁ&Xœªi} äéTj\‚'X‚på,*xÞ% êæUÂð�'ðiŽðA&dÂT’@Xf‚ ’*ÈÁ&@%z¦©€Îf‰�Æ�ZcG:Á A À^Ê%—ÊáK&üÀ'$‚ ˜B_’#è¨þh”)€‰ŠÛ_†Á*4� (´è+Ø!„à i(,ä‹Î$ˆ’ŠF(ÂÈ´,@¤Ä™Àìå#(Â>A‚}ii±r« Â(ôj2¡ ¶žwf£‚.Ad¦®â °€« ¨lin�'PBçÉ礞£{Ž€ð©iòAh A&À)jâÀ&tÁü@¥ŽlB�€�WnÁ%ÁçÙz^%ðÁ¤Ê�€ŒæÈçyvBÍ @tœæ€%ÀiĦ)¬Ö˜¬Þæ5¤,l PPœÀÆ!‰²¨ H« &˜@°�ŠÆ,¶JþK’@# œÀ�h ™Â„F*ŒA¼‚J†Æ"Â'B~ hh B#$‡´�&<Ü)ÔkhP(h@rŽí,íyçQãü|m™R\ÕªgØ�óÉ) dŒÀäÁûÅ)Ëjim'à~F¥î²€Ž€ˆåÌ�çU%T‚Î"`% * (íçU^‚Áè±�%l^¼g^à÷^!†o†ÀÕæZÖŠ†u�(¨*.¬ ”€üžXgš8¦Î¥b.#ÿúíæÂ‚Þº �(˜€À,°½Ê­ÿÖ­&´‚”ªB+@ÁKÚ×+DAh€BdÁ*þŒF#¨Â*¤+y´A(„†Üä$B(€B)Ü$½jÂ)´hPg‹ÂB3âïþ]œvjÞ$,�øÀ¡®ê®#ù*e ¯æå�VÎAh¾éìA'@í\BîŠ, 0A'„j&lÂjÈ�íjdBœZjiZ‚%tìzåæÉìlÌq'ð'ÈA&äAŒ@ o¼Áê¢éùÞMhXdG£œî X§ ,²ýz.–­}�A*€F#B$üèh §³ÚðJÎX#̘đ¨} nhääÀ‚J*Û"(°‚EŽF�†F!D.&(Bà@TnhX�*Äb‚Fx2$g\þ.#Ÿûbf"['‡È/hrlÖAl%°ASndz~¬îê§|:Ÿó¡ækj^ñŽÀñzž|æ@ìAVh(-xBÍŽ@&ðg摈eXŽÞ²4«ç û� X¤¤üZç×6t1G2,0ä€((+ˆÂ*Péh”Ã䎞­*dýji›!€œ@ ˜‚}‘B"ä@Ð(aiH2±),´Á Ä+Ä�B|�&T.Pi”BàÒð³>tâÀ“$H.t2ô™4YÖ�6ÇñÅjžð�{^{¾§îöiœÒ� P¥Îò)'t�Í^BνáÛ¼Ó+> ' Võ² ÇKy�ï^èi~Æ—¼ç~ÒÔ¤ü&êx šþÀâÇ^. êü1'\å-üx {i,:탆u*[¼»~Þ|íOîãˆÄ• @Í#£ÀÂ?2ñ3‡\.ÿÃ|Òþó¿¸ì�‡ì!ò³>y¤U� ¾À#á»ÿ÷§×abe˜ùŸŠ[ðÛúÇŠú†ÀqÇüwδ±þ³@ìÿþCÿG@HA`A ,($¤€p! :„øpâCŠ,²˜0þ�aA‡%DˆR$É‘ CF¤¨�åJ–Ä”93BL‘7DX°“gO ø9† �&MªC†¦N5d¸0õ‚ªS‘2UŠôêW‘:mªô*VªЦU› ‚‚ X`«`ëÖpï¶e›À©Ø¬a5lŒvp… ܾ`x¬Q±N—¥š�reË 0cVÀ@gÏŸA‡=štiÓ§Q§V½šukׯaÇ–=›vmÛ·qçÖ}ÚDoß¿�÷‹xqãÇ‘'WnüwgŒKB¸9eÁ 5¾ÔÞRåǃןg”Ø]åô›! B ˜2âv÷,gÆÏ9gΞ÷ @ àÀþ~ýüÉÚ* Ä€)­Ì:JÁ£( k,©$›l)¦ü2J2 ÔÊpÃä2Ìà ’2Ë( Ø2,¯»PªÂ fÐ0-[ì0ë ‘*¦šÑ²3Ø,3Ìv RÈ!K{ÄÈ#!RÉ%™lÒÉ'¡tR¸)©\ÎÊ+­l΀ðÌIº“,é½í( “‚ :h�¡Œ0ZH‚¼3ˆ:󪓀 îÈÔN>šDÊ©�šDÂÏ'†ê�ÐF|ÐÂÇ´2Â/(0)±4€« ÉÂÊB ]|1®OÃÓ¤¸K�PßZjÒ°ZD«U´f¬ Ã¶�+År¬ê/Ó,3ØøŒ�¢œm�D þ)É#‹mÖÙg¡�VZب¬8,±Í6-¹ôò¼Ö³óMƒÖÔÎ"ˆ^R©Ì‡0â躆âôˆ‚’¾-IÜ4ÕkoL÷ö”©¦œíSPž€Ò/(¡~¢¬Ö£þÒjÀ7��RÍ•ªÈVuÐ)K;…õ­�K<,Q®š*q/éjl+Â6P°À; GWÌôâ«x­ÌÇ_3B a‰mMŒD…b;HvÉ¡¤–5rÚ©©®Úê«s³Vk´íz9nŸëÒK�ÂÕ×½wYÚ݇6’€#xrÓ#·é¦“Þ‚!¥}B¾~žÎ&�^áÿ�8Ä­(ŒªRª0°JBGþ{\2‘GåjA°Rx5C¸:t+±ºü2Õtýr)µfÈ@-Ãfެ-[Ǻy*wÍ9ƒu퉟=3$hÖ>P`Q ʤ™£¾H6ê$±®Þúë±·zkk½îþ8°¡Û^”îL¨Í…Rû:‹ÚS ¶jS<ïâÉ~zEr%‘^ºný¾U 'ÀÙdp+œ~ˆ‚À8ˆt ‚ ÅÊhR–bX_šR œ•ež{ÝÇ>Ä–­@mù�^h”¹ &03ˆY¬DF;£DFW“ÑïN%€Ïâ €(ÄÐ�Dxæ Î3#aC‹O0D"ŒÐôaƒþš!žÐCDñ3`D°D3€3 ëh�bÁ�0ÄÑP0@ôá †xA v†yžQÀßH<þ0ˆCìLM£€G0`zÙsä#!ÉÝl¯7$Ž÷0¹-ß`Ka¸êäílDm ißx& Ä ^I%Ýr¿.áM iŠœÎ´‘šR—èŠI°§™ç…[¡¥@ýÔeW £`-t HŽŠŠæ,¥"›=j*èØ, :zˆd‹»€Éàò–R©lUó Nˆ£ÑÈìÌ•¬h¨»ÝñŽ3;<�å¢�9#~·Ã> à�Vþ¾€|„ø'( €/­ £éƒÀ€Dp¦KTƒø,‘¯P£"jˆTyATZg R�ŽF¦Ýi!:zPÒ|`ˆ�”äQ‘šT¥†F88 þX²J™ìøXðɓؒ=¢4_ßHy.Rª@%¯t›üXÂö�K•v³HN¢ž´õ2!ë߀)b>àO„˜~„ )õ,'å F!ÈUešS�J],w•¯4S›*K4/�!ÏíÅ ›Ý®Æ• œìD©RÙcg �¤6C’‘bT5ÂÈ46GúÌÌÎ@Hô!ŠRøY <ƒÍ¥ @òþ:Cˆ£µ3;E#¤P†Fí̸ØC@x¦ZlcPðOC0j€nû°Gà*ŒØå)qy‹Ð Ñ4Ð#Ô¢¶Týî—¿ÕNàA€�{VõZs`Ö¨€Á np XÐ ¯ ®ð 0œá ‡²:úˆ \Ð�Ä6ñ‰[@VR®xF±„ßWxxæ�qÜ€Ÿø¸�| “™V‚#Ã@ �\,yÉ5 Ø“Yh (SlL¶r“‘lƒ$×@ËJ®ò•i Ú)ÈC&óÐ n`ƒ4ß@h¾�,P iùH€�ë\ç¢Ô)©³V4þPdImEÐp>,W:À€,º�¯ûˆT£íLB=3¬æ&šz»�b!‚6ÒÜ4xD! A,v†‘\t) t(¼ð  (�yzIÛÔ3p#!T½ëÐäZÒ­tiДâwYýuö³¡ %áBÀƒøªeào²nÓ¥ƒÁ}b S8Â6÷ äý�É !vw‰wŒb ¨¸m¤tñ‹c,�•ÃÈqŽã-ažÓX °-hTR8@%O\âJþó“)X ÍÌLÞr—»Üq&C9µ~>am f7¯|š-'KžõŒ€<²74Ŧ©þ‡ÚÐ#tJ¢1“èLw¸8Ý# P`GÏøì3 ÞôqÁˆôó†úiÂ"õgÀÑ|Ê©pÜÌz¼å=/|¹H_)24D?»Ñ;ˆ’V}4j°4³‘m½ï�ï¶  àÀ‚ªSÒvךӳ‚;Üžp…%Ì‚so8”4V÷ˆEœùÇ{Áóf1)ÞXÎÛ–—À¾Aòo‡¾Ç1ÙIÁwRæ…ߥFõŒ¸Äq?q'_<ã´È›Üå-#ùÊ_^²`J.fÕžpÈnV³ÊÕgÃÖÓ°0¿sžSTdé#Å;³Ã}çF‰¿á4ÙQ-tº eDM�@ˆþB|fÛõŒÓ‹ @D‰`b¨@žÀxžàý< ¥Š3²¡Z긞F ¡f�€ìÚ¯¡¦€úi·¤ ìJ Ÿ0ÐÕ á Œ`¡4ΤÀȨú.epI8Hª-À ìð°ÁR)•¯ÁV ÂÊ òLLò&¯N }ôMöMÂ0¯Ä6OÜ*ŒÁ¼íóBÏ flÞPÂò@õn,ô^o'àà€ì-†¬D�¢È¶‚ø(nâlNÊ(FÊ0ø*NË@ÉŒÏʪìe¦lÌì„TÎùTŽ §oš®ûú㔂@ ‹üìÏèÉáâÌ üøL)Mý:þР¤Yö AØÐ¨Áÿh�ê&Í™+ŸÎ‹fxBCŒˆ͹!ŠúïÔ@ Œ`m ×>Q·:Èæih‘ú@�Ô á!iÙDC‘�Ä×ò‹µqcÐq0w�¯¤9–‚P�pÜÔqòÐͬÔMß ¢ô$ E ÞîóVìÞPlß¶PÆL/õÀPÇî>ÊpŠ)/@èc0±.”,÷rÏæÀoijŒË>Ž8Žã¬lµ6Nµ’/AhÍÒlo@‚¨/r¾¬æ±°ÈOÿL‚í@Í@"2@F„a6Qýrkýï C�¬PïQï" 2Þ ® ÀŠé ÷‚ûÖð/+Î!‘Ìæ0Ê(Rø*²!7rä.îä>ÄùÐìùn€a K)`Næñ/á,β‚÷­ž­á$¶.H,dÑmsK‹Ñþ”ª(aCé@Ã\35”²)q37é)ÁQ*§2K6 ÂÊÁ0Œ8Óq+Ùú�ß8LóH,ô$ì�PÅ$ óvÌ ÁòiL.Åp'\¯˜bo/Ah+*óÈš,÷“0å°3)2ÉSþ11òÊü,KÎ1=„$Wî"gú +À?ÑÎØPü kʼo!éé@ºï@Øpqä„n'{2·ðO©Á'c“5•ˆ5Oã6uóCA´jx³ÚÂñ7“ƒ_é*‰ÓÁ O+×1Ã&lÁ˜Ó­ÃÃ,O ëóî1¬¸p#2ïÞ@ŒÛrPÏ Ü‘;®à|lIy ùòcÊ/+*3És÷$Q"G³ãÒ=¹ÔâS#­ ÊüPBÆÌ 2WîùL2rØ´žX’%ñ%ŸÌá"QU.C P³OCôO5PusD¬DMô{‚³Y@PÛŽQiÃíTRuR)µRi�PþAH ð‚ãP¿&Q-5TEuTIµTMµI0u¦ªª<9ÈñTa5VeuViµTysðT•S¯¥U]TkõW�5X…uX�-U7•Uyµ8^•X™µY�õY¡UÚ‚#ÀAªÕPyuY£u[¹µ[½õ[™jZ3µZ[[U[Á5]Õu]ÙÕVÅ5SuÐ7“•8е]íõ^ñ5_µ1UÍÕSëU_6`v`‰_åu^ÿ•`va¶a—Ä`‘a}Õa)¶b-öbWbm°7æ•9&cA6dE¶am�ª-ðúõPvdY¶e]\�UW£c•õc_öfq6g…be–khV“zCgþ…vh‰V5ö’~ve‹vi™¶i—òh;5imÖi©¶j­öÙŒÛ Ojƒöj½ökÁ©žW ¯g9–kM lÕvmÙVDßµP6Y•¶mé¶níö6 vWivnï¶oýöoKCRuU·vo§p7q7f#VnWq!7rÙvl�i ·k%ws9wm1USÍÖg37m;·tM·jy“\ár£vtO÷uawhon7[7vq7wIö76*SÖDùVw…wxñ58¾QÀáw3x‰·y�×[…£Z �v ·c™÷y±7{‰U8¬5ð®5nmWsµw|É×Y£wð¬•J,éþg�–tË÷}á÷W¹·r“|Ïõvã7õ—Q¹WzãµvïW|÷w€ XPSwð”w*¯·€¸�ùî€35�Åq�¸‚-X¿ú·{ A‚y�‚/øƒA¸`ƒW¿€ýC8…UØz5}í÷„x…ex†ýkZï†9øð<˜†y¸‡‰ä)ý·\_XeQ؇�øˆ'é]»7‡µm‡‘ø‰¡˜5æ·r™ØÀœ8б8‹Eã|§·„«WbcX‹ÅxŒQ#ƒÇµŠ­êŠÉx��øV§·~M˜ˆÃ˜�éx�Ç–\…8Ž�·ˆë¸��8ogmýx�É�‡C� 9‘±ˆ­5Sµö‹wŽy’gþØ7 ^ïX!9|Ý—’=™†KÖw‡x�%ù“MÙ‚-yªŽ—z1×zùø”a9~?W“[Œ;9–q¹�)WÀnP�—÷•s9˜ŸwpC—}ÕX˜‘vG4“‹‘“ù™ß×�ÏÖu¡¹šÇWšEוKÙš¹9v™ªŒ˜»yœ›Ãy›É9�%—q79€oY�á™sy¶–#ù�ãùžËùmÿ·�aØžñùŸýÖœ�  : õ9*}Y�Ź šišš:¢'÷ Ñ8“ŽY¢1šeZ›ý9£=zi§XÀX÷7pàœ;ú£QZg‰yJJz Sú¥…vv�–mФaú¦cúm§—¦õ–£þqú§ov™o¤¢�ú¨Cv¥ùYŽO©�ZaC9yº¨}ú©­šb��#x”�¯ú«í5kÍÖ¦Áº¬v™lS§:›mÙ¬ÝZ_÷‘[·ªßº®Ù•�陓íz¯Õuv÷™w§™®ùz°·u£Ûš°Z?·¢1é¢û±Õ°ë²){gz­û°+{³gU©óÚ�9;´cU²õZ´M›TI´O{µ-­óx©I¹©Y{¶s“_›Ù¨i;·At—Óú¶[·�7={®5;¸�›W\q•±½Ç±�û¹!i±¹Z¡½º­Û))zº'x¡¯»»E8D9¡·»º½»¼£;þ»Å»ƒ¹Û¼ÙÛmM@“ö»Z¶Û»¾Y¸7¦êɶlÿ.³'Û¾<’,ySW9‚座‹;Àü¾Á[ª};Á<ÂÝû7�··Ó[‡×[Â5\Hâú›8Ã7<Äq#¤-\¾©›¾E<Ňį_û³ûYÅaåŸ>À=ᛞ㧞âϽ‘™žà;ëÍ;ê…ì¡þãIÔè-úÙÉž²yýì›=í‘~íM[8þ Úþ ß{Píå>±ÿë†! ðž*õ~ï [8N   ðŸðùš’Þ¾±ßñíò!þè%žò9ÛZB×ëW]ó•Þòe^êAÿ¹Eß’7~áK?¸Oç-þ‘$4b5f_ZjÿIn?Hr?÷Oƒ÷MÃ÷cø•øÝNøW¾õ=¿žÃ Â@ šÿù™ßù¥? €ú«ßú�? ¨û—ú¯¿úÃüµþüÅûÃûÍÿû­ü×?ý­ŸüÝ?þá?üç¿ýßùÙÿüÝ_ýÏŸÿË¿þ"ŒÀ,Ã4Hð`–„øð¡B†# £cE… .´Ø1älj R9ð$DŽ$Á€ æ¤J”gÎ\é1¥E•/²ÔI’cÁ—`¾À|‰Æ„Ò¥L™ž0ñjÓ©T«Z½Š5«Ö­\»zý 6¬Ø±dËš=‹6mÖ¤Kc¹} 7®Ü¹tá2�7¯Þ½|ûúý 8°`À!Bà-½/éÀë‘·‡ü5uÁñ�×w?ùýÑÉ÷WçïœÈ]^�ˆ`‚ *(ž æ=8ÜR‚‘àÚt–^†|]Ș†Ê]È!‡ö—߈ÑhUTS•°`‹.¾cŒà5a�q¡·—�üG�>ÆáÆ�a˜HŸ}FYd€H&Yœˆê-ÉÞsNú…"Siü�å�>Ù#2~ f˜bŽ™U•6žyžRò¥…n:±„JÌ9'DÑ”zîÉgŸ~þ hg .Õæ›pÊI'Š:Á"þ™Ž> i¤Þ…Áš6⨗¡t&ª„¢ŠR°a ¢Ž*©¦žzj•jjº©Exª¨Š-’p­’ÞÊ›­¸Š¥ëm4Z !¦y¹9'¢@t +A Ê, ¤‘°ßšÍî9å´É©j o¶ZD·žD£»éÚë®æž‹®n¿kž°xÁ¹)"Wx›¬µöjÖœ™dÂD&8ˆP‡~ÑÉc;Æ¥U2Ž7‡­ ˆÒiÈÈzŠBâ¦û]¹±5!Ê*‰$ˆ HÀb#„pur¶[�BéWm|Â1¤½®Ë.yÄÄEhሰ‘ñÃFO''AP¸^÷E°´�==X 7|þtÖ¥iA±tjñˆÐžæ ¬¬2z\sVŒbW@a2,8TµÈÊ[µÌ•)1{5óVhÃø7¯Û™™sy;oZD§W$"6£ õ_ÕŽj…"†E%| —�àZÔ|‰P˜ÔT fug KùäÍr=ÕuºZÄâb£à8�§Ý1U»W% 8¨x²ÙKÕÍ2Ê\�†ñYµÊRsóN&Î…wøœÝ¾ „„tú8ä‘«Çú´!‘ ûÖaB›THE&knQI'•È!zo0ÆÅ%›ð±,! tr¨Äÿ„€— äaœ˜Ãê°†à€lÐ 4gA>Ð!–ØŸÓ,‘þ >ü@‚y°Äÿr0‚J°�žàCÆÿÀ®)›‚·¼§Üå (øÝ¸¨—•E«(ÅLŠ?,~�^$\‘ Sä@e&{ÅSˆ‰U¤B‰&h„ݬð‰Uœ¢dJ‰C)ZaŠ&´ìq ˜bŠ>4Å€ …*47Œ!©8ÓPŠ(ʱ ˜8„*RqˆéQ6>‹õ®G—ì%Ά‹úƒã26ä€k¨Ä-ùPà`Y› óB…:ì¥ �è+-Ñ´`Á5w°.ó€þ&¦“¸åVÃpæe†K)�ö:Å='X\9æ"eĈU\ÁehÚ@ ¥ ÁodÊ(ʠ䉉DB*Žˆ<"¬" &XB)ä8†U€pkL�„^)boJ9E(ÞHˆ|š p‹‚+�pWHÁD@‚ Ú‹6˜à©è"ËÕÈí<’rÉ%+® `xâ§ÖÎ׉e�–#XÃ:Ç !HK•x è<�29èD†—JP!/bà¿ò0> u–È‹î°.èa]¨D4ÃÊÃXµ4�ó¤µüzr*Åœ“ô”,Y‚Ä¢àOÉ©ËÞ‰Çþ:–‹Xbp  +˜`�˜Ê?•âŸáàd9PJ"aF ⥢XJZÛ¶- ¯Ã)\šSÈ´m®ÈãR˜g‚R(Â¥skƒ)–Ò@¼È±æÚ)Oí"¡¾\lQqøÁ`KÀX•¯€`…¬Š¥%Œì'F°…K„n€#<±¯LxB?è´:�@¼P� ›ØK6±‰¢œlPè0 >\B®¡,QÁŽ••Ün #&„d&hTvÍÂ\Þ…x*�(ÄRaâC Öˆ&À,`ÑÄà)…ŠÅÃ+R�ãU4"µ+ Dg•2†S˜@\Ô§+NqŠ]æþÖKJI¥Ð€‰P„°!B!Rñ"àzKQ­;Á4b19÷¹nÙY²€`† ²hÍÚµpÖ¦T,ôwwXCØ 5ªŽÀ»ÜŸþöÒJK˜/–¨‚YõR;$­ ) ÚŠ—·Ž`vp�ô€!lâ…•xáž+ìæÈaËœÓ- ‹jì~%̵Ái«s³ˆ+E2mBfSA•Ïš@ rÛ§"g¼²×º6zŽ�­>aa„F`â×m›Šn•‚ƒW@Á ­ˆTRá›à 4kƒ&º¼Úݹš6¬FטÉlfŒ9Õnw B}4‚ÙA˜Ì&6q˽d.�ì,™:€·2þ—»ì¥k4H!¬ÀùÆ HiÁNpaÏ™hEà^>„ ·ÄÁ%ÆÚ骂ÞG”R’…»$–Bî†,Œ!O á¸�²±ýiSÃâ) HDN€—RQ¡hú³Ë� Í"Á-ÃÁ(ȸ”D *ÅA!F¡ÇSÌ- °hžž‡èÍlzŒ„Ëe†ØŠÌRŠzÔÝι¥ºÃå#9³„à 6XÂu¨ÐáÊ,(Œùó`.á‰MX“Ï$�ƒ ó�_!ìÁžñ’pŽ ÀÒâè° K — „äJ‚.˜Ðk ù§í®µˆ� wC]yª7vö)"þD$ÅÞfÞ„\ç|e´½¢#VÁ Rhy&°B(Ðø‡¨üá®À(ޮРÍ.e»…¶)ÈPŠUhâ msÄ)>QILô‡ÅHз›=÷^)·#+Åö¹ì \àò�0‡—XÙB!€õ'è'¢³$� X{ x4£Ö>àA0<ƒåv!8eaØAESÁeŽâ<<<&7AH@OUZa!Y#!d!"i"DLT\6D##U,Œg·Ãr{mÌ£¼¯Îa‚ܲžìæŒ2¹,kéó¤«*¸°Ò=¶¨§Œ"¯«b·,[K.·þKïÎ…¦ë$Å ÌîÀeƒ8/Ø<­´Í»¬a(ÑâÊϬíþ½{×x±ÓKω7Ëgy^¬óíõ�ŠKÝÖó^9|ƒF|vÁˆ�üjÄú^·æ]ûí2¾ ¹Fz®¹×«®÷œi9¹Õàú<µÚàÞîòœ‘N©ìÈ æý©®ÿÚl8åw¯Þûåê?¾Àk¸dØL÷ìçïʯbŽwÇŽ¯Èœ lxò€+^»¶Pk}ªÜbŠ;×*GO2Ô²ßI´ööNÎõžìï9ãŽJÚ¿P´ƒÁê€-Kàˈ@wéK€ ŒàÀØ; 6ÐlT �*Ø2Nð<©;YÅö/Î˃à[]‡†>ÒN‚&üà¨è ¬u La�6–ÀØYo†þX˘@q…læÎ±#:1‚Œ¡ÂhÃ$N‰€ñê À7®&>ñ‹`˜§h2#†ˆ><£�8F šq�pŒ£çHÇÖqiwÌ£÷è1¦Q ÜXÒ È-âàÝ*‚�0#8ÒEˆd†0„"¼‘�˜Ìd©ÉNzr`<ü¤(GIÊRnÒ”¨LeàˆQ¨‚£HDÙ-”Ü€–CXvÉ2˜� À$Æ9Lrò�Ü:¦©Lã4sAÏc4“‰L}M3�ÌX4¯©Í>“›Ð,Ö6gNU†S‰kTf9—IÎj¦K�îYg6­ÙMzRÓ�#þ\§çÉ@}²S�ߌgM`A}ò³[ð|¢?íùÎV¡®ˆh+"ÑNTAœá$`4…ð„)0� H`‚#•IJRòAàæ5ŠÌƒ´Ÿîää4]*P|Þ³¡Å'KmŠÑž:s`+­©PÚÐfõ¦ÔLh5ÕyLš¶¨:Åè Œ:UhŽ3ið”©@µšÓ{6µ¨ç¬)SuúRœâT¥f…ªR‰êÓŸ–•¡aE«V¹ÊÓ¡²Õ™U½k8óÚR´ž“®‡4jWyzM‚nµ­V�*\¥ª×ÄÞ°Q…,^÷Ê×�r2 ª ¨f)šŠ/ÔÕ8¶œjUEÛƒ�~ ¨M­I „¸5°ZÝþÃ'V±ŠOì!ž¢ef6u«ÛÉŽv²�…í\ƒ»[™æ·£Í«qa»Tá7¹O-no»\Þ&wºÀ®q·K\æ2W´¯ï<Å›[è*—«Ê]*x½k]ä†w®ÃÍêyŸ]ç–·¼Ý­/u‰º]ìêWº_ÍiqëÝÝW½Öåía¬ÞïB·¹Àðq¿ªàó²¿Ï�.uÓËa»·Ý50zÙ+bþVx•ý¯��‹×ôâÀ°Æ1¬téKá¾b×Åäµ0ûêa7ÄøÝ¯�ÿkc'¤b³›mE+Rá#2´6¾%–ÒÔ2r@B~Ѓ D9¸FÀ’=a„ßwÃí½ïþsÍ|æ ·9ºmNpŽ5ç7Ÿ¹Å•½î�µËf'˜Ï}vó|ý¬ç:¹ÎˆV3¢}›è ãÀ  HÅ!ñ¼ÁQž*"‘hg®sî´œùêW:Îô�%L^U‡úÌ¥ƒ�û‹çFšÍ¶µ¡›ªkT»ÕÖ¿^õ¨ï<ëWËÔ2>u£3]le÷×Ïfîs‹íÛë {¢¢–@²%:¡dL§¡=ó’�+ ”ôÁ–¹ÜCCÉ®èD­«MïzÛûÞøÎ·¾éíì}û{ß&øwyqDR¡ 7¸´¢o A{ žøT©@…CÁÔý¾¯‘ŠT„BÖ÷¶„þ¦jQŒ\â7ˆ5ÊWNh–»üåÕ~±Ì‘�q˜Û\ßcXƒ­Ã@ÑGà šm²íŠV€\Íá¾oÈme&¢ CðJ]è=À›¢{¸¹Ö·Îõ®{ýë+…Îs ñ8#ÂáË^C'PÞˆ±_¼æå¥‚гUÀ=·"7Ž'ÐŽòR|쀼àsKÐÂÇxð.ÿÂÑË †¿ëz C£ôŸGTè@—("âL@dÙˆ|d$³Ì„@<áì>Á 4ÕLµS½öÕ] Ñ|ª‡=á_/ZÝ'Úõ¼¿è…o{ÑÿöÃ'~ë•/üÛ+ßùÉ>î‘O}ÜŸù¶F¾ñ™þ_}ë7ßûÒŸþö�}îø '›Gpýã_?ùêÿ>ó]}Û×_üò­"ü` TŒBòžÀ¢å ~à|�À §P >��pewWð © Ž·iSµ¢  ¢ y°§¤° ¤à¨‹ ~¤�uÄg¥ð§� Š a€ �©P­�€g°¬°§€ –`× ¨p 'g§ -h BP^BÐ evìWg¾§{�ÐCÇiSUŠ€ ©�íæ¥°¥  ?xBЧp žP7P‹Ð‚ˆ°„)çx„À…‡Ðn¸wxÓ§‡æ§kÑ}ÞG~Ú·{Ö}ƒ~þ}|}¸ˆê…÷'ñwè7s4‡l'pHŸWx•˜‰4'|/æy§‰â÷‰øWmŠ(‰„è~æ÷ª`à7«àxø—~¢‹Ã´ eP^è7U£°Y�pŽ€y›E ô~�÷‰�ˆ¢gR>�|00.®Çf™EQ§Yª€~‘x{¾w_Ý8UQˆ}‚XˆØ'Žå~Êw‡‰ñWg�ØŽºç{¨‹è¸Žî7ŽçH|ê8‹ÝçŽÁ‡hõ·ŽÎ·�ø¨~¡ˆ}§(…7€©@7`«°(|:8å g0U ç€'© sc  lÈ€7ð Y’ªà�˜åYOà€kw>Wþ^‰ ‹7°®pÑÑ G ®hŠÀiŸàv®�Æa …yN0 Yg“ˆ–`¹e‡` …º§ˆå%r� ¹Õ GÙÁxG”=`”7¿ˆy˜€“gø‹sx‚ð ­Õ ·‰ìŠügº'ˆ)Ži|Ýh�„xˆ†Hxݧ�¥g‰�ªø}øØ|3‡‰ÓWxˆ¤‰ŸcÔ· š©‹…hñØ{ƒ|¥)™ô¸Ž¶h{!éŠ�y_cÀ ’w« ¹µ Hö‹'�yØX�7€ È—8ÀH“uêfz Ë÷zSXÕQ×HQÙH|Ú{%�7`�Ùþ ŽPÈ��©}éÇ——É�Áž’iù˜ÍžëÙžëYžðÙ�¼�°zÍi´øžÜÇ—æéyçŠê ÂG቞۩{ì…ì—~% �þ  Á šZ ç·‹ ¨�°§›{·u’€ —žW¡àšg瀟PzP{S% :§ ˜„z?ÉpXx�˜ ˜€‰ƒœg°ƒ7à�pKªP%à k€l6¹·–`¢¢õµwqN0Uzð¤%p©Ð ¡~™[ÝØ 7` iÈ¢uqBpNÐ B@¥Så¤&€¥àšdé xØvj~—rc€lg@ Ô™Ÿ•øbþÔY¨Û™ Ù©�…x�×i'ШªŸù¨ ¡ŸZ¨zš ª¨¨ïi¡ßYž¡Š©”Iª– Ÿ˜*ž½·�‰êŸùw©’©ø©{!9›·Ššºž±9›g‹›z¸¹YŽ0U¼)�•Ú|À©‰4‡0€Z«õHðK{�ú—­:UŸ0{®ð •ZšÈFŸÁ÷�¢åˆÏ'«ã©;*~Ê{©:«—)|ØÊ˜�X®®ê Ê'¦¯×ŸÉ®ûùŸí9|õ°ª®¥I®Ü™­ñÚ®Î'¦è9ˆæ¹ž£ùª»èª4*ZŒ7@‰`¤ k¬ d‰‘Ú€e˜†ª�uˆÐþ€˜€‚t镘 €º·7¤­ «Ù �E­`†ª`h ¢ApFð³Sõ�Pº�aP d‰´ø¤¤eL–[!Ë UtUtÝh ‡`„(è©^®àow�kgaÐ +X¬¸�8Ð Uº§K† \X »·»ª³ÿ˜¨‹Žçiíé°ƒYŸ¶©ŸìH©ü¹·‹¸øÙ¯äi°ÔǬ{‰™å¹™ßŠŒ�ø�¤Šˆz¯Þ(°ÿ » ©‡K›ªp‹’G«ÉZ’« kÉ‹b°·½t7пx¬®0 ìŠËªª”òÑB J$à¨%|š�zÀ­z ªÖ‰ºÏþ;šb {쇽ªªZŸ×[ªå©�ßê½æk¾ÛiX ¸Ô{ é{¾ðK‹Ö©ªï¿öù°þY½‡ê¾ö™�ð ©á‹ºùÛ¿×龈KÀˆà•Ù z ” ‰ ’ {#z�‰àpe§´ãZ¢?YÀ‹ %ÀX{(£prâ‹lG[¥7§€lû÷Yw¤Ijo{o€ ( Ö©kw´Û©´ÏËÀÕÀìëy–Ð7à¬à�:˜¦&€§Êè þ«´J¨ðKÐ =€lMÀ³zú&p _ Àö{¾Û ¸dì¯Ï[Æö{Âll¿D|ÀýËÆò—Æo|Çt|ÆÙÛ½ÌÇxœ¿þÛ y®Ð§õë½^óÛ Èö^€©ˆÐµ®°»—gyšgÇÁœ%Ы%A@?@%Ê š½á‹bo F¿û¢ó‰Æ­¼½áK½�ʽھآ¸<¾ø{¿ç«�z¼Ê–Úªó‹ªñY½¬|�ä Ÿñè½nüžŠ:…|œÌæKË~ìÌü¾®z¡Û©)YhÀ @X˜à œ†ÌÖ9¢Ùé¢%Pv9vu$i’ÉŠUjµLŒjgO° 9ˆÆ�šÅ‰€H PÈæ¡  È{�£4,Å ªvfˆ :ç,Z§¯‡Á_„ªÛÏ×¼ÆÃ|İç¤pH`™]‰þÑ×™ÏO�¦$&à 8ùs^ùÅ& ž�&ÀQ Ÿá(Íz½=­ÌlÇCÝÇB-±þ¿È®@}Ôo|Â�Ê�Ë<¡±|ÔÏ;²+ÇeÜ|\ÍÊ_еOz¬B—mJÆŠlËJÆš¼eFÀ"‰ ÊÊÊ£Œ¡£fIöƒ³®áú¨N�{£ŒÆum׉ª¯Ö›Æu} Ù¢E�ž£¼ËºÜÔ¹l½Š¿ClØ|­È¤LÊ*ÙNMÙ³ŒÙŒÝ٫ȸlÚ¦ý×›ج-Ú©m M�ÌŠ�¸�}ÚŽí¿æüØ<ʈP– �ˆÌ欑� Çí ŽÞçóùÌ/~Û›íÊ[ŽÚ|µ†^À{šÍ+åúKØŠm¯X>à]®Ù€ëˆK^À�Žä�>ËPí{Ëëé¸Üì”îÙ.¾Ç¥ä�=í¥­è?.Ù”-Ë™>êàËäé>ëŸ~ä�mí×™íK~éø Ê¹ºµ=Õ­^ËøîîÎâ¸ýãÖ^ÚßÞè¨��N𜚥d]› K`î�šægÞÔvcF0-Ȧ¼# ¼$0ƒ]à«=þØ&ïÔ…}æ}nòÅÎò,ÎÙç^ò¬-ò"ó6¯ÙŸ½å:¯Û.�Ú'ñ&OòÄ.éƒþëA_×–=è±Ýï=è#�ôL�îM/ó¹-ôP¯Ùg×ãCP ¨ô,oõ¿ö­Íó>ßï>Üã~ —½ëWÞòcÏò"ß�oô—]ò)OõõKÏö_öuñ1ÎóVßãEŽô*ïãO?åŠOô^ïä.�ëþ�/ú‹?ÛÇOítÿª–¯Î…-„�€ú~ßüSïüØú4ü³QBà�0¨@980a  >\xb ‰ '&¬(±âÄ�6„X$H�$~lh’dJ•;š0ùñ¤K‹KÊ\ys!Ì� Iˆ¼™RæÐŒuâ¼h“çP¡C¶dêôdÓ�3§Æ\ ÕfRš(�Fôø4fPˆT'’pÒÉ•«Vk×¶ò´dâÒ¯ìÞÅ›Wï^¾x/&!�ĈÁ# 6üWñbÆ�/&ñXòdÊ•-CŒ|Ys‰À›+wþöZôhÒ¥MŸF�ZõjÖ­]C~]ÙÄ—C¢V­uè‹ã¾½}÷¶9slâÅ� ùræÍ�?‡]úôÏÔ§ü[ûöWÖ½^üxòåÍŸÿ‹ýzçÜÝ÷ Î^þ|úõíßÇŸ_ÿ~Õïýçå/@ïSo@Tn½oAÎ’;í? »‹ÍÁÐ,LM9 ä°C?1D5›ð?ë6dEòT±EÍX„ÐEWƒqÆ /«Q ý³q¹{l.G ‡Tm°…ñG"w|�H“Bò¯(K›ò:' 4Ò´*ÚòJ׺äÒËÔ˜to90S²¸3_$-Í‹ÄÏM*Å´þNÎÑXLsÍé”�Lî.Ò“Ï•ŽÐ×ì\ÌPç´QäcÏÏíHc´µJO»Ô¾L§ÛÔQ-= “ÃN”T;PUõTSe�Õû\} VU×+õ7ñd�U2D礱UL�Û5ׂõUØ7ͯVߌM‘H\™5ðYhái-S¸ï®�vFn»7¼,õô±l᳌ÛoÉåo+^�]7QdÙ­n¿qY;—/É”+â‰HÙ-6LÞ Á KPã`Êd%a�N¨TäÇ0T/n ‘@è%ÍZˆâå¸Ü�7Ëw¯Çi%åVü¸Á �Ô•[±DÃ�KââÆÀA³XþA(¡QÖ"`×dneRqW±501-bÔŒ°DUB1"3zÆ9Œ„Âèá¡Qýpd!BùKãm‡\pCb£…NÈ(Ažµd½&¶¤�ö>¶”¬CóãжºÁ’7:ňP)kD�„¨X…ëCȉQÉÑaÅ› @PóärVü¢RJã�Í�.;!Bù+‘�;¤;4�yâÍÞÞ¬öcò½4»tì>2Â�üh RRÁ¤ôß#øúxÞÃKpø†NZ9…”"¦ 㔄 �]ó¨%¬KÈ[ EÌ__•¦ŸÈ�F\WD"*:IE)üð8 °yP+�"þú·E"§ÐƒóRሰÝ@¤@E'JG *|¢EðÎB±‡œA§( /s ¯E„¨@"öö½ðé!ª(!JÀ¼Cü¡j§XCBúPŠT ‡3Ã(PAŠã¥«z®C„@:˜ Q|¡Zjc`!8@ô¤jMÁ R£Ð Ï/Åëh’÷¯Ý|!„'tÊf ©@È\q8ÍÝ FØÒ Pq1AîFsBH…Ë  ìá`78E(¨P?0¢Ju\‚(Ì@Dž¡F …€Š �zàÔák­¡f¸Á\áà}ÈppF¨®¤ …zþ1[ð«¨ΤWBl!�øD--á8?²ƒðå/w£ˆTLò ¬B ’Ç=3™BX&ežÈB¨‘ŠTE8±è O:ácà�ç­}¥ëvbÔçCÌx—‰=B§@è’×SlS‡%B+†p¤¥l€Šxè/ùè‰ààƒ€±L!¢†R4Dsk…ÐΰUàà � „ã,±†±A Žr–3‰h.q&ˆT0bµ•ÉÌHñ¹V¤"¯-�GéÔl:S°ÝánJÛ™Ò– ‚@…y5ãK\ö²†@ VQÛ *"7XÃ'²‡èŠÑÂWd)Ô˜ø f³ Màì<³NÎÜ7!#äGýNό⠫šæÞð0þ�"§p‚ “h×ц�ÜKióÔ„è�£kFÁEÔ&´Õ Ú,!ÃØFÆo�IÄ!œ ŠIbä4&c øð°S,3ª ŒYâÌCˆ¸3±@BƨÌ•!1!¢ø4¤&‹ÇÈë0O¨‚‰ ùŸÏ›Xll µ�™®@Å/Œ"3¥¶Ñ¤–@KÜÀìDh-ƒL¤‚¤„DL ˆR¬bªù)– "¿!NÈ›ÒqT(zð^^;‘LÑš-Š%¨&¬Ö‰Ü‹˜p ÓÓ™Å.|>�w¼k>ÙÕ”R‹µbL0˜†ORøÓÐbLð |þ4D�UæA¥Ø¹ òÓƒWq&yr �  Ã*JNeÓÞ÷¿Ñìõzà[º1Fè¹ûj¾œÞë]¨!>îæEù9Rm ZX¬´_}â°(A³/~øÅ§f êõÑoøÇ¿~ö·ßýRÿûÍ´*ù×ßþ Šÿý«þ÷Ÿýù——Üé<ÿ#À4@ÏÀ<Àg±<tÀǺˆ< ëØ¼÷ «¸Œ”ºpŒ ÐÀàÁÉH |ÀŒ�Ù€dÁtÁpÁ”Á¤Á´ÁÄÁÔÁ¤AäÁBÂ!$Â"4Â#DÂ$TÂ%dÂ&tÂ'„ÂÂ(¤Â*´þÂ+ÄÂDÀÈZˆ,ôÂ/Ã0Ã1$Ã24Ã3DÃ4TÃ5dÃ6DÂ�™ ,Ã)tÃ%¤C:¬A<4C=ÌA=äC"üÃ:ÄA$ÄB4ÄCDÄBÜÂ�JpÄG„��ÄI”Dh�ÀÄLÔDàÄNôDÅPEJ”D˜DNŒHEN|U|�€�VŒ�p�`�[ÄÅ[lÅ^ôÅp`|ÅZÔÄLœ€cLd<Æ P�ftÆg|€€€h¤Æh´€k¬€k´€l´ðF˜�oôÆ Çà(ǨXÇvôưx”Ç�Gp GqôFÐÇ}äGzôÇyþ¬�sÈ�ä�m4ÈlDÈTÈÐ À‡lȇÄ{ ǰHpäÇŒ´ °È ˜ŽÜŽ\ÇwüÆuüH‹DI”Ì�•dɖ܃HÂhÁ@ø€D\Á>0„A°É�äÉžôÉC\D3jDH„S$EJ,FM\EOüD_ Å£œDPÅ©LÅUŒ�WÌDXœÅ\äJ^tÊÅ¢ìŘŤÄÄd\F´œ€xƶT�jœFi|ÐFƒÄÆnÄGqLǽÜvôËv\HŒÇ‘ôÆŠ,L�ìÇÁ\H‚$H„H„ØüÉàÎáôÉ ž¡$J¨´ÄK,Fb|E¦lJSôŨK£$˪œÅª”LŒÅL¬E[ìJ\tJ§F±ÌJ³L�ô\Ƶ\K·„Æj„Ϻ”O¼¼G|äËtÍ‘ÌyŒG(L| ÇÍÔGzTL HÆHÇ|Ì Llȵ̔ÄL‹Ì=É ½L’|Ç‘ìL ýÈPM–Dƒ&(ƒ#là\ÁA˜Mü€€`CØM+$�(%ÎåÑUCã´ä„D¨ÜÅK¤E³„ÎèüJ"mT¤Êª|(þËW¼Jð O¯Ï`$OL F¬4FµTKftÏf|€ F3¥Æ»ÄFnÌËú¼ÏrüË¿ÌÇúÌG�L…DP�Ѓ20ÖNýÔ àÕ•UauVNõÔ@ÀÖQ-U�Á.(QE×tUW,Ò’ÒG<Êå¼Dç,Æ$íÄ,µN¨´J«þTÅ*-Ë«ŒÒ+½Å§EPKPF„õÒb\OöLF¶Ó·„ÏjänÔF65Ì:}SrÌÏwTÌõÏ:]TíÏõÇ==G?]S PÈ��AÔMÔ MI’õÌGýP’tGÀY ÅÔ•ÔÔ4�UC�É@ ��ÍmÁ¡€HÕƒ.€ˆZ„]€«€.@°¤�A=X£XU4€‚ì,ÛÕHV" ZÖ,ÚMÕÍ[²5„˜A»M³EZ¥­AS]×ÃEÜÄ=Â0›<¢tDR”WƬìR{åDP¬Î±K¨ì×Î�ÅíLÊìX,%Oþ„=X…5Ëp؆Ó1­F3�FP•­¸F‘ÕË��Ó9ÝO@eT;½ÓÍlPzDÙ‚ôS‹5YzÌ�°€�$ÔˆtÈÂÄLF%YKíP¿ÔPz¼Lç}ÔŸÍFа•ÁA[=ÀV(ˆÍ ÕdZ¼Íì[ˆÑ˜\¹ÝT²…mmÁàÚ2ˆÕ.øƒ>àÕ\Eè€AXîV0_¶­QàZ0ØU|àF_øþ-WrUÜáfÁvÍ—wuDK”W¤\ØM´Ü¯Ä\&MEY|R€¥Ò¬]|íÅ„íR€µáWlØ0�X‰ES¸¼€ ØFn´ÝæmÓqÜþXý”SvȤÈo¼G’ÞÝÓuL@UÈ—5ÔC…Hý]’Ý€3öÌ“$L(®� …ÔðÞ2€‚DXQ žÍ2hØÚ®¥ZöÕQš„ßXF`Ád¹õÛ¦ÅÍì€f UÍc=f„]5×TQ„&Zc9^AeA¨„G„µß;Îã=î‚>¦AÃ%áW†eÄ5ásAáH\ÎfN)ÅÄ#]Ê$ÍÜ^ŒÄR,EÌ¥ÊN¬Êî$Æî|�\lÆ\üåñäaÔ5Ë`Ýõ⸌F¨Æù¬€nÆKFµbØXÿLì LÅ gÜ�’eçä5ÐâåF‹MÈ…ÌÏ”HÊþÄ€Œ•Ðê%MÓàvìÌVíâF€(næfn0X€>Xä¦Õƒ§õÕ$‚@„ ‚>ÂCæè©­ZC`ÕÙìÛh%‚DøUDƒ.�Qð[\ú¦à0`„-Ñ•vàênäüM„D†`ª}ÚDnä]goìÖns DŸ¦ð ¯ÃYΖ„(j¢^jjTjj”êhk«q³®j¸ëºžëµöjk/칆ë©fq¶>ë¸k¼^ë»ÆkÄîqÄòÂ.ìÀÀîñÅŽÁ0þnÊ6€Ê>îÜní%ïìÓ¦òÓ–rÉÆò,gmÍÎí%&ÔºÔ€ÜÆmÝîmÞ€ãÆò-_mÍVm5Ïr)€åvnå>€œde††‚aeÁ`„…>V=0òÆZóöÛ ÕûÝh‹¦ÕDøƒ:vÁ`E0=X€Q•ÉèƒP fE¯A"`„YŸd ·õ[¿p’áB†Øð¡Vj¥fp€l–êb�êcok¹Föª.€¼Vk¶æê±~q²6qGöoñ·vëm—ê®^ñº>lìf'€¿r!7l(r¿îqœBÆ–rá¶lËþnrÎÎmÏ6nÈÖ÷ÒÖ÷*wrãîßví�çr0×FB5€1Wø„/ó2_óà–wáVî6nŠon`îÆfACˆèäèŸHÂAøx y\Wù•7D W–a1�^/âj”]‡ê§ößö¬fg¿jÇvj—v±æy´–klçj£Çñ¸¦ë¦ÿöf‡z ls/wt7lsqWww_A¬øÔmÌ®ì(ÏmˆÇrGûx�‡m(ßì/×ÆÙ¾ÆÍóÖnøÞÖò7Oó5xŠGsyŸøÆÆøâ.ñ=å$NN>BÆgÁÄgùÈ—|7tùZ ™ïðÿð¥Æùþ›/ö¤‡k³öy¨ê¾®q'kië¨fúÏGúÐ}Ö_qh'÷q·ýrÇ}Ķú°ìÿëDó%¯÷Õ¾l·—÷à/íyŸ÷´¿r�Oó¶'{Xb˜•{ p{ܾ{Þø7ü½ßþãÿí8Ïøånlš>ùuüžL"LUuôɇÿøÃÊ/•ËïõÐæÍïpd/v¦ævЈ $H `Xèpa'JhQ † rĈ1#ƒŒ 6l8€Ê„U¢l‰RÊ�2gf„é2ÀIìÔÉ3‚ B‡V0€ BPHhê”iS¦C‰V¨jõ*Ö«S·F5þ*Ô¨Ó°M5X°@Ö ,ÐÔm[ЭkW®Ô¤_¥zÚ·ïT°B€ ð�Š3nìø1äÈ’'S®lù2æÌš7sîìù3èТG“.mzs‰ÔªW³n­úìØ²gÓ®m[¶êºwGPààÁÁ�+( ``‹Æ *Oîü`Ê•&;BlÐÀ�Dë:GN}cw�ÌKš,¿0zK–._ Isýúž?åoÝZ”«Ø°õ“V=ŠôVXVŸ`ê÷TYea�–ZlÅ„vIèW`\åeá…^yuÀa„vØi!Š8"‰%šx"Š)ª¸"‹œ¹ö"Œ·É8£Œ¹ñ¦pþÃ1�ãÇ€?:ÇrÜqwÒw!i'ÖuÇœx¡ôÐ�ÍMGÐB奧‘K0ÙÔ’{3ÁçRdûXQ>E¡Pº‰•™yU~N­µVƒxÖ锄 ¦×_Bç~~HXb-*º(£�:ú(¤‘JªŒ•²F#¦™¾²h4¨Œ$À 4;íµÛþ(ß/xΊþl´‰/¹`lœpd“`ü È;VþêÑûv`Èb 0¢˜ð*¢¸b(È áï›óÈ“<Ü)ß¾>ûí»ÿ>üäîÚîÆ þ;ôˆs@Ñ��0jdá:ü�`‡Ó@0ˆÅ|Îh¡#•èì@Çp1|�Ü|¿ r°ƒüà®Æå7ÝÕOS÷[šÄÇ3�âpÛ ©™*†$�áóô4à \ÌÖè5°ÕëCCC”EÅÙP†MŒLTת0Ây ¼"³¨Å-¢f~—*a¦Nسàíï†HD `D<02fDã×ÎØÆç©±y û¡b>–ˆ9RÌbӘŧÆDô!‚ƒœccˆ*FÀþ h³"#)ÉIR2~^ü"i$8I寒žü$(C)Ê.^25™ÄÔ&#ÕÉQ²²•®|åKùšSÎ(•�Z%,s©Ë]ò2o²4%-kÔ»^³˜Æ<¦(ù·`ÚF5[d¨�)ÍiR³šµS&3�•3h‚ÊšÞü&8Ãé­ÖŒ�fÙlæ6¹)Îu²³�î Ö/ÏYNqózï¼'>ó©ÏÎ`SžçJ'4÷)Ð�t ýôglHÐE´¡}¨7ŠPœ¥õ„(F3ªQXJ¡ôTçFC*Ò‘Æò’#œhl>P’²´¥.e_Gý©R†¾´¦6½éÜb*Ï™F§>ý)P·õ¢r þ¥°ái7ƒªÔ¥2UR:='RíÙÔ©Rµª%zj6£jÕ­rµ«žÁ*3méÕ±’µ¬�k0Sc‚šµ­n5+ZiéÌ·Òµ®[�ë)Åj×½òõ¥xͤ^û*ØÁf”oD�%JKØÅ2vŸ£b+ÙÉŠó±%Œ,e3«YdZ¶~˜Ý,hCËÑxõ³¢=-j=ÙÙÝ™6µ®}-ûÆ7£R´°½-n%¹ZÀµ6·¾ýíìv{³Þ·¸ÆÍ)i;Ìã2·¹x®ÍˆëÜéR[Ð5—t««Ýíâ꺼«(wÃ+^iy×~Ë/zÓëÔäNÔkµ­zã+_xÊ’¶$0xç«ßýš¨¼þÅÊ.,`ɸׄç0‚,.özôÀ ~0„Sà0:8†ð„QYá sxÀÖä†;,býš±ðˆSì\ƨ´!V1ŒÃûáZ¾8Æ6¦îŒ…™ßó˜»9ÖæŽ{,äéþ�A2’�[äyÖ8ÉNNí’iƒâ'Sy±Qþç‘«¬åÓ^7MÞ2˜Ûå”~9Ìfîë˜�Zæ3³™®i®m›ãÜØ7OYÎvn*�×|ç=W5ÏYæ3 ½ú¢/ø�_(ª‹ÿèE÷¹5_ þˆC×,Ñðe4¦ïÚ>D:�à¹,�éQ7š5’–4 jå*šÔ®¶©kñHGzÕ'þÖó«s]ØÖtúÔ¶np«u-ì�º&Ò³Nõ¯eŠëa3[ ®9vªU-ÛeÞ:ØÍ¾vAc}jY'{§ËÆ6¸ÙYì^K»Å¬¾t¸Ó�m^ÿaÖÜNÍa�ynuÓ› ã–ô»Í]mt×»ßï·¬%Ým¨~Ûß略e é�gµàøhMMn†‡Õá¿ø(µmì@P<­Ç8ÈUËëTÏú—ëÇC®òH>[á¯ô¼W.s^–à°²Nu¾Ió™óü•Ï.yÎé'êž�•÷¾ùÉóšò¢3½}G_8Ì÷Ýô©WrÜÆ.·Î¥Nõ­s‘œ$WxÒ»t®“ýn­1A¤{vÈŽ½ìn—þ™ÕIŽ_}›ßo¿»Ó[ƒo_G½îxÿ»û´ ôµ_¶í€?|·žýu“÷]ÙÖF<äc–pTÞ³†�<æ}õtƫ水­sæCÿ«–·ûåtw¼ÝE¯ú•iAoÍç/¿úÙ;êè³®Á•ÏüéמݿŸ{ðQOý퇰5‡4ð…¾sî“?RÅ>õí¡ßpé—¿ýWåµÂ_�I­»¿þŠŠ;²Õ_qöÛ¿ÿ£Qü÷áoéžÿàYé]§ÉßjÄÿ RŠ÷•œée_ò¥Þ^ h8ߊýa f õµÖq ðþ}  ò“õ¹^øÁÞÐ�  féuš¾ ûÅ`ú�^Ú VÆùAššX ö JXn Ž_.¡Nhà&a2áÆÚÄéŸÇ5 : –Ü FW f!ñ ž NZ º rœ¢žaÿm¡ëu!v}¡ÒÞÑ_Í‘ öÕažßÆÉáw™!b`ËEÛ Îßâ Æ R`ô ¢"ja æ #®$Fbþ ßéà#b"ê�Ë©!'Z¢'.bJ öéá–â¶ÜÍ=!"î!+â$Šb%îß%Îbûužj”É¢yµ¡.†!(¢Ú¤"&â0úߎáþ(ââ2> &æß3^a.F#õ�е0þbãÛM#,.` ‚£ý9_Ê[šã.Fà×Å9*!;–éqá*�0ÎcèÝá!Æã:êãö¹"åÝ£Øå#@FžàÑZ7˜A$â)žÚ$Û5¤CDd5²á5V¤êÝá8 ¡,räôßGª£2Šäð1¢,$…Q$Jº�kð­¨Á›*vâK2Ÿk|A´¥e$>ndNBÞ ñ�¡ÝKj˜KåÖÁ~)TR‚ØR2åÔù™PRåßY%VòžVn¥2XHzecÕJ5u%u‘%¢å4U <&#ê™�2Å¥\Î%]Ö¥]®þ†{åá]ÊTî¥_þ%`&k˜�|Áæa¦abA|`Á(æaN¦d&ædZ¦df&e^frfdz¦fjfgvfhnæh~ææfŠæg"fk®&lž¦k–fe¾&iÂfm¦ìfdvoâæeÚ&j¾fiÊæ<&mgn'k.gj§pž¦t¢¦jNgmZçsRæuF'wJ¦€&gæwRçŒ'o–ŽÀO &{¶§{ f_¾§|Î'}Ö'ß\Áˆ€~î'æ§ø'¨€(�¨�(‚&¨‚.(ƒ6¨ƒ>(„F¨„N(…V¨…^(†&¨?Ö�ÀÈ ÜÀþ„h[Ú'‰–¨‰žèüŒ({ª¨`²¨{^A†Æ¨ŒÎ(�Ö¨�Þ(Žæ¨Žî(ƒn(êuè‹àŒÀ‡º(Š)’&©’‹‘.i’Â(�Ž ð(•V©•^)–f©–n©‚¢§¥鋬€M¦ˆi]6iî ©aÝ¥š:©›¾)œú%”¨˜Š@ügˆr©žî)Ÿö©Ÿþ)•úh^�kœ�Q¾Á$*¢¾�¼�£²�Ä©¤N*¥Vª¥Æåœ¨j£:ª§>*¤ª¨Ž*©–ª©n© >"˜®0�«.A �¬Á¬Á$Á¥æª®²©‰鮚h¦òg«ºª«Êª±� +þœ*³6«³>+´j(HVપ†«&,�±ÎêAôÀÐL›þꯎ+¹–ع ‹¹K°êç+0Á¶"�­Á·A´æ«¾î+¿êiªZbµ¦¼Æ*(A½"«ü@Ì纲Ø\6l\ ”Ħ+¥Vì’b�«¼Öj½þÀ Aô+É–¬Éž¬…þ+.l È+Á"A!<ÁÁ&l,¬ÅÞl�6B*„‚XB A+ ¥k¬A'ÐŒ¸`’ÂzN*ÆþRÓÊR[¶«~«±¬Â~+Êní�$d‚È�ˆ€l€¨´Á‚þÀ&0è$DÁƒjApþ-ʪ¬5.mjÄë¶bÁ"0ÁÈ~«™â,áªÆ€Â‡ A‡-ˆÖœ¯¦Ñm+Ø¥„�j�‚i›â@ ˜‚*˜BMÎÏÄ‹ÔŠ@,­b�"0à Á·öÐ-íhÈÁˆ€´-�Îè� oïº-Ü:¨ÜÖnÉÚ­Fjkl+­þ@‚ëÆné¢èÓ¦!ˆé0.¼5.äí¸­]¾Á!¨Æ)xAˆÒL_r.ö:�*PAöŽiš¾(�"ÁËÚ*‚îÁ Ì®òÖnd�»êgñþnðþç~o€†@+èÛÆíÜ0¿2oPþ²,ôÚª0A �ôÀ à€ÐÞ¯“®�"B*”ÂJz¨†'„n B*¨Â)|ÑÞЦ†4Â)œB”ojTA'¼ðJB.)¬‚)ú–€Œ¬†"(k˜‚ŒB*t‚ô@ýú °Bœ+�!”À)èA(ì¬ó Á"œB*‚Í–Â᪂(Ø/k„¨pœâç€ëÇ2� AÍÊî h0³Î�äA&H‚ˆ@¤Á~æÁغ+pB&P /ïn ˆÀÜA&dBÜ©'‹€àA&\‹äÁ&\4pDB(ïç$P2NB#sBì®XA\Bþ&\ò8r%P²àA\Â%ÄÁÙ*ò©rpA–€Ëj²Úk$AÀÀ·~è øåöf9Ó̸”€œÂ¬A(¤Ð k‚!„è`Bøm t!øð—@ä *¬³� ‡A*d®ü,Òž)kðÁ ¯†)x‚ÜÀ ˆB ‚!@.GÂú¦)€=ÿA<÷ó!é§�ô'`o p´jàÀ”=ûñ›ž®¬A½ú@0�°ìÞ@"Ss©ÎA&Ad'ü€H‚~þ'ä²»&°~z2Û¢€$Çï^‚ˆ€'¯À&h�Á$s&LÁ  °­lþ�$ü@€ÒAH°LB€(äA~ênÁ&øÀl‚ˆÀ ø€hÁX‹PÂW5³ZóDb³kÔ«\ö$ü€ßœÀ ä%N+é�‚j‚"Ü@*P øA#´!üóäóÏ-ÐÚì t'�Àáî'”€"¤±jm”ö K)¬s à@+8¤Â �&ìÁÂH—)ô1;O.ÐVq ,A+ ©§†°‚j¤B+”\Rêèâ¬Ncmfó�¬Õ Ì· @6©ÎÁT2%'µ´Áh�5X/`ÿ5(£@Р€'§AÃòÜÖWïgïJÁ$ü€]þè$ø` ³�Þõˆ@%Ðbß©LÂ~Ö�L³}ªd^jt¨”®½®fÀj�À 䥹ž3þÒ%�ëe)­Ao§ÆxB ˆ´ �‚åZB+´¨‚÷ƶ’3®´Âñ"´’³ö…Á)”€%ذ°B)”‚ÍZŠ)ü$*(˜Á™Ó3u³/¥ýð¤‚jÜ€+Á ”¥-Òî9\7¯†6¦èÇÚ+|s™ÞW ¸W‹‹j*‹Àoàn%|5lÂ&Pr%(ð€‹rÛNÁ&´ò%hB xÀÁÛ©\‚à/3ð&LÂ$Pµ�NB$ëg&Àmä�þ°s„À ÀA&Ü�\'¯~ÚÁ+Oº¨¾¸åŸkøOA —ÁàÀ �€·{;2"º‰®A)¨!`ñ¤ö)¤°¹‚ÿðx ­ÐF.<��‘û6p˳+�"8‚°�€›À(wC¯�#ì#|h T*­¾;´ÍÆû8|wOnj@¥ 0‚¸—n¢¨­AàšÁM»j@û–f8X¯º~FYARg€Zõ¨³í'Œ@ ox2³C¸¥sµV²Ì°¸€n¸»n('A~^Bˆëg ÈÁÜÊ­¦­É�ª´ç^ŒÇgjØëÀ€ÂÚ,É_üÅÞ,´Â˜þ@º§·%x!xý’»;nÏ6Rv‚!˜0Hô(¹¤Âø°¡›*P�_o+�ŒÂ«F¢f1( Á B<Ÿð)€Â„h|‡Z7¥9ÒŽ€' ô#(‘R<ú>Á¸×”BMʽ“¹}ž.+ôÀZM쌗À”f=ŸÎAÓ‹@l‚\KrLxʼ'7pÐA(ƒì¨÷'hAÍç óð+¬p‚aç�Úò§ï~RB^�@µP‚~ZÁ&LÁ¶8xbã�»b=ðÿéÖ`jàm \6@é!F�$J T(¢DC‡!F„8BbE‹)^Ôhþ1ãF�;~,±“!T§ü84ÓjIȆ„݉©„‘V2…(J¥*•‘�Ré¤j‡{J±eÄfÃ*ª®¢RaZâðÐ)™%„tJeh©/ �¸ÂÉ(U)C8–:[³U *ŸT­åç«H¿1ž(¸¢KÀ$ ;¼"‚qcÆ?~ùŽ$0S�ØqgÏŸA‡=štiÓ§Q§vƒ²3)‡¬ÒÊ+±ÜÏ!¹ì’K%áƒ>@á<„:È€Mbt(±8/œ3Î!â”1èѬˆ®H¥ˆ>ý²“ÐC½hFÏFxrÌ ý`Í7ÕÌÒÒK1í¬HÇn $¶LA UÔQ1åÏËSQó0ûD(Ñ ]MO‡:A�W-$Ñ!=O ×_Ý X¿Hðâ4*þC–Ôe™-„MËã’ùš­ÖÚk­5Õm 4ÐÐaÁ W\Âb÷ÛW ;w\qÅÖÝwá�WÞy³Ý’Û{4°ÜuéT—ßÅõ`�8ÆvéMXá…n¸amñ�XÕ w_ƒ9ÂXã +>´c�/DØá‘I.Ùä“„8â{'&÷¯�7‚™`e¶°æ�qÎy°aEFÙçŸ�Úa•WÞö!*†p5Â�.Öy]¥k6!¢¦¢ÚA;©vHj�–.¡£ŽšNL3b!ʺ¢}Í.Á„rËýZÎÀXm(m¦5êšî—Kâ ¡ùîÛï¿—%ºèS2á /¾ðBq/®H|ñÆWÜqÅþ!|rƯœò/º¸¢qÍ1�ôÑ/']ôÒ3GýôÕ-�qÖ7‡q,ºÀâó/ ¿¼ ×_·¼óÛ]W}ñ/h÷÷Ëyï]ùÉ‘—|øá¿à|÷ä_¿âùÈ…�|zç±—=yæ¯_>|ð“7�úлwÝüðÑ�½{'˜d,þøA£?4û\ÒðßßKG’ÁáËBi››Cb˜¡­l ¬š%Ò6>�‚j;`·æ‘@Gk«Hè¬`jjk gÁX­Ð"kc¡Bä4‚­y°pé`Ó\(q°„'ÄL¥¦£öpG0œZHH€Â!†Pj4¬àE&˜!>qGD”þ›Ϧ@(ÖÍi¹Y ƒE0b苉*#ŒÈ2‡€�mtãáÈÆÄÑ�s¤#íxG=î±�yäãHA’�o € ™È>þ‘Štä# ÙHHN’’•´ä%1™IMn’“�ôä'A)H ¥‘[À P‰J@ «\¥V)YB@– ¥(�Ë\¦`—ºô¥.e™KYF—¸¤/WÀK^3˜Í¤€-]ÍXÒò–Ôü¥1�ùK_:€›Ýô&,!Îp®Rå4ç9pu@�ê 0p džõÌ€ê©|Þóýôç?á©� Оôüg?+àÏzt¡òŒgþ?z�l€¢�(E+�ÑŒ"  ¨2 O‡.”£E€G?Z|t¡ h6pÑ ÜsE¨F+ÐÒ–"ô  èO�J¡•¨E5êQ‘šT¥.U“£$eªÖ˜JUºòÒ´å,k)Ëejó™Å|æW…ÙÌZN`ÇÜeYÚլ¶2šÐ¤f-cL D€«s­ë7¿Ín¾òœ}U€pV��m¨Nç)Ѐâ¢íçJ1€O}ÂÓ±ÿ$hdwZÙxV”³õ¨Iqš�:ô0íiQ‹XÅv–³3­FkŠXÌ^ ¡Hmý TÝ2•·¼eÄo�ˈÞ—¸Å5îqþ1éÔ§zÉ”R]�4­úVµ.s«[æ5¿:ºsXf2™©V¸Ò²•l½êU¥ ®ÒÕ—ÙÔ%^½NX‚“›ðë9`À²3„­g? šÏ}ꓞmlDºÏÿþ3¡µ¥'d/«Ð‡>”µœý(G/œP,¸¥ 8ihO:O̶t ¨‰30g4ÀiF[:Ð;Ö§ºý)ôpJ À�ƒørõC â¨Âu£‘�œd%/™ÉLUîr¿U©Vu•Tv¥xÑûK´ “Ë]ÎêW‹™T¦€ºe­%Ô{æè®²•³Œ@5³éÞºn¾{…îüJûÞ·œØo: þ+ÙÊ󞘅¬* ÑÈ*¸¡µulŒÒˆ´Âí(‹YÌÑ‘þž=)jsºÐ˺´Äœ5ñ^l[�Ê–²š¥‘}p¢MP‚2vÒ ­4E1üáK'”Õ¦5©iSªSÈ^Õ8¥íªY][›ò4ÖÕˆPH6!×mÜu!?�þ�@ 0*ŠýI0t ] ²³�=m¥/�éJ¯¶µó%åTÒ;®X¾7¾Ó}M,¼ºTfW³:�ñ²’•nus5i‰o9ƒUß°|,ùÍ×–îí<,§'ëpÅtÒy/t@iŒ[#  'yGQ ò�rx—.é¥EkÏ|’ÚÄ•·ôk=ºéÁ·:Ñ-ÏÀº°=¢ }C´± CD"1ˆAðxP„!P€è!€àõ š ‡Aä¾�P�ã(€2 YÇ Ä�`ˆáõƒÐñ Ñ=”ÁÇDé„6Û� X¾õïøý>„?õ«gcë÷(þF$éM·ÿýñä§C½@RŸ*›çMºÄË—R ÝȌҭ»º ½ª™²Žº¬«–ˆI–º šÜ ±ê¼ €½|I»ð-ö�ðjÞú�þMà�Îî4oò l²4€ÊhPÑÍá*«¶ZJ²8ÏŸjðZëÔ0J£8êñ8Íà0à£<ìÃ0L¶èéÄJÅ.¥p ÆXí¶­ŸnËóZOôÒOæÀ|.H€�ºhN×Ð@> xLºHÀ  dnèºà×Ê�Žô `ÄPg  €�:€ÇQN@ >€â�Öto€üÚè@}þ.Ž(1a ûð ïˆ !ê/ÿTqY‘¨>ÂNø¯[üÍXÉÛÀÊä­Ä­—P—ºKÑ ì$`»ºÊ¬À‹Ì’ië¾­­¦‰¼ÒK®´¬½rIìÞkßF°úmœN0àØ©°ÞIÔmÑnÑ|ð~PÁò®ËÑ¡ ë°úÉâ©¶*JÅdj˜0¥0¬ë äøñÓXÍ¥4`ê±Ä*ÀNN£<êìä`-Öú€ô =±õ@ü  ×> èÒ°�¡ aä¯AÇÈ��>’�  �ï�àøØ¨ r¯ôú`ü„o¡N@æavÒû(Rü.1è&þ@&éýpÌ"s­ÜïŽýR±©²*­r’ö/_á!`ii t‘ÁíÍò™~Qð€t±-u1.Ëë-À-Éì.ñ²yé °»ÀÍ¿íÛÂr™ÌÒ/å²éR.ã’ ``  ó1 ³0 @  3 à2 µà3C“ ²ÁHSŸP35ñ‰5€5áñ5-€h�ã‰`P5õ©Äè1 'ê6ss51ÀZS8áñ´2ìÂf€R9—ÓµJó9 R¿¤s:   ]l6]L6‰S8Í;½³¶¼Ó gÓÑÀ³  @ú’’×ÊàñPþã��6’�n ¡É*²yí${LØÜ¨ Aö€��ò“ø ÁÖ|ì'!N ¼�=›€�€®øa@eï=QB71àS�@4t*¯²DMôDé(+cє̎ɬÍR-_F+S-ÃM.Ýr1ß2Gã2/ó’/½«» P1ãòÛ^ “H•TIÓ1#³I 0û²»6S  3ËéJOë3õë´®³4±7SÓ5‡6ió5e�yPŸ8@ u7‹ ät5gŸP«:E–“9“S9]ì9I³@¿ µP L•4Ã38Í4<±Ó:±“!I³Ÿô0Ðþ3)…r? H 'Û×t­?€þ�o$#Ô$½�#%²ü<Žàp%!ñ'ÁAyrS…uïUñè?ÙHrÏSAÙî âsÙÞ(éP´Y�µU”ÿXš¼Í»ÂR§Tìü2F¿�[Ã�G“Há’|/�ôEÉì/»U]Ï2IsIÁõ-%ss2 2ÍK7ó2#Sò4+€JS`5JLQ38Ët6UaáñlÓÐ VŸèQÅè45GL;Y³5e4A-9=–˜“õK]L:ÕP €Q!5<åte]vRËÓŸÎsú@{LØ0Ùˆ!Ü(j’#þIÇâð �öú3›@çš@gãéD/øÐÐÏ“8ˆ'€ tp¶ ;1úVY HÀ ;@ˆls¶yô¨ À ¿æˆDŸ5oõÖþ¢긲ênT[Ų,ñ•FýÒFS\ërG³\ÍUJÓu,‰´p S1ßGãJs^£4[ÍÒJõõ2;349ö3GVR…Ó`g <[ÓQƒsa Ò6Uóa’7QSÅ,vÑd÷5õ‰8é”cQËc…—9§,€:©3RQw8�óL¯óeÃSåTîª/Yá¨ö†L(uO4%õÀŽ–?MRÙvØú°>?Rx¯þ÷x-ùŽMà1úõ‚` `ÎV}òkÓs´ˆà×”Mù~ ~ „ {•R÷aBéˆþ‚ëðvo-ø‚›¬o­Í”š‘,‡T,gTÜbT[IX-—T1Ý.±ÕqûrF…4ÜL8.aX/ñUrOXIíUsÀsót­3µtKóu3êNSw{7Lá±ecÓyiw MmW m÷`}Saµ³5÷´+ :›Sx“S£T6: QO6e£z]VR‰x6]­ÁpÌfߨXãVUéèx’HÑ�îS�*ƒ9�‰Kƒ¡lZWi7]ÅRHy˜[«õÛr4^UsYþ˜†ƒtJåòFÇ’r5ù†™t2s8J+³/9³œàJ1³t½tdU¥R³MÇ4ava]7bkWbe·w[5s9cy·5SŠKI—Oû´9iÀ:_,£Ê˜:5RYV6wy;�Y<_vå²ð([õzÛ7Tƒ–éöØ‘¾Y€»�þX�Íùœ�Š�—kZ‰ ür‘IØ…¥t[ƒTìsÁ•\Y˜rÅ2qÃÍžyHãòr7’Jéµ-ïuž»ë29ó”³xWyPÃ4b³x–�¸¢wyN%v7kW ‰7CÚQ‡³‹ùQ™+ŒÿT _ŒQ»yUQ!5šÕøyW–!ý)¡þè›Aþ¶�Z‘ò˜#Ãy�‚ ¸Ð9©•Ú/D+„ƒ] ÜÔ’ÝŒ4,ÿYJuQHm´Q¸«ñ¹’_Ø’íÙ_ûÒ“½ºÚ^E¹žÍr_ûõ¡Mk‹C�ÁTRmÙ;c�81ÖucNÅw‘w…sw·aYó_E3¥?ÖOEöyÅø¥•:ÙXeáI¯kÚe{ ·–º³=û³©R�ŸêoÝJì(°[ßy”å¹[cø†'yq+Ùs§T0×5«9y.Ñzqër­›Ô,=ׇZtà„ØKÙLU‰öuýÚ‰Öb%ë£%ö£-`ug`Ä’éôL%Ú´R¥•³ˆÅ;P§óŒSþu¿ôuiS–dz;¡÷¦ÌŽA{¾é»¾ŸM´I‰ƒáŠM۴ﲆ˲…ÿ9­u»ÀWØqÏÕ»†4q«u…¼“‹­y{2ÝÚ2÷ˆQY•MkPV`3–MEZšE|¦#+uo3¦~³£?5]S½[·:�ä `¥ÿT9�²¿Ô¼Ë¸PUR™øN!Õ½!µ4s¡,À¾‘<É•ÜÉšÚ©Ä” i©$I©%iÊÏv‘ê¨�éWéÈÊ­”¾|ÉÅ|ÌÉ|�›ÜÉýÊË|ÍÙ¼ÍÝüÍK¿ÓHÍá¼ÎíüÎñ<Ï—JΈÎõüÏ=Ð}ÐùˆÏÇÏ =Ñ}Ñ}Ì ½hþ½Ñ%}Ò)½Ò÷öÑW&Ò-}Ó9½Ó=}Ú0]bÖèÓI½ÔMýÔ™ÜBмÿÕ]ýÕa=Ö)‰bbÕÓ|Ôe=×u}×wýfl½?4�×…}؉]ÒC�€p½Ø•}Ù™ýÏ E"~}+“½Ù©½Ú­}É�]�ZýÚ¹½Û½]©³½”¦ýÛɽÜÍ=ÎÏ|ÕƒýÜÙ½ÝÝÚÂÝhÆýÝé½Þí}¸âª¶ýÞù½ßý}¨ò�pæýß ¾à >‘ž¹þà¾á¾�¾KÖýá)¾âï=â£lß-~ã9¾Þ1^5¾ãE~ä¿ýã¯máI>åU~ØMþ©Q~åa>æ]½å£.äeþæq¾ÔþiþÉ_>ç}þç}çY½€¾è�žÑ…þÖmþ虾éë<é�½ç�~ê©Þ¾¡^Ú—¾êµ~ëAûê'žëÁ>ì1Øë¥^ìÍþìM”ì³íÙ¾í«’0:"Ú¿Þíé¾î�΋,BîËÞîù¾ïõ/ÝÑ|îý~ð Ÿ·Ôžè ?ñßÌUý×ñ!?ò› ð�üñ%ÿò1ß‘?ó9¿ó-ió=?ôEŸ�@ôMÿôS”ò�ÚòQ¿õ#ÿóÞñ÷Þõi_ñK¿öqŸóo?÷yÿõU_+Y¿÷…íwø�¿ï‹ÿø•ßí“ù�ßì›ÿù¥ë£ú­ßé«ÿúµ¿è³û½ç»ÿûÅþåÃüÍ_äi½ô~íÏ¿ýÑÿ÷WtöÝþ¾üéÿþ þÙ#býÿý¿â¢„À� ø*¡Â… :|¸� €‰+Z¼ˆ1£Æ�;zü2¤È‘$Kš<‰2¥Ê•,[º| 3¦Ì™4kÚ¼‰3§Éƒ<{Bü ô§D�D‹=Š4©Ò¥L›:} 5ªÔ©{Z-4«ÖWC©zý 6¬Ø±dËš=‹6mÕ«l·º…ØU­Ü¹tëÚ½‹7¯Þ½Ù¶} 8â@¾„ >Œ8±âÅfŽð‹0°ä¸Œ+[¾Œ9³æÍz@îyCòäÁœK›>�:µêÕ%­’`Z4`ʬkÛ¾�;·îþ²¯yš`;BölÒ»‹?Ž<¹òÖ—0ñ…Ÿ/7® K{¹öíÜ»{¿|ð  ?�þ|±~}köïìÛ»jóê%øˆúôY×Çÿ`€È\ #8Â} RBoñ§•NHa…~×Ü7ܰà ZaÄ]Hb‰&žXÛAäý±â~!Â5"Š2ÎHc�|TÞ}ä¹ø¢CÚd�BÙ”ŠãÝ�=2ô#‘N> e”*á¸âxJ.©P“RnÉe—]ªX^ å]‰%W1z‰fšjy’,& b™ i¹f�vÞù•:â\sÉ9ç™xJh¡Ý�ùædbI§þ¡Ž> ©iFù¡O€ &P¤šnÊ)gmâwߢK6Úi©¦žz–žGŠÚ#©¨¾ k¬ETå˜q^š�«²îÊk¯3}º'«/êêk±Æ+•äÙj)®ffŠl´ÒN;Ò¤,VÊ“³¹ Jm·ÞN«¬˜ŠÞŠ+±ßž‹î¦ˆŠ+lˆæ¦ o¼xä\Ž×¶ á»òîË/—F.;n³årÛoÁ§idŽØ>¨­¾? ±‰ª2›mÃGŒqÆ3‚YÞŽä^ê°Æ"�Ì^¸V:&�¶Ï–@rË.Wh� pÅΆürÎ:Û¦g˜øò‡óÎBí)­�ÐüszAÍtÓŠµù¦˜I_·´ÓV_�þÔ×ÌðÍc v؈Qyt¨ZµØj¯ýÕ¿:N-\ÚlÏM7Sn{,0È_×Íwß^‘ýöÙrÊíwá†Ã„h‹‚—IøáŽ?¾“A ãmóÀÐBŽyæ:¹ œy£½·æ¢�~Ùrm�Ê�“Î:æëRܵå,·N{íÕä&å±ë}¹í¾ÿ¾VA“óYPŸ%¨:ðÊ‹ž8ì©[ÜûòÒÓn­ÙŸžüôÚþ)yÖWÎûìÛ�¯¹ªÂ-Ûêä¯ß2¢�£Žôâ³O¿ßÀ⇾hê×ÏÿÃá:?¯E¯ ›µÎ·8Fe¯€ |Ùÿà÷'ù5p‚W{##ÀùQpƒ;£—@ì…þÀë1n�,¡ÁþÅ®ŽŠ„&l¡¼6³ü�f€.¬¡ÿ$73F0ƒ6ìaÄ8ö&f>,"§LAç �†Flâ·b6³Ó q8Lt¢¥Õ3Ý=�‡Wìb·À„4¶Š…^,c§ ¶,Ï�tU4£_¥5]0e|£w¸ïíŽ�¼£KŹ9¯Ž,äiE35î{m4¤#ç…ò r‰}|¤%!Y�åhŠØ!ã%?I¤O…i’„¥)Õd¾…mQv§l%ÂŒ–(Nº…ˆ®¬¥{ºç31Ë“¶ì%ÌŒ6JYª‡—¾,¦€ÂÂ52²’Æl¦Ä‰RrÑ™ÔnŠó—ÂË¡4µ9ÎtÈ}É\ä©ÎxnG”1Ô¥»ˆ)Ï|¦‡f„ð©Ï€¢æ€ØÜ!:ŠÐäÄ9Ô�=óЄJ<�‚ÎóP Et¢}ZÌ¢3�¿L³£$eÍAäJ–t¥«ùŒO–Êt1.Uæ;™9ӜҴ¦« §Njžúi�#ªQ#Tpò#8ª›  ¤4|_¸BU¯Š…«ZÕªZýBU¹ºÕ­zU¬^øBY¯ V­¦5¬WUk[ÑúV°~õ­]­+\Ý*×µÆu¬s«^áš×¿þÕ®{Å+_ÅŠXºúõ°i-+¼Ù-þô•±Šì^kØ®æu¬�U¬gçšYÀR¶³¤=,a1ËÖÍ&v° M­iÉZÖ²~Á 13ž†¤ŠÛÜêv·¼å- ~ÓÛà × (®q�‹ÜÜÒƒ²�sŸ ÝèJwºÔ­®u¯‹Ýìjw»Üí®w¿ ^ïŽ ¼ä-¯yÏ‹ÞôªWºW‚’ ßøÊw¾ô­¯}ï‹ßÞzaª|\¯ÿ à xÀ.°�Œàî¶÷*ºm~ áKx®ðpI°_æ†/Áî°‡? â‹xÄè]0lècá«xÅ,n±‹w›a:f»7X‰oŒãëxÇ<±‰}#¬àÅD.²‘�Œdãþï 1&js§{é¼aÊ|˜²•Ù0å/ôxË\î²—¿Lâ$ÊR¶²™­†$«yÍ)^2›ß|•/q¼Ò˜€g&$! Hèó�0„!ø` `.´¡�èDkW̹sž™€H#¡�ö�˜àb7ÃyӜ1*ã'K÷Ñ}.5~è!ü Šnµ«_ k7Ù@#Àsôlê"PZÕªö´¯é«é_ »Íi2%©‹ç>K >´ªƒÐƒÄzÚÜEµ¯ â$˜ Å»Ánmê!ìZAÂ~°æ`;¹(%ün5Ç»ÅÆ–²ù\êATAܨ>wN€í€?÷þwÈD²€‡ü`t–®ò°Ý…xQ¯µÞêY äÛøFB!°0!„\?€AŠ ßyËWåÇe9O�ŠT‚Š2B+z‚EZÅæ.-¨]º†N¬<É.¯¯tìéFÚÔXPĈ`@»=ÀxÀÓ� ý` "Xxႇo×›¯uC )<—âá½&‘‰K¤Áê…ft ¾ÍôE0AÕ=‚ß{°n·;¸Š D nP'”ÀæƒHÎ?ÃóÏ�âçŸ zà'\t¿Ô{ÆJ÷žPˆ%£Çè~m8È!º@`x´]±W÷õ"¸ Ènvë¦�þk¯¸Äݸ]Ú@Ð×Qße�/¾Ï@p6è—pîüû—gqæ?ŠåÄ®hwŸß ¶p¿çÊgË<áœëWÅ›utK�ê@?!N0Bp°!äóX uˆCÜÕ y0wΕø\p� ™0 Rðp( q!Ð'wÎ%v#y� ” °—•° yˆuU÷\vPÑ5 [�š€@ЛÎUœ —  “"0 XgpÇ×up ™@&à\”ðp¨°÷\y°úÇeÜfFÐgÍViOI &gzê'\\ˆyÆ…~` ª iÖ Už°þqŽÀ §@ =�IyCЩp kxàG�� ¥àñ¤° ¤°‘÷¢xA kP¤Ð¢� ˜ x%Ÿp §Ð²¢‡{0k€ „€ §`�§b^Èií·a�÷g©öIÀC°+€'pN¨cZ° M—�[ ÎåœpzÐ%q md7!�«÷— aq7� Z >P sg™0¸�e·“ mЕ¸vyÐ#Yg«'Z0ŒpÀ{Î5 y mmqÐ�qw€�úèŽ÷\#À—ðJ¨‹9Öd�1RØg?ðþwNÐåfzB&Tª¸~iˆ� NpcÀ FЉ‡gsBPÙ'Š@‡­P�P7`§p�'©p%�£ –˜ ^pŠÇsg@ FP‰@q ž€� áN@U° Fp«P# H¹­°7 ¥0�Æ‘ôÆ_ËD]ºC`n±ÈèFBÐ7p ycZ0 Ïu0— "]q �b‡{­—‹" ¼'v²'X'ý§mÖFvR0 >�])è\#ƒ@� #€x0wqp�:8€­×u›PÎÕz­9 ZXvÎu ›Œ"À�xIbPþ8F~ç{°ˆ7�‘Ò‘¼¥Š#€r±‡ˆ0Ÿð7€ ÜöŒ`,Y‡ð’4Ù 8 ‡€{˜$k až%Pxas_P HÉ`�(7Ð •8¥p#0 ‡€”Õ±¥0Œ H÷u–ÁU îƬØ_Ó«Ö{@‹B'ð¼Ib�™x †)’ vx° ›Pš‡™ÈšJ° „'ÈŒm ¡Î%” ¤ ]­·‚=pqÖµ€Ïޏ?À ¹h�½GŒe7™ „0(—`Î%qy�\`¡9v¹˜7À•Cý6œ‹(&@}ëw Z<~ñnþàWŒ�$„p%0 ça ­Ð k¨�s¸x0I®�‰§� ŠPs8~Ðñ§0–€†wJ ¥ÐŽôâf>7¨ð$`–ð ŸÀ ûÕ‚p Œ`¡è ¡€Š.5¦<áf*wª(_ ª– hçöfÐ&ð$à Øµ›Sê_Z £ÎÕ—"šJ@ _¬$ºšÂZ›´7�y’é\\qý÷\ · ?P/z]“ ¥ÂƒÏŠj@˜CŠ�¾§šd·˜ª¹¤1¨¬Ðu r@˜¼:bWP{VZ{] œ%‡*Ù ¸~Éi_ÍYSŠà§ Žè¨p¥Ðx¢@[7Àþ�wZNÀ ài Õ‘¤ºQ){ªë¹}®PŠ` ÕÑ'�W ŽxxõIª x$p 1±˜P–×’ºb[_–\®zSÔåw~÷F ’¸J�a´ò b¼È¨ Y@€yÓ9جí€r�‹!h�—�Û( Úh€i�7àu? µ_—Ž(¨�"@y—�M™YW®EJ€rpxw`¶y|­g@ò‹ê¨´>f¯µ'"Gråx¯!¡·šrùµªö…«Àmfp•� ž@n¦�.y§Õá ‡xFð©€ê¨°“F0 ŽX©@&`8Às8 ›þH…0k¥ Cp‚ 7§P_à BIÕñŸP³Bw³RU¹ÄfÓ[G§a *]äFu=°=@}&€´ b¬‡{`Z€rp ø\Y ¤ÒÅ¡" ‚«ÙšØª œ�\'{˜ •`€‘9 ø‚›€NË®ÎEs€‚p�ü\r°—ÎÕ¸zl‡µ7`q'D€t«šÈ 8µºi¾æŠ{¥Ò÷T‡7€«ÌÉœG»[Ö °¡…0†e8fPŸ e¹�/)BÀ©  ¡ÀmsXTà ª@ h¸¥À ¦€º7WVùc¯K/¤¢  ˜°¢þ¥à ‚ð_€�PŠ�ð5ªÑ[Ãó5¦> S­9]ÑtY«8`«ä{ P™Ð5È`w]»*` ¸›!`#¬]9ê®ÓU¾�ìaT ¸(¼¥Ý{‹à)ÃG«›q|d9‡¥q~¬*\: ÊFÃ<õt¼B1{%ðà�Ë Ë”Œ`…,8@ tËe;]SÀ çèË'”õZ¯$ð!@˳\Ë `Í» ¦ŠœÂ¶ÊB…¦!§ }©œ\Ýx®Ý=âFàUÍá%Na-( 2nâ1NÎ+¾ã:>_<žÊ@Ê&ð¿%äðEä räHþÑK^Î!À*#åSNåUnåWŽåY®å[Îå]îå×_æa.æcNæenægŽæi®ækÎæmîæoçq.çsNçunçwŽçy®ç{Îç}îçè�.èƒNè…n臎艮è‹Îè�îè�é‘.é“Né•né—Žé™®é›Îé�îéŸê¡.ê£Nê¥nê§Žê©®ê«Îê­îê¯ë±.ë³Nëµnë·Žë¹®ë»Îë½îë¿ìÁ.ìÃNìÅnìÇŽìÉ®ìËÎìÍîìÏíÑ.íÓNíÕní׎íÙ®íÛÎíÝîíßîá.îãNîånîçŽîé®îë®æ!ùú®,� ‡      % ( $"*3<= ".*4;?2$%%&*++++#&)*.0"'5+04.4924516;49>;;;&7DHLR%A%!["#m#DHKRY6D,ÝùsæÓ¡ç¸žý¹wïÍyþÔ˜îÃ8ôêשûðŽ\:õñÏ£3g.>~õñÙ«[Ǽ8qìÚͧœyßI7à ÑÁ‡_‚Ш`wÙ1xžõ�7¡wõq烀7”‡ß„Ã÷¡{ë�ǃˆßU˜]õ�Ø¡zã Ñ]óY8¡xb¨`}ó�÷!ƒF!…ÙW^{8*HdˆåÁøˆVˆc|ÞÕXÃ:÷à‚Ô%ùá‚Bº§_sÝ•¢‘ù ñ$ƒï‰£Œ&nHÞy46—^Žçƒ #šiÃzƵ(¦‰ùM¹%x<Ô韑æ—ßz†ÞiewˆßzêÙè’JfЦ£;ʹè enÙœ–ú-×¥ãuh^†þð™y¤«Fîk«zš §8"gh‚l^j+„ þYžŠ±zJêwóiÙßrÛ]:Þ  ‚Ã…xæ�"~é-¨ßz ç·†òi^¸¦®ªå“ÞvKæ{^^zî¹àâšÜ·£º ã·¢Ökh«á²‹®¶J~ˆÜ¦6އ½_Æk0™^ \°·Kü££Ï1<ð¼úÆ›1©ï&죴£Ën—õî:äÀÞ:|bˆCøp‘ [Ìò·Êéøá ›üe•!Ç °¾BŽ8ðÈ_È©Â÷Íî¨5"�´»+Ê,-üÑk¨y=+üeqGžÙòÎÈE ê¿ìQͲÀ^M0|m“©6Î=ƒ ž–  $vþ¾÷*W'¡ö�  ™‡Ìo¸ÈŠí5¡s’\äÅõ†<êÄðYºô¶¾’9ÊF;Þs›b§^æ€oë!~qc�ä ¦:=¢Ðaƒ;¨tƒëšäê�OÙô²…c�úÏ`¶ù9ðnÛ<]✢Œô“³Û[üëE?È:èd¶NøtM péʾøõ³RÝ6áBç^9rÛ·Û4uص�>¼·Ê«-Ÿ¿3^~þeëö¹OŽ_ŸÔéíä…¢�oD\“t¤¤+ŠQ¯kt£óÈ”§nH<4@ÉH2>láJÝýnFÂÖA΀é+¡ Q.ÎlmIóÞ gH⋃.|T gh¼úð†þ?ìÜÆvX¾ Ò0‡F<¡ YæÁ0‰8Ëw)úµ�bÕӟư¨¿v0H,¡ïÂÕ¨‰PD—§cE·]�g=lËôÆ2ÈÉy7Ø`“h<4Fq�€ìb éÇA2�qLbéÃD2Ò[‹|¤$'IÉ4RŽ•dYKxÉLÎp“L¼$Ãõ!¡”BHe~°Ê°Ò‘_Ф'gùCYÒò–¸œ! YÆÂ2—™Ü¥Ï€IÌb6Ò–“¦1?¤L6³Ï\&~º Q¬b£@Dn¶ËQz+iPƒ8Ç 0ˆ èÃÖù›í2šìŠf¸”)ÏfÊó[Èþ´§.YöNíAóŸ*¼g@yÏ~Ñ óä'3i(Ðñ4ôKÑŒè%VÏ…~ë¡uÛCI¸ÑAê¢ hC?úQoIt Ü bIg˜Ï„¡ëè¡(S‡†T…PèD+vÊ Vì´�X‚MÃEÇgÒÀHh�@$�Pu¥+uPœ˜ZÕ¤Å�@1ªÕ® “ç¢çE‡šRŠñ©ž0­èHÇJV—‚”«ô<©EjÔ¬²«e½ëUçjW¯Þô­1ýª_ÿI¹¢]Ý¥aúÏ­âU¬ ¬`ÛÊW½þµ±xuë3Ú×Ìz–® ­,V!‹Yh†•¯5µéJºÚÒ^6³¤�þæ ;ÖÉÎÓ±&¥�QÚÏ.¨â§Àý)*¶ Z”èö¸Z½ÁR™ ÕæJU:ð� Þ9Ù]îÁ©X…'ôPZäê–¬ßE.4©K^òbö»ãýjxÑ{\õö³½C¥îyåËÞùŽ·±ëµ/~Å»Þ÷²×½øU-ï^ðî—ÀöT/�l^Öš·½vh�'ŒàþN˜¾–,†më_ù2ÀFpzGìaø–½ó…0o ÌZçwÃfl‰ï{ÞïwÆý1ƒ½ë_ÕV¸Çéõ.‰y\ß K8Ã?v톗aŠCâ5HC'B�A|º±ià�KÝéS÷­ø³™)]éb¯ú×ŸŽ¶†¡YŠ.lyÈ,æt¨5 kQcÛ›Žu„%�hH›ÜÒæv¸'M_GøËçNw³c,oK×{Ü�^´¾¥­ÓVp¢o~'zÚ }×ÀÎ\fŠP„æ�S˜*t±õœfšÐþ.õ½7ÎñŽ{üã ¹ÈþGNò’›ü¸Š8Ä.Ÿ´a×Žîµ¼Û l“?á º=+4þi!(¦…µ7>ÞJøA·>ENNƒRx�éP�ºÔ§Nõª[½ä_HCº»ðÓJРnÞi#jðV°bè€Fø“wÀp‡#�PðtÃZi=¼¸z¸ºÞ÷Î÷¾ûýï÷þ„ެëJ+Â×ÒÎuÍK¾­Ó@ç<¿´ú ¤A‘¯´Ñ‡ã‰@@Ý逽èGOú©ka Žöêåý…�~AÚˆ€ó"¾.ö4Έ¨´qKP‚ã–? 'BZ‰@ ¡:èÁxìÍÞÓÀù4ðÄÝê è;ßúÏ¿>ôþM‚ãvÿù¾×-ïkÀûì‡_·Ó5ùÅß|ñûþúç¿öÁï~c¿þôoï÷÷¯ÿó§ÿýá·}ò×^éÇñW~ä7ô'€ùç|ßhø}÷7~ë—ßW€íe}¿·�&p•–€P ¨ ¯× |p\�àÐç«€ £�‡gy0'Ÿ€ ¡@\4ƒ– © ¯§[m0 « Jlã±³}£À]â'¦Ð¥€ Šà[€ ãÕ¨P-X bP¬�QX •WOÀ ¨` GGJP {` ¨P >a=À G@"кõ€yØ^ŽàdGÇåsn˜Ï!¥ þ¦  bxT�à†œ4‹€ ¨€qH£°¿G§€ ‡påÇ€ëç�è}{Hz¨bõWé7�Xÿ‡½×�h~¸Xi‹ (~¯x~ŒÇ5]Œ´è{äG~ÀÈŠùWix€ÌèŠxĸ«ðƒô÷©°z·øŒ4 �ë” b`ç' ÁU ¶7v·\¢@ŽÏ§v½7~>`JÃ÷\DÐKÀãaûŠ4� ÀusÀ• ݘ‹ºxŠŸæ�ñwŒÍ€ð�è~yiHŒ è�Ñ–ŠÍøŒ ù‹ �¼È€ˆ‹£hw˜�ø§Œà'’ö€Û'€ þ}øs-—« i ºU«ð 㘃žWxA€ ã¨��ˆžW…OP[� 8‡…ĵ–'l‡ðéÕ{‰À�4à…‡0œ r£”‰Pѧu5P­€ãQ €è¨�5p¢p‚BÐ ‰`•`íåˆàu I€€V ЊÐ^œ`ÓÑŒPBÀ ‰Ð–€‹08WœP‹Ùºz‚Ð ?pœ0äÈ‘$©€òƨb¬(’‰+i¨Yo»˜n°Èš‘/€°¹›Ç¨�·‰‘èŽ1‘ é›Íø«€vßx� €b° A�Û4ŠÛ�Àµ œþIë8�4`‹õ‡ñ8žäÇÂW�Gmšúø�È{·�;%�?• ð™}¯Š¡Ø}ü‰�¨øŸÀŠ Ÿ¼×Ÿ{H ¼g‘ø™�8žówŸªŸþXœ¯éŸ¨�»¸Ÿ±¹’ªŸ©¨‡$0¡ ¡ú|ĸ€ ùŸ'   ¡Ù× å÷¡¨Ÿ#ê ëןý‰¹§[œÀ]¨€s€àJ@©[‰0”»–Ÿp\¢ uŠày‰ˆº¥€¸€Ø}%l‚À ãU¢ôw—•G]` 4àŽVPI� u%p—E [{àuJz\z°¤`Z$§ºõ¿õ 8§b Ú’ÿY þ@7{ºu—>Ð{JÀ > suH{À %0¥€�w9^JÐ qX ¨W©º5£ ðy¢.J¡�é}»h {(�Ú¡-ª~ «±9‘-:¤ði $�«î·XZ«¶«4Z¢û©�J¢ÆJª:«Í¨šy8žÿ¢ù«ÅZ ÿ ~Êùƒà(ªj~]°œÜºœ¦Ú{òÉSÛÙ�õùª¼'ž­Z;0U?�žm ¼Wqýi�% }Ó× Õ§¬ÔZ£Õú¯ºµ¢J£$YZ’ ¨}% «Ý¸­ Y­º*­ j‡úǰZ±²Z¬°Š¥{¡û·°±¸~ Ê¡ȱ6ú|zx’ªª°°Êþ‘h±ë°‹Ÿµ™ƒRJ‹4@ˆP£`m«ÐS8”݇‚�k»«tn˜wGJ—Àß'‡y yw\i@‰gš‹÷y¨;×}O°s>  8¹¤4°¦½÷˜ÿ”—@€Àÿ¹¥@j „m‹\b€ Ty\ªÀ AÛSfgv=ű4àˆà„W+¨€\tx©»Ä¶ºÕœpˆ$ Ù}5Ð BPœJ¬p  { ¥ #Z£ãù}+걨jŠy8°ïy²:¤¯ ¬­jºêǺ¦Ø{„›°Îê¥-Ûµ-{ ½k� ;»ïé¯ °!:±.)¬¿:±¸ªÝºN׫¢þº ÖˆvΩà2kŽÀ¥Žœi®­ÐŽ«®$Z'wA@:�«#@0Z¢üÙùÚ z`¬ ŠŸôû¬‹£þû¬ú£þ ÀÆŠ«¨8ÀŒÀСý©²«ü À½*Á Œ Ì¢zÁºË¿úû¿¼ªõÊÁÔ À |Ÿ ÌÁ!Lˆp˜ºõ y·fOp É{D[O*ƒ.'lø´TúŸŒ $`¥Ïš¡�tG§Àw9gbj ݧf*µg›¼w—ÿ©m;§Ý§�@·¬ðŸ4±4ð m�Áì¿Z K§«€sˆŠŠ‹ZIðÅÝl¾÷Ã¥s—ÇMש¨G»þ$¬¿±( ÂÜÁ)ÈÉŸŠ|Á'Ü«‡œ¬¬ÈÜÈ€\É–L»gü±$ÊÀ™ ²&Ìu¬ðˆ¬À§Ç¿XºR ¿‡¾¶'vÜ \ˆ�Èð‰¾$º¾>ðï;ñÛË ¼ ßwqo&l|ÂÂ+Ì«ì¡%¼ÂªÀõJË�̬ºZÁýÛ}"€«…¼}8zÌúKËμ¼Ìü͘ÜÉ«ZÀ8ʪ$œÌªÀìÌŠ€ -�az œ@ÐZÍEJOJ…—rIPMJI[` 8W…s–·nìÁVK� «І ‰ðy—lYŸÀ—ý ©‡$àøLVŒŠv Ð$=þ m@IÐ yØm«aúª€sfŒ�â\F§[~0 Жãa˜$½s&Í $w%�tÖ–˜ãÁ‡IœZŸIGµ ûɺ‹ºÚLÀÑ[ÍÙW¯å|À <ÂX�Á" ÊåœÁß|ÌÖÜÉÐ<ÁeÍÖÊÛÖ…ìÕÐL¢Í*“ºµNÔ“k�ÁtM ^ÐuÜéSµWv­`Ñ‘¬® œ«¼÷®Ñ];ÀË"0ÙÌËŽ]Í!êØBp „öˆŽ}«¢½ÙšÂ¹:Á^­Ù ›ÏW£%œÚ„\ ùܺ±}Õ¬mÛ¸=ÂXºÙ�­¢Ëɼ½°ªmÌ¡ÝÚ²�ÁÂý¬Õ,Ü™mÚÂ-ÚÏMÚÐþýÛ°]Ý¥�ÈÒ�νzÚϼÁ�ýÜÑ}¡w¥"(tX:­ £]DËÂBl¹Z†©€ —à,ìyþ< ¨ð ¨×}{ „¢€“FMPhߎݤ*«€ˆ¨�—§- ŠpÚR0 §0V\Ç5÷�à‚RK·.�á®ßªð ?XÝÔÍÛ!ªÆ§Í˜‘¸©PkxzáU™¥¥ º��«>0‰¦`‰ Õ«'¥� lXÜà½ÁF^Úº{‡§ ÜÏýÛ­ÛÝ¡åÅýËKÞ¬¦íÜë�ÛFžÝp½¼Í=Ö�,ÍÆÝÚŒ<æù|åcŽÜÐ�åÆ}æ'ÞÝZîäaŽäØݼ­ºª}ÜH¾çþdîÝ¢M÷ooV �>Öc.žbÙ8`G X–M˜¿¨-À!ÚØ• ž uÛýÝØÝØÐŠç“¾ÚKîÝ^êx޲WÎÝÏ,ÝjØÝt~±ÙLÛÉ=ÛU¾ÛlNäDÊ©mÖ[ÎçÛ}«žNÍ¢Þͺ®åµÞëÂŽ²mÞæ›^­ xÖiP}´¼Ù¦HÁÅ~êž|«4‹ �©ºÿù³«]×·þ¯¼Šíÿkâp-순îé¾ÜŸ>ÉÃÎîzîçëmí ;믾ÜÌMÛw.Íœ~Ýçîé|^äiÞÛ¶~ÚY­ÝßYí¤íÜØÓìÖ?åG~ÝܾÚNŽìª>ê�ÜÝüÚ¨�ÜþðG^æ{NEÀf÷Û ¨¦�èÌÝÀ±S,À{¼œó"PÙý¾äÜíóàÝÜÁ­ðD¯íš}ây>óB¯ô>�ÚÍÍÜv.ÛRþó]nçMßô'¾°&põªMõNÿð(Îõ›ñÏõR/öï~õY_ö^ïó",ôg>ó¶½ÙO«Ù>PmàžÍj¿÷G/ö_ßå`ߦtì󰤿]ïõH�ömßø·îôÊÝøiësõrÿô’ïö@ÿïÕ­«JNê™ õ¦®ù�ÞôAõ¶íä€�øYmõ`Oô–?ê›�âR/ú¢�øD/ôkö ßó�¿úa?õFïö’÷pŸûøîÕ–kØw‡ Rþ°ïD�èS_!PÙ•ý$ÀÜÏý°³NúâOû¡�üÁï÷�ÿê·û=ê?õ˜ßû£¾øQ>óîŽúöÿû�?þd®îb¿þA‚D‰ 4(�B "\è°!Ã… ZLQ£@$:LˆäÈ�¢x J‰7¾|s¢Â˜2,XP+‡ ¿˜útDæÍŒ0eÒ¤ÙóèÄ–3�.5ºTéÜ"/âhõéV¢& n¥ŠpH¢G›"-{5+B­lݾÅi‘è‡V‰m*.ÉœCû†¬yT®È¶Q×òÅj)X½‡oFX„S+Ê•)sª–¨+Î�=þZ´ç£c=Žè8¢ Ö"B þøUölÚµm߯�[÷nÚ¦yÿ\8TßÃq7ž\ùr™È™?‡]útêÃcGßbHTªT¡m9+sôxò㫟GŸ^ýzö·¯·‡_þ|úõíßÇ¿ü~þ®òË~ï?$°@œ-@d°A ®? GsP=+Ä0· yÛ0Cêœó0DG$ÑÀ Oüì7K„oÅÿ:d1Fg”Fm³ñFQäÑ? ÑEé„|ŽH �D2I%uìÅ%Åz2JÛ@̱Ä*¥Ä2K-¯<�Ë&OÔ2ÌÚŒ\�K1ÏD“63{[Ó#àÈL3º6qûþrBåæ ÏæäÄMA=©û³O ã$´P5ô¶:%” ÑD‘tTL8u¯ÒAiŒôÉI¡ü*Óí/CO•Òù<-ÕÔüR½39Vƒ{5JPù»mSUoÅÕ¾Xsåµ×]…›u¿^ýU× hëvµ½ŽC«íËü(A �J!Ù8?Xa¥•UT~¨’4hðA �¾¨�·VQ‚!QÒð¼ê¦fe•Q Û¶—•K�‡}^JˆJTIå“.–»¡þ¨‹“Î�F"$‘¥á™hà`úÒô*¯séµíhÐ’fz. NÎ�–iÏdz*%ˆß¡'V‰û<ÓRB”Èi›{è™=ZÝ8O©¡k!HpކR|X]DâÁ¬j|©“|�ß$5ÏWòO='�¶Gø`HˆV "�„4FA¥’ãw»�¢>LA¥­Õ焆ÉLÅuݺ0B¡ü ä Ÿ@(þHæ?D$ÁRÑ’MÅ�„X„;� S4Ѐœà ÓÀ 0 Š@÷°>T8â IÄ(LÁ‰÷�â �XE~çƒPœ,  àß7Sü-64 *PA<ìoz@…*ÞFSh�‰�*J‘„ì�~‡8DH †P‘7Šãˆq &0j�SŸ"’ØAïÕ‘av4Ž�¸‡ð…o6�èC‚Ð ‘yM ¨(!:qÆ–1€J AZÁÅ’ ­ð‡h€ ¯m’d•ô*œÖ…Tø`*£�)Bq3?,bN·+A@á´ b $‚(NVK. á‰êƒÙ!þ±IŠ�[`…#jPƒO’†£#žG‚QŒB ‡«[ :Ñ@¨ $ðÁûÔôBˆÂŠ '–Y‰Ø]Ò Ä4©éµD â Ç\E&·` H¦�z Á* À5qâ†hT!žçƒTŒ²”$¸ž?¹¨S8 �ÚÃÏÆ¶ÇÞüì9}ì mBP‰T˜Â¤P؉ÈH–BÄ­øÁnúP5Êü�rüÈÙJ�†N¬®þãM!ž@�Tò‡Å” Šcvb(¹üÚ¶ Vâa?숰šO $ Ÿf:’†R dQ#„!RÉÑí!uãDì" Ct7?ÔåÓ'þQ4„ï¼ 2Š•P ¦¸™‘z2Q ¨>óÃ*Lz S¨Âq_Eê-A³õ!DŒÈ•»D«*=‡£ðÑçbRÎÔ¦€ðA|P‚�ddMLÅ)t›Š%À¯‘©„[\õõ”?Ž£9ˆ¢’’5i*ĈXCøA  ècq¿4´=¦)¢·8�ìsiPâoyŠÞ  Â!:ñ‰Q`�°I!*�Ð[P- Xá?A4¸Š$€ãa½¦AXbH ô\e>ïÄ ©›ú xÁ ¶ØÓŸ®Œ]K>�ŒÀƤÛÂ)î\ƒ›Á¯¨³ÈOŠP‚.°" ç]$ PA¸ßøaZ.. ~€Š­•` a(¨¬P´´Ê%ðA#:1‚PÞ2—\ ¥1¯]À%¬â ç5s{Õü¼O¤AÈœP±œ!ÊÙåþÑàÎ$hCWuSƒ/�GèDìщãåÉ©ðBCøÐ ÔÍóŠ‘+…×x:&OP£´U”åŒ~L1�Ìl†X“mH£RÐG"Ò�u¢·ŸT.¥Ñ¶Çõe"•é�ƒzÓ c­§�îùÌUø“»òµ¿ý,1ÿøÜ¿�:¡yP8½LÃ/ÚT|èÕ¦JïÏž�ÌÿqÖÿr¹a>ø…ó}ý«ß£Æ'­ä뿣ȿ0á¿ÜT@í+@-¹>\À”À œ�Ü�ü´»‘ÀLÁÀ˜è@ AU±ÀÜ?4ÁDÁ �Š„ŽÙKA÷S�|Á¤AaAæ`ä�؃�AƒâžàQ¬Á"4Ba>PÂ%dÂ&tÂ'„Â(”Â)¤Â*´þÂ+ÄÂ,ÔÂ-äÂ. €.Ã0Ã1$Ã24Ã3DÃ4TÃ5dÃ6tÃ7„Ã8ŒÃåc-†XBÀCÐÃ=D ÐCÄ@Ä ÄB4Ä@ÄDTD>dD0ÄG\ˆDIT� ĈFôC?TÄNL@DÄJDÄ@œÄˆ€h€Tl€UTEÈÄT„E �€P€[ÄÅP]¤€^¤ðÅ^Ä€a$Æ F@ÆdŒ�d|ctFgÄ€f”Æi|� ÆkFÐÆˆèFn¤Æf„�fDÆHÆs Æt¤€pœÆ pÇwtG ¨b´Æ 0F ¨€nôF}äFÀÇ þ˜Ç H€ŒÆ‚„ÆÈ H…THntH‡üB9”ȉ¤ÈŠ´È‹ÄÈŒÔÈ�ä˜ð’:Dˆ;ÌÃFÜÃR,EH0„AÐ5ÐEÐ5CÈì#ɤÌ<”ÌÜC =I@tÉèÌhÍFtÍмPØ´Ä�ÉYìC hMQüDDd eQJ”D¡„QWD�Û¤ÉZÔÅÅѦ|Ê`ÆbÆ{Ôë”�eìKåœN®tΰOodRæ„€ 0„%Ì‚-ÈÓDH„Aþ &C%L=¨›PÂ@H=„µe Pþ|ƒÕO(WCøO „±5�0@è=ƒÀµZ¬ÕZ%Œ×%€0@ܪ½Ú¬ÝÚ®U¯•Â,W}%ÝþÒ5ÝÄäWÏqÐDeD •F�Ä ÕPÖTÔxT—ìT ч…XNlÍPäTØ DV¬MڔŎ=U ðM‘UJ’ýEuŒÕÆãœÊ¨Õ[½ÕpÄÆ\%Æ»´Ï#Rd,Y¶K·<Ö¹ä^küѯ4ÏmœÏ/MOklYé�OkµV4�=È‚©•ÂA×H„v ¸€Àq…S2€@„/Ô_0CàOÿ€H„ˆÌ‚:%à'Ôƒ- ^�²%& \þL 6=C�È‚×0ª­\%Ô.ø`Cá'¤``6`(ÜßÓ5â#Fb9Ü��8T€þØ�eXC¼]H¥]дPJ=ņµD>”Å>ÔTÖ¬ÄD¬ÐI¤DWŒÅÙÊ $ÊS]Uæ ÙñíJ÷TÆã<Ⱦ<ÒatOllV°$Ëp¬Îë<Πb%VòÇvtG ðYxT_õ Z&…ÎnH¾LH¬¤_ñOû%̯\@`=Ø`'6Õ­e‚7í=€SîO6M„vUB@ÐÏVBVBX´}W&`½å»Í‚?èƒ%ܺFØz-à`æQÆáɽáÈ[% f(„f(åUFeU&b{Mâpçq.TÞHÂe]–üÔ“¼P„åÃuÔ‚-MžÔÐQÜÄ{þîÃMíÄ0^Ÿ„€Ú”M�MÞT4Ê�õM\ÜQuÌclTÒê5Ë:^N?†îíÊfýF±dÎ_-GñõÅ„6d›5ßc½Ö‘ndYåRH†0eÈ•VÚùNû¥Ë†DS% &Hses­é 6„ÈVB^Â@øÏÞ€`„%Üæ@Ø€/ÄåV~ Û@„AH%Dj»máÄ-à`æ@pfp5à�ÞZUîF² Îiš6a öi&èe',br¶ë»Æë'LÝ£ñW'F …b˜”Ý•ôD’„çmÑíÐM<ÕˆíDM½XàÝÉ%ãÙ„â%h�-ÊSEÊ�€}€þñS™}� ßë�NmìÎ$½èèìU* _N`4�&K¨€E†K�|OzTßÖμ´€g�ß«üÑ¿´ß„¼üýÜà¶nÂlÞfè„ XB!°WæáX¦Ú?ÐOîî᡾S_NÎàv¥á\f¯×@H€ ƒ&åÉ]ï\>ï'ìeé¶Û>aì]pÎë'ð#VAÚHB(øèÍgJg€T�ð˜ð˜pÈð ÇpÀðT ñ ñ7ñV„hÅo€ 'q çp7ñïð�ñ 'ñqàñ'€àqr!çq7r÷ñ$'þ€&ìå´tò'Vµ”ò(ò*wòANK@bUduÌíVd,ä-7-¯r,·ò´Ô+È*xó�ó9§ó@€(s-?€-×r3Ïó2¿ò1¿r)ïó>t+7à‚¨nlî�ê‚´fÂ(æ¡6W\~à6ÛL‡å$€e `($ƒ,�žÛUfa¸iàF˜W±N˜ïAèƒè€QÜW^ÂR]ïa©Öj\‡t_ o~Bݹ.ðh—vBÝëŽqPÀv×ö¤ñnwq ÏðnñÏ&÷T¥ñoEgñ0wp‡ñs7q�ñ þÏðÇð/{·w Orò#?r%'€àq%|×w óµó0—ò†OóaMË_ ôñíÅ/¯x‡�r>Ïòˆø7OË·ÈwŒó4¯ó:ßó>?y�ÿs3'sŽ7s,×x>'ó˜7€,H[«­Z&ðÛ%LF€nÐC�`ˆÓ[Æîp[[îj£zDHÿŽBç®S=X®¥Ú>XÛVÇ‚¯væ[×yC(oQ÷uè[D zÊE„;]ë�g[Ÿ—B `„¹g­žö»Çû­v„±C%Ävßö×Å ?ʸð'wqßqx'ñu_wzÿvzw|—|§÷{7÷ÏÎwþÏ÷Ïîq~÷qÃx �/xƒgr-ó†o}aEs'÷ø4ýñUäÜVä._øˆ�}2�ý´œ}'?ß ù‘OK(ù9§y?—y@÷s*ŸrŽÏs?¯r<Çáònk'DöA½n2¼n9oÂíÏûñ'ÿAÝû{é{øûÀp'}qww×ÿç|Ư|ÈgñÉG|Ç—÷y§üˆ$@°�„ °P!ƒˆ'*lX‘€ŒlÔˆ� B )’¤É’*Wš™R$̘2g†t‰ò¤� s²ìy¡B… ?�þìY2€�J—2婳äG§:Ÿ®´9Ò¥Ë�QwFÍþf„ưu  G³jײmëö­[²páÊ‹v.Þ¼z÷òíë÷/àÀ‚.lø°ZŠ3nìx±«È’'S®lùòdÆYp~àù3è¢E3]€Á@Ô  p´k× ¦Øà6îÜ¥U¯nMû·ÄÓ¬yÓ~-ðbAƒ `˜p"t+ PË1@T•WoÖ̉©Ñ’VoÎŒ©Af çÅcµÉ5¼O òç'ŸT%ÓüH§Bõ.Õ{(U¥Uý�4× ˆ5H]ˆ9ÈVƒ€%¸`„Zx!†j¸!‡z˜Ñc!Šˆ‰%’¨YFœ±‹�&š¤7£qˆ&Ûlþ¿Ù–�7®ÆšŽAªäi­ùv�A%¤¤’Ó=]DXädF׉V/m'|,mYSyaÆÄåK+aÙ¥Jò ”¬„œõé§ÔSZý÷ÞGYj©Wu®ô! � :(¡…z(¢Џhc&:ú¨+(&:)¥•Zz)¦™jº)§�zú©YŒŠJ¤¥b&©Y�”%–Œ$°W«Õ�:+­µÚz+®¹êº+¯‰�º¨©ÁR¦™Œ{,n_ÁÑ­&ô¡½Z{-¶Ùj»-·¶þ ¬°á¢¨T"(€ªÝêÕ� aT«.¼ñÊ;/½õÒûíˆá ‹j¹ÂÄ †0{Öþ ®j´€‰$Â[=üjFbÃk ’E€ô�Ñ}$ðÆÖF'BFF „‘È &DœðÂ/'B±[Y¼k/Î9ë¼3Ï=+Šïcúî»XXýf4Œ�´(1ad±nìÕÔ¬ªÖ a�@"¯v Ã¼ªÇÔ&R}$€ ¡À†,Y¯š7Z×|³Ï� >8á…ó tc"�*´©ünɉÜ�#²âMÔm]�w™sM²o ˆå±f4r²$§\¹WÌr�yX\t€~;î¹ë¾û¦ˆ;ÆxãDkdt²«NH9ÔpÁàÆš'Ïþ…!ƒ4Ïõªa0ÜÁ a-ÀˆFz0«Èä3È ä'Ñ7ôÒg¤|ôÓ¯e3ïñË??ýõæ{£ÀC긿«Ú=|ø4b‚D¸%lf1" ä2xÈžFLÔ Bu_#àZ( ¶~aišýB(Â’w¿R\ˆô·?áe„xé€!”‘>,#xÕ á@Ôð†6Ô¡Åú € {béCÚÁ6· n ÌBuFp·D‚>Ìa[°.XnŒp_ Ã(Æ1’1[øcŒ Å¿£ù/|0D"6Æ8bkkÉ!"äHG .hކX µ—0Ô•l‰ÐG8êþánsL!IHµáXŒXÝÀXÆMr²“ž¬Ô!“F¡ 0 Œ²,uÊO²²•®|¥ƒB©˜Q’’…”J%,s©Ë]ò’V²\-OeËIá²—Æ<&2“ù¡_óDÃT&4£)Íib‹™Í¼Œf,‰,jr³›Þü&¢¬yÍÊdS,Ü':Ó©ÎuÆ1(üÖ8-Sΰœ“�ö¼'>ó qÆS2óÔH=õ)Ð�4™üìg¤ˆÐ´ }(D9yÐ~þ“r½(F3j¿‰Æ³¢h¨FC*ґãô(HIªÒ•²´šøC!B3³†š³¥6½)N½%˘úS¡5Í)Pƒ*ÔI™ôþš¥éP“ªÔ¥j(DïO#ƒR¦RµªVLQ›9Õ«rµ«^ ÕN£šMm¦ô«f=«P³Ìq¡µ­nUªZiYÊ·Òµ®,�ë(çj×½òõ¢xM£^û*ØÁæó¯* ,a«Øoâë©¢ä)b+ÙÉ4¬�}&e3«Ù]V‘Ý,hC[ÆÎï³¢=-j7jÙ˜š6µ®}­ W‹ÐÖ¶¶¶=œ;ñÕ„*æ¶¾ý­àHË8Ú·¸Æ½–p…FÜã2·¹µJ®¾–ëÜéR7SÐf««ÝíZW¶Í.wÃ+^C]wh½/zÓK¨òKºê}/|±êÝŽ‚7¾ö½ï_ؼóâ·¿þþí‹~KåÞÿ¿^! ¬à‡åÀj¬/ƒ#l_;jÀ¾pu)\Ëc¸ÃðÕp‰,ìá÷¥�e-„I¬âæž0_—åðŠc<];Æ2¾±qi,Lã¸Ç·Õ16Sìã!‹Èò2‘“œY#“ÉJ~²b™<,'C¹Ê}•²Lylå-Ë=Õ2—üW/K•Êb>³WÉÌ[ ¹Í|U³ˆÝ,ç ÂÙÌs¾3�ç{R;ã¹Ï6}Œh�8£Ì~>4PC´…>ðA ‚v,ÅÊgDSZ£�Ñ 2ý‡-ÐÒ'ží¤+-jˆ>Æ™Äü OÏRÒ†5¬-í˜?œÕ�þ&4¨_ë]“Ú1§¦5 �ëïêš×ÆècPýkFí6ÎÇ~6c}]k@0ÛÕl†6¶šìi;µÕ/¾v¶Ã-Ðm׺Úß7º +í_ ;…ÖN7¼Ù¹m`[qžnv¨ã­oX’ûÔíÚ»÷-pnö;Ó$`50 î�3\™É¦w½‡Mßb7¼âŸ,8µÁun‹sœ—7·Â;.ò\>¼ÜùÈSÎÊyâ� � Ý½q•Ó|“ýn9 B ñ=S¼æ>çÝcèý‡?„ æŸùÏ“.ÂygÚ9ß¹Qó­ô©—Ô1-wy¦au­J�ê^¯—;]>tZ¯zëkíú×Ó¯ÆtúêW¿þµÌQ®ö¹Ž1«¦�˯ñ¸çzátÿ{Ï0¾êGé€?|Î0~ë{ñŽ·WЧ]v¾Ûï�¿¼º>nt»'¼ï˜ÿüÚ¥-ö½]î ?½K3mÒÿ®ñ¨=¯"¯é—o>†‡=îuªze·|ò¥÷|{Æ@<ãf—+Ú…¯üeΚÛ'þò£ß»æ+Ûø”Ÿ¸å¥¯}¢:†9ô·/þD-&Ð�;°}ßúÛ�¿ý‚*ÿàW÷ßWÞýö_¯ÝE€ê?Âàϯÿý ó-Ìí°ÜñåUò Î…à!¾õ\N €­›¿Õ^á™n`aÈž¿A ëq †æþ% `-àr Ë‘²_ ¾ ^~  æ…ºÜü­ŸÞ ²Óõ^ öàaƒY ÿ™àa¡ º_É�øÕ`Ná>ù­àÿanáÇe!ÏeßÞ ü­Úê!\çIaþ üuZõ%áõ}a6¡b˜_Ñ]a ¸!ç�¡Æa*F§ ÜaõEáò¡] ´\Ë"â!Z¡–ý¢#Â`²µ ,¢F�V¢Z¡Þ1"'‚¡'RàÃ)¢õMb#–b "âè‰"×u"+nßc`âÐ,ž�,΢ô!"ªi¢zòâéÕ¢ØãŽ"1Š`ä½âþ&Æ").£ý£"þA."ß.Jcð¹â/^£f£6â5Ò.>£.Fc8Š_3Þ¡5š#6¢c:jß8ºœ7ž 8Æc1Î&¢Z=.á=âãç=¡Ë�úÑ %dûÝÜäœù9VÂ#B _¿ Ì™_í=dDN#õÛžíaFFßâ!†_HÊ#¦bI¢áIŠ$õA¡;~#D¶$êua0–Ö0Ò$Õy`¦™!Fêd/ZÝè©äGš$Pn£PbQf Q¥8n$I%K:%ì=FÆœô£0þ#UzÝcl�¦Âh%NreWN�¢ñ�lA �åpåäYªœï<ÕOÆeUêþ™2Úå]†R]êe>š¡[*\ú%ÇÕ™Y¦\úaA¦®"bŠÑ*ÕVùa á©â(ŠA(µå/m&gv¦g~&h†¦hŽ&i–¦i~‹h�j®&k®æLÁlÁk¶¦jʦ°æÜæmæfmîænÒfoJ�mææp§p §kJ�pçr"gm§ðæj"'qâæq²¦oFgoʦuþ¦jN�v¾&lB§k’§uFçonmæærÂflVgt§rʧz2gq's6'{Úæ|§}gs§z¢'oÖ'm‚'kŽgxêg~Vg}úgƒ6§„Ê'„(~ú§ètR¨ƒ¨~fè…þz¨mš§j:ç¼ÓCjA(1&i²èi¾(ŒÆ¨ŒÎ(�Öh(I�„€Žî(�îhŽúh�©�)‘é|€Ž"i�*)‘2i‘>)”F©“Fiüè�R)–Béüè”i“véŽzi–Ž)™é•êhŽ2é”.éš–i–Šé˜Â©�¶i’éœêœA~¡Š†H ˜¡h¦�ª *¡ž‘‹Žæ¡Šf¢–¦Щ›>*¤ji¤N*¥Vª¥^*¦fª¦n*§vª�æé(î)¤ýi¡–ª©ž*ª"jª®ê¨H�®š§Æª¬Î*­Öª­Þ*®æê¤]Š:U ”@ °€b´% «Gn梶þh&ë¨0ë :+«F«´žÑœ)� kŒ@•ª®v«·~+¸nª£Êê¸öh¹Æê¹’)’~SVÞž6Æð´Á¼ÖkÜ+¾zÁ´î+¿"´v[¿l JÁ�ŽAø¾&콪Á½rA¸>,ÄF¬Äzë–RiÅêêÅjj»bŸŒ€cL„,A�ÉþÊþ€�À¶¬Ë¾ì\Î Ì*+g¬�‚¬È"�ÉA lÁÄ-Ñ­Ñ-ÒN^¯:†È�ì,¤¬Êú€̬Õ^-Öþ«¿bmcØl�Š€ÈB­É�¤,Õ¦+ÒªkÚ®-Û¶mÑòªµ½+c„þlÔíΚ­è@T-×ö­¨x,Ìʬß.£LÁ�†,èìɦlÞúÀ ø€­¢­Û⪜N®å^®Æ‚*4Êíb �”lÔ lÞêÀ œn³ò«Öæ–©Ò@# (ˆÁ#”@°‚̦'|‹°¢Ò€(p®ê êêÊÒ¨–€á i(®ÉŠ.éæí ô@ `®ô–iH&TA8AÈ�©$h/•ÊÁ¼é˜bïôžï¥n¬ž>FâŽíLÁ" éâÔ@ .þ.F| Ô€¨èî>�cà®îòîgú@,Æ(8ZAŽJ )¤)ì âË ¯i:+ÿŠ«þ"/èþÀ/P­�kΡ¯ )LTiä÷©%€o”ÊÁd*Є€ù>ªà&`àÀ _®ú†*ûB-ÊB�!Œðè @oþî+àâ !’€´Âíæ.ë6ÆîâÏ;† Âb˜‚T毃óÔ6 \Â!Øo#,‚bŒÂ'Èv 6¶A)€3Cï+ó¨ÏãÒ5œ°l›F³5¤Jî£ZBà€ÄAÐ@¼pÐÁô("§pKƒÀ ;A&ðô`²XÂM»A쀜�$ähÈA ЀÏ�¨wó(ØA%ëhl„Xþà@"ë¨$D"Wò„WAì€ ‡Ió´hOO¸Q׆×éI78x#­[¿#\KméÁðm °€lOw´¦Á(ü²"Ô*Ì1 4B÷‚ ¥A'DöiK÷! 1îŠ@x‚þb¹"Ðóbtñ”‚0&),t;³‚<*tÚ%ì2!ô6 ˜B(†¬Bdtßo)Ä9 (�“*°‚)$0�Ok£ )v£,Õþ€ð�xì³ÀwÏøÐJB…‡@ lœÁJ‹€ŠÃ÷&¤ð÷no÷Êôô&q¨wøŽÖ0|ú"»Át/”VÁZë¨ÐAàA;�%¬Ú©Kx­‡€þä°‚ÿ(”:­2œ’ž�&lB¤°¤-»jî[ZÔ–®ÁŽ“€ÇŽÀ¯®hhúõŽ»,��n+Æ)èù ³juïhv³Àt»·“ üêÑŠ7Û†ºŽÖðhptÁ&l&�«‡@¨Ï°L[‚%`B§‡z§ëhTI÷¨ ±Œ7釀`rPBÄÁ{‹À¯û0&ëhto˜þUË�ÙïhÚó( ä´µN»ÄÖ¸LÂuÞFq �A£û8ôÊj”°Â8 �›n[ù½ó®1×Þ¿§�p“Àäî–“@[Ž@ÿ@"<Âcl^ ŒB´e °Â8³#ð#Z Bï)Çïî ȹíÖ󯛼·+!¼g’{cmËë(鿀ø@4º·þbØýÄJ‚Q�8¦@œ�Lÿ•Æw'}÷Ò�X)‡k/¯º´ú™ÊÁð€&8�!iL‚’¾®#ý‰W醿=Ú�º¬·w÷n=@ÔY‚ A›xT¸�aC‡!F”8‘bE‹Id$áþŠcG�A†éQ# -#Jfü¡ƒ¥7HŒ!bÄ1SæÔ¹“gN=�:”hÑŸ9Ó´JS¢ª0+u"„2%!B$h¤éDB+$:ªÁ5 ­$| CCˆ¨4$¾ zRBH !­JÔÕ'åž=)K�òA⨯5L}Ú‚µO§¯$FiÁª¤UFNbo0J”±Ôb®••0¡AC ©¿E…RE½šuk×�ö¤!ÅáJ>|Ü`A‚&U”#.>œxqã!$åÙ"NžœäÁDƒ!�M4D wBŽ›N0%‘Pû7ÊEœ‘43Ïœ™ ç¸QEõ‚8äàPXeþš<0Á‚ 3òpŽ M¢ ¨ C¨Ã èÂûN“„¨`ÐÁ° Ž70É>øPÄ ñÃ’FJQÅK’,'Ü|¨á†j A&™2Âñµ’bÛ1¥}ÔÈ yC-�K A¥?4ƒ!t²ê«#¹ò -ERI”'Ìâ$£'8IE”=JÈh�RT%ˆ *#a‰T¤( Dã#”T*â±CLẏKP)2ÏîÊÈFP1E,ζ@©+žð$•UDa’ÈK1ÍTÓ’žpÈnp©†Lø 6ILUÕU‹“äØy8§}Vê¨Yí÷ß«G 8§ç:ø`„8ààÈö`ƒ ¡éÁÃæ€ì³Ïö€êůhè…Îkü¢þ¡�8@�\óÍ™ŽÚj¬A'éå #H–{¢IoÔçV�µ£&æ±Té»ã�Ñ2¥”.lÿ±÷߆¢¦9/Þø…'( Lb=¢ z;HpÞzЦ¾¾ ÏC]kÔX^üñÇ7™üóÙ_SáµÏ^{ëm}_þùé/Žûî±þýýùïßµÕç:¾QE}DêTý˜@.��×»þ6ºÿMp'¤àûfÁÞi°elàABŽð"„àŠôg¾ rÐetá ÇB¾±�}$´á q˜Cù™ð„,ÒÈ~þ©pmDÔ‰�¸?Ž%‰$XbÆ2r3�<ñRTäþ‰ûöD,jäKXÈà¦F¼ç€Ñ}€ œàt˜F5®1„!êQA—’'h�Žu´ãñ˜G:Ja zôãHA’�…4ä!ûxHEêq ‰ dêI-8ÒŽ’¼£%/‰I=j“YÐd%óøÉCŠr“‹4% 9H%Äo&�kªf"¸ø1ÍV�#q™K]nÎi�K ó'”Ñe>)&ODpÌ”(³$Ì$Š35²Å–•©&A)U5©X&)êä+ëÉ6wRirŒc¥Ê 8£Ŝؤš®1§kJp!©‰CÁNêæ‰)æÓ'Ú„�4ùÉÍ B³oÀ¼ZI�P….`èþCúP‰N”¢µhEÑ‹n”£õhB3ÊÑ£ý¨BMzÒ�’´£©JQÊR˜Î”¦5µéMqšS�î”§=õéO�T¡§>2h3’Ð,µpêS¡©F˜¤Š‚ luZåêWÁ:�ˆU¬cÅêX½š°n`ªf½êU¡׫Nõ¬R ë]ïê½î•¯Ð+ðT@@…5ìa�XÅ.ö¸Àc!ÙÇf`²È€0›YÍZ6²'plc5k d6²”�,i1 Z d€µ­u-F [ $@¶Ÿ•lc€€ €ºí-$kZʺֵ¸ìþ`+Úä²�¬h3;ÚÌ—³ÃiJ‰š]ín—»ÝõîwÁ^ñÕ¨GE¡F”ÊÔ¸bõ©m¥«]ñºÕ±’u¬(ëX=pW¯N@­\µëáÊ^§R•½SeëY!ðÕù’5¾àëƒÿ׿– Xì… Yçvö'°, ÚÐZÖ´6­ˆ7ëaÉzÅ©%.qaë[ÙÂ6ÄŽµñx+ÛÜÎ6·}ìŒäÃ6Ð�­m/âÓ¢x´Ã­îP:^)O™ÊU¶ò•±œe¡–×¼>Lj˜ÚÔõBµ¬÷…oWï:Öù®™¾ Nð™·Ú߮ΫòUs�£J`ª¶5ÁñõoX!þÜW§–ÐN­0b1¬Ø lø´*æìd)kãÔJÚ´·½l‹?[âÓ‚–Ó˜}ñk@ÛÙŽºÇðñf«[Ýî¶&ö1q…œ�ÚÖ–¶J.íc-PdÍ&—ÄON¨L€S™ ÐrEaˆA›ÙÍvö³¡­].wY$³zÇ<à=×Î~Æë‚û­îWÁÿmk€ÛÛ^³¸Î ööW½W¿Ö¯‚=´a�Xà×Hf-d�i¸ßÒ�îjW\cÕjöÓ­�ñÃÀïÛòvÇ¿Íí©!ÝðÓÖÈŒ¾5f ŽÙ ÜÉ–€ †`×\Xè 8 R."€Xyw7°þl¢Î<}Ø@´�t¡�§Ó¦6H¬ælk[ªoÞóWÉ­UûÒw«°ï%ûÊ™«(ÐêTËšu³ 8ÝM—ê�»­à¯N�«ñŽ0T#|ïÂæ;±!¶û£+ËÙ‘·ØÖ¶îw‹ï¾iI3¼áر¨i‹\‰7öǧxowY”Ö0®¬c;jèb´±­tëäá&AzHBlš—+´Ø*íÀ`E$@§Ø¥hγ»… $€ :'úï�|á+ÔèG]R ô¥sÛ½Þ¾úÚ³ng±f�íi–¯ÀNà²ÇuÏlívõ¹ þ ¸=Âo÷«Ü-Lw ãºÒ–7u¥5ûyþÒªØï—üj}bÔfö…—µr޹N�Ç �ÕÐÇb�¸” ôdëÖjÌàäÏÅœÌá²€ö(j² ¡` A¡²€ r.a�¤¡÷jôa*Ho\p¡6@†m¡ ”-Ø`ŒM !æ’`N0ØLàú@€@û<0¡pO¡€ N°2p­ðC0¡FТ€0pøÔp ÙËVHŒ¯ÚЫVÛ²íª¼Ïì¬ ¬öËꬎ¾Ä.úæÊêL­úË«ÌMª²Žûò¬¬ê*Á8 ê¬ŽÜüp¿Ì€ lÐèþÍÞän±à¯±Îï0ÀÖD¬äà’ þ˜Ì±T¬Ò”lÒBËÿ�/Nð«·vÌ·x ï‚+^ eíóŽŒÈëok×*àÈ(KɆK˜€  ¢� ` �ö�§ÐØ@õ*È : J²àäAQoÚ1¡˜ÀÁ%J¸ ´±G É€ ^P  !öô¡€`S 00 Íåöq¢®1·‘ؼQ +j ÁöÚ0$Er$ÉK(X'CÂÚ\@½à©€ÌÞ ìÞíô¾ÄŽÜªùQϱéÀ& ·Êªþˆ²í0QLÂ4ÑÐ>Q± Àë±* ð.²0`òöÏVQÉ@Ì< öÅ|LÅFŒðZë^+sÌÆî±/ÇV ²L 6`¸,O-Yëòd‹ãŽŒ*«òLíï¸2-ÄNÑÉàAìñ¡ÁõÀ§:À÷Ä‘õV` j52¡ ¡@ ÷lï  ¡Âà 7°jôaLõans Wn2±Ð÷Î�¥¸ !S2=ðôÄТ I²9�ó9sªøPÒÚV :W ¼;±ÓëP€T Tà;Ãó;S <Íó<ù̽Գ­Z`TÀþ:ás<ås< Ѿü0ëÀÓ;çS²jªP ÖsϤ* ´@`?åS àôT€uL¶4à*Àï,4` 8TC;t/O1D‡QC´D_P4NtC_@b@\F_4N@EQôF94ÈJ4DpÆfàG�4H-T‡”*£¢2I£rÓ’ñBw4ÉNñB¥4J“Œ*Aìó Ô+“Ê€ ˜ bN¢82 ’ «ó=ÝAßó>§Ïu5<Õ“Vauª Ô@U ”A•µ Ç(À,޹0 0­´C9tC³uG¹5K±òn^@Cµ•Cc[]”\Å5\OqD_`‡±Ge+Héuª²Z‰”H•I�Ô¨R²œ4D³ÔD§ÔUQÉ4àI-�K/ÐK' PÍ ú‘4 åƱMà 14[èton¡ò³ #-u¤¡v3À°qzó qϡҥʤdö×®°PÎb%Š þÖñS‰¶h=5T��:q•;S•iÃ<Ës<]ÕU‡VWq5>çó:Ëó>µî>å3Wé3'i@%@´X t?Å–AÀ°Þ¶°t Z/®Z6É*[7toc€[“D«ôoÃUEUt\_4]ÍõEÃ2€]-Fr1à·(w^ëHóµH-TI“ô–²¬Õný6DÖn/+Eo£tG{@²`|Ö"õ@:À3/6Oª`S¡`N4®² L çt àx!r¢6•v«‘ ’ôJ36 ¸€l¢Pu3fgw�4à{]JhjHs‰sú@þ¼—4� R+* \N �¶~í7$‘öèF>™¶Óëž6€£vj_ÕjƒUàÓ=Ku½6‚¿›�WÀ£µîþ•‡Õ°�•—u¹A@B+¶N×n#K[ËU[3›·Ÿ´³aà™gÔFaø†Kôq!¨U›µ3Wœ•4²L7#w¦•q˜Ó9u¡”˜—”;Ã5|Ç�¹ ¡Y™ÃI¼ÄMüÄQ<ÅgÊÃ� ÄyÄU<Æe|Æi¼Æï—ÅáÈÅ_Nm¼Ç}üÇ�<È› Ç{HÇ_\È‘<É•|É™¼¨v%‘½vÜ‘›¼Ê­üʱɉü„ŒƳüËÁ<Ìż¬·‚ºœÊÇ<ÍÕ|ÍÙ|$ËΜÇÛ|Îé¼ÎíÜ ŸÊ��`ÊåüÎ=Ð}Ћ.Ïõœ#âœÐ}ѽÑ'êÍ»þ'Ñ}Ò)½ÒçÜ5ŽâÐ;B#‚ÍÏ-ýÓA=Ô«Ò½Gʧ\ÔQ=ÕU}ÆI=ŽL}ÇW=Öe}Ö‘»…~IÓ]á’i}×y½×oÜÐ]×}}؉½ØñØõ\Ø�}Ù™½ÙŸ­Õƒ)©œ}Ú©½Ú¥ Ú ½¬}Û¹½Û� Û‘ŠÏ½}ÜɽÜOJ'X×s]ÛͽÝÝýÝÜýEÙá½Þí}Úåý¼¤ýÞù½ß‹=ßU„Þý}à >ÕÞËĽà~á?ýೆÝ>â%žÐ^÷}â1>ã/Ù¡\à5þãAþÊ+>%!>äMþä•|ä£üâQ¾å]ÞÆUþ#<þåi¾æ9<æ÷Üæþu~ç7ç7½äy>è…>’}Ñ�~è‘>é9µè×�å•þé¡þØ_CÝg>ê­þꃮ5bƒê�ë½þë™meRBG¸ÞéÁþìўʘ¾êÓ¾íÝ>»Ö¾ëß~îéÞ§âÞìë>ïõÞ¦î>á÷þï_¥ú>ð ¿ð7jð ?ñŸø8~:å~ñ!¿îÅ~'ÊÞï#ÿòóñ1óé^ó9ÿóÓÞóAô½^ôIÿô¡ÞôQõ‡^õYÿõuÞõaö]^öiÿöCÞöq÷1^÷yÿ÷Þ÷�øý]ø‰ÿøëÝø‘ùÍÓ3¢ò™?ú‹¿ñEõñ¥ÿú½]ù±û›ÝÖyH­ŸûÅ¿þû©?iÃüџص?ýÙ_Ö׿ýáÕß?þéßÒç¿þñ¿Ñѽ( ?ÿý?Ö‚„À� HՅ :|‘!A+Z¼ˆ1£Æ�;zü2¤È‘$Kš<‰2¥Ê•,[º| 3¦Ì™4kÚ¼‰3§Î“{öL1¨Ðˆw=Š4©Ò¥L›:} 5ªÔ©T«f,(‚`VŸE ý ÖUQ«dËš=‹6­ÚµlÛº}{õà–>}¶p6/Ñ�pûúý 8°àÁ„ ¾(�b@v¹ê}ÜpìáÉ”+[¾Œ9³æ´ù,Äç.äÑbùn>�:µêÕ¬[SÆJã³bѤKv�;·îþݼ{ûiP„l@´kç½ý;¹òåÌ›;_{PñŸ@… qýúV¼Æ�›~>¼øñäË›Œˆútì4D°ßÞÝ»@óôëÛ¿�÷Áê€þ¬o/B€Årùˆ`‚ .hzŠQ÷^{ø•� ^ˆa†nÈ“AÒ-ÖÕvQXáwžˆbŠ*2è ˆ]DâP®Hc�6Þ¸[tüÍæXŒA͈c�BI¤`-Î&â„>Bd‘N> e”Oí'›€=-¹×|RnÉe—^ÖtuÔ)&á•X>Ôä—j®Éf›aú�>�‰¦‰mÞ‰gžRRÙŸœfÒ)‘�zJh¡)¾ùYq€*”¦þ¡Ž> )yûÅé✋.Ôh¤šnÊ©nÑ�©X’J^ši§¦žŠje:‚*ꈗ2*hª²ÎJ«‘íè'Œ¯–¦e­¾þ ¬Z|ò§è¢¥‹l²ÊÚÄg¥’ë²ÒNK­KG×*w¯[m·Þ~[Ñ@’°Vêºm´à®Ë®·�@ƒ@ùgiº½¶‹o¾ÓàuüQZ¦VÚB{¯¾\«¸×Ñ@©¹Ù’°+¯$ LqŨ¾çtç1· 2—TVáërÊ*oÙ¢s¼«Ç+ÏLó¡âúò¨Æ¢\sÏ>£xm±€ÊüsÑF×7lBÓIôÑN?í\Ð=Ú;1ÔV_�þ´‡²-}fÓX vj«ò— $yrÁa¯ÍvnèM¡vôºJuÛvßÍÚÛýBC¼$ˆšvÕxN¸e-Æ]/Á‚Îxã�é¸#×Xzíxå–3%uâ;«}yçžOÕl®0×ýyé¦O©u¢&ÇÌó鮿.Sè€l¬óЭÎ{î(!ºØÜè*®{ð¯ôê¤�|ò ¯¹íœ+}ôz‡ê÷ï›/}öз<»ñÀk¾òÍBXýè߇�~ðzkìýõ鿟»¨¸7õùðß_zÒô7Ïôíøÿ7ÙInI” �Ƽg¹ï€ ,VÈõ¡îñ¯kþk �V�öH‡R¾3ß/þ°ÁFLæ*ß‹ ƺç…p…F#¼h0?ÚÑÍ~,¬a "¦½Ép �³¡oX�ê�ì9›áˆÄ™µŒˆísö’Å�éè3Eäa ŸÅ,,s t¢¿H1ý ÐG£gÅEëyñŒl\×�¥Æþ©°�tDŸÄ ö䬇u죴%Dìˆv|ô£!ƒu8¾ù­ƒ(<Þ!I«õ1!ª�±ÉLr*�\£&?Ù)1Ö,å£8iI ÎÑ”¬t“Ö"7JO¶r–y’ÝFJZêRMãOq)Ë] ³KošŽèR9¹ s™6哹JfJ³™8ÄÕcTÆþijA“JT,/¹Íp‰{·l$ ʼnN ¤;âÃ/Í™ËtÊ3C‰„üИgP™óì'}z²…{nÁ„�§?Ê¢»(´�mh~ºÐ+:t¢ø�è5I”MŠjt5ý¦*1¹Ñ�.§£Á¦AEŠÒæ�´‹'M©K³Ò8~t SØ‚hŠÓ›Öt§Zè©Om*…-lA AµéMo:T£ú´¨A%jR“êT¦µ©B•‚OoêÔŸ^u«F¥êT•ªªr«O�jUÅ Ö«u«WêQÙÚV¶~5¬oíiZµÔžZu¬jj[q:½ú®X­ëV×*ؼ޵«x½kXËêÕ¢þ–²Fý*S#kÙªf֭޽+Zåª×Ÿ:ö¨P=+e¥ªÙ½ŠV )ÁÃb ÛØÊv¶´­-IK@ÜÚv·¼U¨nuÛÛà w¸Âe­©‚ä*w¹Ìm®sŸûæŠ ºÏ­®u¯‹Ýìjw»Üí®w¿ ÞðŠw¼×ýuÍKÞôªW¹ÔMo{Û›Üé*W }-qï‹ßüêw¿üí¯ÿÛÛliÁˆN$/|׋ào÷À n°ƒ áKx ¦¯E[e_kxÃî°‡? âàø¸Š£°‰OŒâ«xÅ,n±‹™û ÷ĵ®í[p€âëxÇ<î±�…;b“~»ýZ.ƒ_Œä$+þyÉLn²‹E ト@·5`Ak3ìa,ÿxË\î²—õdxعjðÚ€æ4«Í[¯y�ìä8ËyÎt®s‚Íå�Œ�|nÃÖ¬f/hùË„.´¡�è�„9pÏ� i"H:AÐÁtPð~ @p¶³§? êP«¸ÓFAò,GC D(¤+­ aЉ®µ­ïKë[ëx=YôŸiVKZÒ–þ�r ôŠzÙÌš m:oÊÓʪ‡-éhÛØ?è�®¿ ný¾3ÜúÍ5W|�\ç>šÂ&öˆ [ò�¶½‘ÜiSß{ßI~3ªIéao[þÛ>ÐÁ n@R-›»¿ '÷]pÌãrÖ×áFw‰ÕÍîa A¸t¼k`pRó{ß4°&ò`<„›¨.ò°]´“pÂÈoþݤݒ.Ķ-oƒ×â\áu¸�^è.Œb…h�ˆ@ø¤ùT("þ0\£§�1 Ø"m‹tŠïÖ¾¿°N„),b A( |Ü@ä8‡6òàžØœåúnn`®ÜgÃX¹,÷nߟ[ƒ&(W6o Ü ‰LH u5µ rž« —–·XP±ã7ì.ÅH@ƒ.„>êSó]±?Þ~!ô$ØzÂ))<"þ¦¨„6LzÑ $ø)û˜›‹ímC¡HðÁ œ_T¾ònˆse~øåÞ}ÓÐm¯Ì•[äëf¹ÏFÃßøñÃØ¼ƒ„—phÂÓõ¿±MmCÁG0øÁß.|ÄÕp¶U­à�R×¥àz¨7|{>A|AP¦ð{µÇuR¦G!!~“ªG…P 6€g|—ô\·m²E7P5€õ7gUPp` –p!�f \y°ƒÊå˜ yNðw+7s5€˜0yÉõrF�LxÔ…”  ”À…w7ƒÉUt°\"€ /— yÀ%° þ<�\4  g€ š o“pw'×x4P–€ sPÉe V�š€†Í•U@ƒžvùt,è‚rg2hh%¨c£ ¢€ —p[` ~ÉՓŸÀ ¥0‚TG¬W{£� ¾'¤`4 ¤°Š(�� ©@$€Ä‘¡ ¦i …` ¦à$Œ0�àœ0Š£ ip „�Œ…à� €  Ð‘‰`Éh¡tÈ”Ø['8dΕm�ˆ| ,z& }‰gU° T <` XP‘�\,WÌqðlGÈw¸\y�; p€wu`}]snp~É% ykxy`}%'†!` %)oxñu–°øXg‹t—V|à|7‰Vö_ên£Ð >@„ 4 §wŠ„P>5À H¦˜€$°¨p% � ¦SI~P ñò ˆà5ð{‰ð5ð ËH>°A@iÐ ¬¥¦ B  H£ zŽ«�$¦ð�R` O@Z� J iÀ i@>@™Õs�{“nìM$�‚+kI©#0uYØ]tG”äU’�\þðt@†ô·vÐ\oÑ¥�-Çr3sðGx„È !@p°\,דð‘Öõ–@ñµ†<€ ¦–”÷/M-W›0ƒ2‡–€ˆý8s!€ ›ð�¹IgÿÖqùo˜¶:€[‘ƒÕnPé Q†)^©|0‚5€ KP>ð•1 vÁz‰�‹$à­ð{®–]`t”)­@{‘€œ aBi0 ~C‹@•°Œ–Ix�i` ¡~@ˆÐ¡$ „Й=ŠzŒÀ—}  ¬ `YR¸va6€´¦šû$Ï嚘&�¹ÅkµÉšú¹d§oÛþÇI M x° ›àƒp`�!p„Øg›€ 9ˆ bøw0œú–�ЃíÅr’` _h]y€úf 6‡r8  Øqž‹§xëN€ ෠𷨹 }×�‡(¦qöoWp˜Ö:@F¦Ršpèh¦(¨°>  @�Ÿ@OÀ ­€ $pJª‹RÇ ¼—Œ›¬vÁ Ã(��ZЫ‘¨€ |yi –¡—@] 4p¹H¬˜ØŠ@•°!‰`®¡X7>1Êe¹† ýu¥dDŒÎel±k`p¼6" ›¸m¸‰] «`»©\¿ùþ<� F` ¦_gê„0WxØ'xɵ§w×›ÛW�Þ· ; *‡]–pÉ¥†lˆx`b‘ ““š›±�š\Ø—ž�÷}à'q�“ưbÊŸðk7±i°;DçªÁU‚¥™$P€G@¤}Ðm0H~c¡`iЬ‡®¯j¦�OT§(JˆpJ� ÿV<:3Š¡O` J€zä ŽH¤ÄQ¤¡-j{¨‡—Q€x›e…–¯±¯ØÔ¯Íel9ðy0X²Ù´ÃªJ¦�UÐ�˜€æE’qÊ\pš\W ±:k“Ø�5{Je”€ˆX€ F°‘4Px4þ A¨\h@yË“8´€G†y0©f€w5»³3—sàv €è³�Ê<`jþÈ»™Û\E‹`ÿVpù÷|G°û€„V‚‹k[¥ð €y¡�{Ÿ€ P:^ ¶Z ¬W–OÐ7˜™¬Ìè–~ð­$ † ƒx;¶<š‰P®àØ  )©Ðñò�ð � ßø®}›™¨ ¿š‰·ƒ{¿DÔX‚Љ+…¾¶Â³Õ¸õ¸Ì%o’+oLÉkäk¹Ú‹dUpq@…›X°†Î…±!ðrHhj5P™° yàÏ xRˆ ”À»f rˆ…KÊ5Ñ»\–àþSXˆÉ›\50…Ö' “Ê…;`M¶éÄœR¨ Mì÷ƽNÖ€:�Iûyrò9�QËÂ]±˜} ™x�$ Îø7eòµ¼–¡P§€MÇ{ˆ°–¯ÉÈŠ$ ް © "°‹ðêQ˜ˆ Ž—pɤ«Ð±ÉX�(cKcÐȟжçñJ†P ª€ •àʈ¼c¼öÂBÏuóxp7�£Nù7Â:übÑÉ\ÛWgŒç\u°Çã~àçÍ¢jU–´L¹`Z°àÌMh°*]€ ŸÐÌü²c9MØØ)ªÕ÷ÔÝ1ÔUmÑeÔn-× K^p;e *Ði­×{Í×”™vIq=׃MØ„ýt}�؉­ØûµÖÆÑÖ… Ù‘]ØG·vÈQª×2=¥‹ `š QØØµ¡ÈL&Ø’mÚuVÚ.Æ}ÍÅ¿^æÙ]öÚwÛ•Õþ=ÛxÍ[Q–6êç\©=^¾-^ÀÝl­iª½Î¥½ÚÌòk—MRÎ-�wat ÝùUÝ£Ç××}_ÚM\ÎÍÝ­-fý“GFF׃gÕé�sç­Þ½yÚ#ÍÛ FÔæÍÞÅ ]îý` K]Ó¥lïK ZMEX„àp5Z{¥ZK5à Îà¡EWp•à îà.álUàŽá‡ETžá®Wîá!ÞSY á Þàâ(.â NTá+ÞáÌJb;3.‘G=ÁH¨wã:ŽãÅ�;‹äã uÛ8n �ýZž-C·M/Y!*ö579®PE4hQ>äã6[VÎT.ä¢vþß vŸ æ1�_®C[eNнPyÙÁ‡¾ÕM¾³¥æAzhÎ[µMzÄ1zôä°…e�þÙœ½Ù]µøU›EŽSvè?íèâbnŠŽÙÀGè•[¹6æ–N‚c5®éçZT*éáZwÏ£Î[¨þ鉖é«^h'1lÐé®îÌ�Î_`ê–è´N\¶µ¾îªÀž_¸¾a€Æ.µ' ÃìÍîìÏíÑ.íÓNíÕní׎íÙ®íÛÎíÝîíßîá.îãNîånîçŽîé®îëÎîíîîïïñ.ïóNïõnï÷Žïù®ïûÎïýîïÿð/ðOðoð��ð ¯ð Ïð ïðñ/ñOñoñ�ñ¯ñÏñïñò!/ò#Oò%oò'�ò)¯ò+Ïò-ïò/ó1/ó3Oó5oó7�ó9¯ó;Ïó=ïó?ôA/ôCOôEoôG�ôI¯ôKÏôMïôOõQ/õSOõUoõW�õY¯õ[Ïõ]ïõ_öa/öcOöeOî!ùú±,� ‡        " ,0$#*4== #-*0 647=? .6"&7%%%+++%)+ 8 (-1+04.4934516;49><<<BHLS\a$H(!d!"i"%r%'x'CMT]6D##U,¾½uöйG¯_={ãûÕG]qÜ xtÌÑ]réMGt .w^tÛaW„‚èi‡œvÉ•GÜÐYøŸuÞ¹— t虇Ÿxò•ˆET·žtûµbqÏu(ŠðýW4ƧÝxúð`ƒHá"’Þhß�ÑÉ8dtÝåw#{JÞ—ž‹W:¹bˆû5‡"…FGD‹!®h¢„ZxwÍ™¨&};§#�(Ây¢yÆ¥˜Þ•vª§bqI%’%8_~.‰bŠq’bsDö9!ŸÔ 8¨HI„Œ&Ù^@9&…êaWÜr zG¤žþË)Ègv+êây)ú%¢ûáº%Ž þ˜_�Ø�ºàpšbù£€ÕUJcˆ£¶Ù�(‰àiy>!ŸÍ-Gb›§Ê·ãˆò±Ùf¹ž!®¸§"Q*r£^Z§–²¸î½«öi¾üé)§â—¯q¬ÚKï½ÞªýFûŸ˜-Ìî¾2žË°›�;q�äâË ›éÚ0ª3L®“##Jq¿s®KjÂhúkœ¬áºy*ÄÐÚøp‚þŽY£Íüf,î™æqÊ£Wð³ ²|M Ý­©C‡w4‰M§œ]©óŠ›µ{uæÜñqðžÙ0sÿ -tÐ/ µÖÀ ü5¾f+ÜpÉpG#þªGœ€’º —Ìí{ßÚWqÚkOvÕÖ�å¾0/,¤�Ö¡îˆ-·'0Ùu»<ï©2N­8°c³ˆ÷¾«n>±¼o¯+ºÓEj|që>îHóºrKü$™¨ÊêríÆU<ž”冇ùŸž¯Küއó{¼Ýß;{מÖ-ðÏŠÙùõ;ªn&“¡w{õ›ÔOÎïòg^3ïð)g5ä–çÍô¡Mà <óÁßË}À=®z¦ ó_¤LF§û¤ u^#`ØÔ5ºçm.r$³]†FG¹Y5OrõñAPÒ/âhkhy3Úèæv¯Ðå >�“ßéZ§5%Nk),Ÿüž†À¼eðiRóÜ CÈCö0yþ?ÔßÀ´¶Ââ�„=\óF´î]FêA §7>†p‰ALਸ਼±1‹Zü!ѤèDÜýð‚#Ãâ%×0,‚Ñl vF4"|ð�÷êAñB=òñ�€ $¡ãÇ6â«�‚Ì[ÉCF&ò‘AÌš# ×BHZ²’üÒ£ ™È®±aO¼¤ÖôøÅ=rò�xܤ(!yÇ~Å_M˜ÂŽÐ„Z6� JP.uYÊUú•œä%…ùËQó˜¿d 2—DbþЙ|„f&™IÍuI³š}Ħ 'ÉMq™Á¦h…+LÑ3hí•ëRÂÞ�8À! kXCÒ€†z¢Á hþHÂ5Q‰Ènó‘‹´Ž#õHLFö³šÂ,¨@å³Ï…‡  AÿÙ/…Jô¡Æd¨FzÈ�3eµ¨7ÊЀjÔ�å¨"I:Òir¢p3¨5£‰ÑL^“›û\¤G±yІ½´¥U)P·9Ô�Rt�Mé@‹¦¾¢©°xE(¶Mt²« X�‚V¥ Ë'<� ¸,‚ü„Ôš²ô¬G�©I5ˆR‡Ê4­BÙZͺP™:¦)½iB/ZWµN´­|5¦QMÙÑÀÒ•­g…i_KÚRÀŽÔ±�5,b;WŸÊ•Ÿ’5+^ã ÙÍ62¨Å«?9Y´õ´š *d‹ZŒö¥]þkOA›Z VV±§õ¨ggÊ[×Ötµ¬%lo[{R‰V–¸> + ÊܨÂbd(mK9Èë° ºFÚ* àU&ö$A¹ß»ËÕÎv#—^ît¯6ïIþ¿ýuk_ÝN�{÷>ð“añ-6Fßâ7èù÷»^T_ñˆPˆ�Äüái”ôº§G‘÷]¹$„ ÝA4Ð)>д¶HØ¢ðyPu„Öqx]¨Sø€+°k¸�= kÈ�%�È�è^)‚³�*8k(8ƒ/Hƒœö�‹„ƒx‚<�ƒ5h‚@˜�0˜‚"H‚'‚è‚�æ‚;„2¨„Q…D¨„F8…Vè‚� «P jР( d÷€“à ©€ GÐM7m‹4¢ðuë×϶Ho` ¬@ h Ss` ®€ Qà†=ð�Ðb¨zþ2H©0©° Ž�d° le«�®  §°M o� ©� —Ö] ¬� ƒ°HM� Ј“ Oý•°Ð„ƒ=ð�(kSÈ™ „m‹”‘Ј�@Àwˆ§°  è|Á¸  À=ÀŽ  «À®ˆ ëçˆ �‹@ˆ]N„‹„‹0¨S:ƒUø�Xˆ‹PX‚W‚>8„à莴v‚ýE‹¶(„+Èz-Æ„è8‚6è�.(ŽÿW]¹ÈióX…PØrôèŽøèx¹�fÀ {ØbhÀ sØ�ý¥‰­ð…Ù_¦Ð\’Ð’ > Ìe �þw]GhzÔþ_UrJ€p4ý5�2¸\PEuÌÅ #È“ÝhŽéiåhŽû(ƒE�"x�ꈅX€ïxs´‚;¹„T˜•*È“øh…R8”Tx…5(�©†ƒ+Дa –]¹•騕Ji–t¸ U·¯Ðo@ ‹®àŠ:E k°HL‡|¡à|«ðw錌P=@©PuIU”]U7›Ð‹0x^ÙsˆŠ° Î ‹Ð¨˜tm¢ð{ÙÆÅ‘ Ж‡ÙM` ¡·‰Œð “š’Æ“€•[ö€™‘àý а’ ² Ž ™�=@…ØU†‹ ‘ œ¤þÉv† I` p&H�2¨A;8ŽãX�n™žL¹“ôø‘B¨– �©ÆžžÖ”îøƒE¨–n‰‚HžèI•)—6‡–9X�^ù”ó•�<æ�= ‘¹H¡ô)¡­0‘Z¡>9#Ù(”RHkG•XÝ^Þñs ³öƒ/€‹´¡°”PÅ 0€DÙž z•µ˜µx�) (£Ø:¤/ê•7¤Z”JÚ�PФ,ð�TJŽäH‹S:¤Nx£/Z–ü¨ŸI¦Rª£cY”AH¤9h�I £iɤKJkTº¥Hº¦^Ú¥9êü÷�¡Ð^tÉ… œ: ¦É†þÈ×l··H¦ðwŽÀ˜�à‹ƒm�à¨+ð›`›À‚vê™—¶f� = ½é¬ + lP‹ÙŠ~p™o0 |¨—›X‹ä½I�y6 WpƒL™£×õ�™à‰õ‡Š}¹—¯�ÁG‹ô™€|¨¡M |=puú„ l׬´Æ¨0ƒMº­9ؤµx É�Ü ¥ßJ”Ûj¦KÚ¥mz®Qº¦Ti VZ‹ä* ÿ9 $ؤUj®Þz®¬‡£í)¯(è€oª�è£Uú£l:¤KȤ^*¤Ti¡*‘ ‹¯=À‘:¡ìÊ2UÚù¡5º«¹qZZ]4€¢¹„*š×Õ,+þ¤C:� ¢à¦vÚ®½š¦�––ÿJ„rºWi‹íê®5û”ú*¦6[…<Ú>ú­äX”Xú°äz ò8¥åH¤éz³R¥é®ÝÈ£>KƒW(kIÉ¥Oû¦RŸÛ®Œà¨‘À˜ˆÀ¨`,à OEU‚ºH�POW˜„€†žÈ ¡·˜=° ðµ…@œ P¸:•|«Ð¡@ë ¨­Zð ÎÇ xéªø{›È‡—9Êù��*›”»H–Ù_D°«ptßÚ zÖº®ûTLkŒ�Šˆ8«ð­> Qð¬l%ˆf ©ðw¶Û_¯@,� ëçgª°¼§�¶ ë¤`¯h ´³ö¸Dþ+´súŽG»µOx¦pŽNŠ´êÉ´A;°{¤ Z®;ë’éʵY˰ û¯Ò»��»¯<Û¾\ú­›¡Újƒ�Ù aG‹jÐ Õg–!É\# Ü\¨¾³6¢çŠ¢HPOÐH°* ëê­T:1 ~�±"œ°Uê³Tj‹_K½øJ°ßZÂò:Â0Ã#ÜÂË´ µ7û¸ª³üÚÂ˯<,Ã3ìÂ7ÜÂßë¦ïjÃ/<®î ijæĹH¢P¸x¦žZƒ˜µH¨nø÷µµ(¸u¸H�`+0tú¥@gG¾I̱ºH¡»Ž’z¥ú>»¹×e™+0þ£€~ +°¹¸ª‹´´+0 ~ð‚ùªÄ7¬‹¡7­Pu›¨²,@X÷¬ß:Èä¨Ðõ¬ú´U ³f�ÇKB¼­‘LµÛÊH§ìºÄAªÃæ»Ã²œ±ËÊ>ìÂXÂçj ¹¼Ãa Ë«|̪È?Z½K±>‹ÌBZ¥� 9Â8ÌÂd0ìÊÌ¥œ(™ÌÅ+Ìì­¹$VÞ�E Á>ëÎëêÈL›r•æ¾°ì®DìÃËlÄϼÁòœÏ�kÌ8 ÍÖü­ÊüµûÊ´Ü«¿œ¤' ÌT[®;œ¥ÏLÐÎìõÜÂÓ;Ë',Ñøš–=Ñ{]Œðg=À®þŠ™ ˆàÂ=0 É„€|›¿h= ¸ŽYucÀ ²Št)›D@©+ ¬ÐËÌ‹ ÊYj›F" ‹pÍ£À Dð�’€´¶¹µ˜t†ùw³éÕ¦\‹nH¡X‘¤ê üÊTœ‹$¨ A¢‰œŽ°šœƒnXú”¦ ·¢YÃy]§ Ê† PÀI`Á<®µÜÐmϽŒÏÄ §ïzÃ!=¥‘ý¢÷<Ì Ð��¯ìêËt °žmÑݦˌ–G̦ðZÎ2ìÂõ´Ï­Ú§ ÒJkĬL‚ç¡ßlPÅÔ8 ÁmZÑc5Á HŒŒÁ|ܽ ´ÏÜ›PqLÒ–þÉiMµ–ý£)\ÂÚmË =½`l¨mÙïúËã]Ù"=ÝâÜ“ §à�¦&ÌÐÝÝ-Ý¿¬ÞÁ,¯ÕmÝÑœßÓ½®Ò�ßÞ�ÂÖ} MÞi�ÝÝšÖ”�Þç�ÂŒ�™]x8ȰPé]‹¤ð…+�·)à†) ¤¸ › O‚;©¦° £P_û¨Ð ¨€—+7§�Ò] oðËMÐ ~ЈŽ`Ü=@ õ—–]€ ª°uí³I÷�Z ¬@›µhÈÈ·¬}¸ ¬ ÑÙ�¥ÍLµlí£� +��° ©°�¢û£Ó6©¨�†Ðæ�@æ× Ødð�†p ¬p !¬ßçÑþ[NÑîýßÞÐô=ÞË�ßâmèºLÑþ-ÞްÝ=Ý­ÝIŒè“ÞÞý½Üà½àö½Ü+lÞŒNÞçê£)`ÂXÙÁ}ÚèÍÊNÙDœé´|êŽé�ê,@iWq¶Îè!½qƒ€&WF° è&¬Áð è±>L^åsP®¬.ÒÕýÝ=ÛÀÌê¦ÝÞØ­éƒ¾éýLëžê!MêžàºîßMë—ŽÞ½þéõííöÍß„ÞßÒ^îÿéõîñ¾íì�Ó…°Ýo ð-ï-6ê‰ìݧ ɽLê­¬³œœÝ‹d� ~ ­®³°½ÌÊ|¥îßóýé¢Þb°~³OÛýþÌkºé ßÙ¾å®þê ÜŸÞ‹^Þ�>óõÝçœ~óŸNÛ �àê®àò¾Ñ3/ÌêþÑñ®ëàþèè�Ýë-óçÎïÁ|í�Úò]ó¹üЉ�g1ûTÏ9ÏÐ(èâ=Ác$40¥îlìÙ½ÝÙîê™.ëà-÷™^÷íÝö˜>à®ÌÞ ÷ïýÞ{ßòîÊÿÝφÿÐqOø€o÷uOíŠÿ÷Ýùwÿ÷«ø„O÷êýèsÏð—¯ø˜¾ñ�o°šÿ܋Z÷I� _hðŒßø=Ïðmû�ßs?¥ÿЃ0 ˆßöoßú~ïú»ÿö½ïÞø-߇ù‡?ú…¿÷�OûÎþ/órÿù¯?ù�ŸøJOö±øW¯ýŠ?ü±_÷ú]û³¿êÉ¿ù0_øèŸ÷´¯ýÉŸóÖ�匟혯ý ï÷ƒ¿øýŒï›?¥¶»gŸÔÁ‚Å ‚ÆB˜PáB† *4hP *(VÄž;ÏÈ+R¨ˆYÅ )P�/Ÿ\ýzöíÝ¿‡_þ|úõíßÇ?¿{~þýýÿ0@$°@D@21…VJa„ ö´£°ÂXÄ0C 7ä°C?1DG$±DO$ÐBB±E“ÛïEg¤±Æc´1GwDpEâ1H!‡$²H#�DG$—ìñG'™„2J)§¤²J+¯ÄÒ>'ŸÌ²K/¿ RI0Ç$³L ·üA1ÍdÓ½5Õ”òÍ6çÌþp¼é QΚÐôO?]ÔsÌ@ÿ¬iPBM’F>WÒÐíD4Ò9�R-=SIëÓ”ÄEUŒ²ÓMG%uIQ[”SÉSKeõS ¹3qUVgµROYÝLaÍ[iÕpWR!ÍÑÕ y%¶Ø‘|å±dA\ÖØ›•qYa)üZg¯ÝÑZl�ÔvÓn]œV»myü2cË%Ýú€5uCL›U÷½x 7;çw­yñÍ7?e3ä—¾€½«´ß;S¬—Eÿ.ÈVX§dØ`�dN‰/¶8ÔöVøbU1Îr׈É4td�á3yá39nh>&R1  ÌdŽИd@42™1…7@þñ�‘A2u�”1ä›×ºK™ÊTO²ilYn™½I¢ƒNÔ Hæþ‘î•LpÌÄ�’8z5| / Wª+È”7’˜Ù@A^q¥Té¡âÃÞØÄ¿F–ö¯‰LÅŒµÖ&( Å 2ƒ ‘D$DY‹ÂK•úãø¤ ô‡Í]OtöL—’j†Ú›$�Êòš?ArSX*•&ê‹ä-‚´`…m’ZcL>A*_¡ S6_páÓ@Q!‚ˆ1vçIw‚æX¤2 �·sž¢‡ºüÓCöÛü÷TR½È&įWJeæ7PY%ìå£\©@ð_D&Þ°‚Ÿ± 9§@Eþæc†S$Ž ÐV Q¨‚’„ âˆTünôA‰ß�b§Z O¨Á¾Ä|ƒ#±ŠTüá ’ÛzàˆS¤ØC]ÑFâl¥øÃ Ø`ŠU "‰óIÅã ‚U¨b=0 ,ø‡U°‡X�×1ˆIœp€ùß*±›-QN!òD(> ZpfJ# #ÑÄ’K}ûºÒç4ü°"Vc!Aš‹� âhdPEuŠ8ŽD0UèÁ`1ÀŸ½û“OVq4QJ¨“IXÅz ˜$ü�g=H)´°A�p>³#HJ±alXALþ‘D"¤¢ ,¦Ùœ§I˜ä ®xCÈðŠú`� j÷ñ�Âcð ŠØQh�o;(áƒSÔm……âò–|’ †_%ÔˆUÌrš $Ã*ªÀMÎáœVHg% r¹ r +0+=M>´ ªàÁØ´*‡5ŒW1BÝwdÈ„°ŽªHE*¶°È�œBBŽãÙ`¡„[>Ç9Cs"âÆIJÖS�ñIæÌÐÁ¾�qcCXÁ2„Ât©ñæŠWðM‰GÝÞ((Š‚ÌA«oÈD!@‘’ûÄ3CÄ"F⼜â‰)(|ÇS,â§ñ1**lÖþÖÇ­ÁžÄ> â5F4¢ ªè á»˜Â ¦`Ä]ß#ˆV ´Š¬0ì´JSspDEA aK‘"í`‘Z–¶B: Mi‹¤qA&Q%$! Èdd[%´ V¤â²5ž$pW�L!,Èb BŠ$v²¯°p}:Ë ¸ur7.#�Š�þò¯ ]Á\AÀÍ®àÊĪ™�fT"˜‚…o q+€[Ãf‚&9³n ÒˆET�‰-É"°ü9‚Ø8õSŠ=Y X +–pØU,cIqÆ—$bГ•ã ‘Ù¯�oHÅMÁBRǧFõ xÜžLį i©„ÆÐOô`–ò*øb¢†T ¯à¤Y¡8#·gËe†óÙVÍX�mk Å—Q �V’Iˆ(®­ 6 SnÊfC„9À6Í ­˜e' r_‚þÐy“^�2àØ­ F|Ã)’`k<Ó>>@CX@§¾ó‡gî° -Š”–p¾Y* ÑMÚz sàê-3=jeG³V0 E�½¥f4Õ'Gy•VícGÖú$ÈOšÏøÜM:òÄ)V¡FØLÝHi‰×³Hã–;2Z+V ɹÍwj€…� W¸B�`0Ò½èê †Â‹OTw¾“Pî‚°û�†%*H!@ Bœ ÷&$šiÂS€¸ñ‘÷*\q C´à%†8+N±W?= &LÌÉ'`‚ä|ލY\P Š-€ùÂâÛ‚ —.9Šfb†§HdÊE?ú#­þ¼t *6ÊÖ’zذ¾Ô"žwb_’_¢ J(L~ ¡¨ûPÝÙjI$üc‘~c¯5þ}RU2âó$ Z0‰\Á`ù>ù×Ç>¡‰&Ó¨ùÙ—OB_R˜aö²>øU_­ö8Êý€êÙúO>úcHSÝWþÁ|oÅüø×¿À$@ ÀDÀTÀ„�dÀ„ÀD@�ï“À ÄÀ ä9¡@ ôÀÁ“é@q=ù(ÁDÁTAé>pÁ„Á”Á¤Á´ÁÄÁÔÁäÁôÁÂ!$Â"4Â#DÂ$TÂ%dÂ&tÂ'„Â(”Â)¤Â*´þÂÔä+ˆ+äÂ.„B!ôÂ0Ã1$Ã24Ã3DÃ4TÃ5dÃ%$ KiÁ6œA0|A:”C´Ã´CÈC<ÔÃìà DÄ,D<Ä;TÄEdÄFtÄG„ÄGÌB’ ¸�K¼ÄÐÄMäÄðÄOŸ ÅR$E@ETŒU”�MlÅV\Å Å E¨[¤E EO¤Å�ÐD`F`LÅbÔÄbDFT ÅP¬€f¬pÆf|�i¤Æj” ˜  €käÆ À€oôF ðÆ È0G(Çs ‚udG!wÜØ�xœÇsܘG|¼Çz4ÇrLGsþä�€H�œÇ‚4ÈytG!PÈu\Hq Çqǃ½‰ÔGѼL ÐøÔŠÔÈÕÐIåÐ4PÍÕ\Í�É6h<1BXExA1(ƒßl„F8„C¸ÉC¸‚CP„\€ðƒ{S�¥?8eRH¸É\5X„C¨É8ÜEÐÉ%8amh�A?Pƒo€Z½U$RàtÁlen­Ax%yþÝÕ^%ƒeÒ�%Ø‚�B'¥(½Ä¥ÔN*õÅP„ÊQ”Ê7…S«„N0-Ó1 Ó]ìÅŒ-Ó²TS�MÆXLËTüD*Åh¬SiÄS¹äÆmÜÆkìS ýÆÃôGtüÏÇ‚U=TÆÜÔ}ÜKýÌÐ ME5È¿¬TÇÄÏɤLŸ5ÈÌlQP½L¡mÉ%È@ÕÒDTzŒÇUM…ÕלÕ|mƒee„-„g}WÜ€µÍ6h!ô1¹-Öm€¹uA1ØMµ�A?(hE˜U Ö6¸l€›,\õP@»=„²]€EøQ]¥W\Â]„±•ÁC˜ÑþP´UÛœX$5ØÖuÝ×åAý ˆLʸØXœñ´Ò+ŨœØ8¥Ø«ÜÎRìE\DYðôJódSx�7¥Ê’UÆö¬;uÆ–•˘åƈÈÏ xÈþôO†|ÐÀäG•,ÐzäÌÃ4ÕEmÐ…DÚwœÌ™=ߊüTOmQàÈÎDPSÈuUUQEG’ÜŽLÕ°}M?èÍÑUÛ6�×+¨Í!uA†ÁB�ÍнÑgM[4\€¸Blm„5PV[ !•ÖC€„0Òwm˜ÑCP[?�WÎ}Áh„kUƒ]ÙÔa à~]]ØUâ%fbDþX–QXÛ½Ý@^3õÅ,-Eª$Y«´XXôݨô]ðD1>Ùé„E²Ƴ„^dlO÷¤Þ;mY=½Æ È€û GqÜ€?5LAýÏ¿ ‚•ìÙó ÔAþÌ|´ÚóÕÙA}Ç …Ȉ,PŠ ÕOåPðÌZ}TÐõßTÝËòEGKÎ�[Xõƒ+`¶]¶UƒÅ-\»uA·}Aw•ÜhHxÁ"¦åÆÉ~AÖB(ce\H VE�Ío�áBX€([Z^e”`]VBÖÅ�Ahf幃+(ÄðÕ&çq.Ø'æ˜(žb*ÎÝ\K®´Î‰ÝR†½X2¦ç1^Æ­þ\Þ|^«ôRµ”Þ÷t `Y<½Fú”ÀF™•Ð¥½KýµÙ¤PÁ$P{4Ú‚tè›-UþÅÇD=HˆvP†Ǧ­LŠ”ZÕ€©åLŒISÝdŽ€pàë½öë¿ìÀÞk8(€Øë½NlÄ6l(Èv€ ìèkÊîë¾æë`€Å&ÈmþÈNlÀîëpÅökÅmÇVlpmÃ.€×þì0€Ï&ÈìËÆìpíØ–mצA!T€âF€â¾€âVîåfnpîç~næfnïUïµîëÆnë^îänn8îïönèoðTðT 8o @oöæÃ÷†o0îå>îùïð¦oévnéV€H€þþïOh€Ÿ¦ÁC„(�FØÜ% „B€A$háÄÑZö\1ˆÛ€ 7Ü ‚]§Áº€ ð¶�Ü+ˆÜh¤H(Òng~×@€ñ«nÑm1€¸Û€!FðÏñ%f=,þ5R·vò'OCsN˜J¤ëºFè»f,—¿îlèò¿s¿&ìÂ^ìÃ>ìÄFóÏ>°ìÊžìÊæëÓÖìÅnìÆþìÑÞl9Ïì@óÖVmÇæs×>ì×vm€Ü–ì6ßí¾®mB7€pm0ÂIwAþ¶tä6nñïû–îìötO¿tïuðþîñ~nOEõTWoñfoçŽïøþoåÞoˆuQïoYŸïLÇuNpÿöõ¯Õ|½ŸìÍ€�B?PE(ᦀhÖ´Â]Fêfm„A\Ýl?h€]ÝÜ@X@‚Ýd×%@‚ef7VŸbX›ÕþPsÛX"öCbc§\Hxc†ò‚7ø5”òz¡ò*¿Æ,GhnŒ¾–€2ŸxÁFmÀŽm:7óÂNsÑNì6ùËÖsÓŽó5mÛþìE¿øÖæó>wlAtAÏíÚ¦mÜvíE_t×€øl›7D¬Ã õP7õèÎõí®îOWú¡·ïY/zç¾OôÆóŽúVçÃõ~îW‡oã®õZ?îÿ®ïþ®oN·uû.î?{O€€jœDeÜq¢ j$œ{ŒûƒÇû¼_Ä„—…§ë-ŸK„FhÂf€Á&m1G|2ßø3·óÐ6í�·ìÉŽs’_lpl�·í–ßsÍîsþÅæóÏwtÃvô™×yÝŽEùÐ'ô zDdúêÆôL/úî6îìNz¥§î²§ïYßuM?uTÿÆûôÔ¢wo­¿zY{Z/ûýæï°Çuæîõ_ð·Oe\‚ JÕ=BZŽpì×ûïÿ3äûiñû¹|ØÞÀ?€Â/|1ÇøÒ>mÓ6ì2‡y4¯m“‡lÌžì7§lÓÖìÒˆ (X�@„`è@àÃ'Jh@ �‹�ñcÆDŠ\¸Ð¨ä€˜2c*¨iÓ&‚›: èɳ'МâÆÀn‰äBF>™eCO‚‡HùZtTŽDå­E„B}ç[DÅý &™eÄG(­ÇÞšL{œ1y;“œ5Ýy�ž•ÓžéŽ[©Y“ÒÙÓUXi5hO^MÅh£ Êåß}ä®Õ®P#Š—þ]¤|0 +¼0ãžúð «J [«µé–[n ©1D¶^ùq³ºzçk²ÀŽ$ÝŒ´$³½¥”PFæ!„\³W†w³@8¥y(¡Éí°Ø.ô¦DÃ)ÓK1½Ä®œwT~êî®Pé·ŸƒñjÅÁ„ˆâÛÕ¿Ѝ!‡”†k¦iŸØ0Ûm»ý6Üq' ñ©ÛÍšl @#$ S!�É�¢ -¨QÆà˜)�øŠ0þ8ä‘K>ùãt›z7欎†Wß�QqH#K¼„Ä!� p×~(Âȃ‘ɨûш"­ vˆ�‚ÄK%ò¹"¼§Þ!pè~Ä4€‹¢þ) øÁë/¥N½íƒ‰�8åÝ{ÿ=øá‹?þà–㘹Ýy¿Ô_/ÒÆ$4²8ìw©!ÆÞ%VÿK÷ÿ`¡, ±A#Ä“ØùA�*XÄ鮈@ŽóCÐE4eP p?@pÚãùR¨Â²°….|a^ÌgšD }S:§è5¢ƒ/áŸÛ;ñ jb¡çÞ!pqà!4uã `�pœEW!âňH$ Ã1’±Œf<#g$ÃÓØð†›»‹@Áqð.¦»ËÊ ˆCðŽ0@Ìãû˜CN ­�"ðÒHÜþe‰€„_2H¢’‡ ÂÿôÈGëiR�‚Ù^C)ÊQ’²”¡\£iÚ(1Æ1p/©ã¿ø’4¢0 -“ø?ˆn‡ÀˇX<L/L.ñ’Ìæeq¦|&4£)Íi"Œn4´‘*WÅJºŠh=¨ v*H$a¢xœîœ Ä»ü/ -#8A|Ƥ�$TA[ÐNt²Ÿè ÌÞˆEŒÀ¢£&D#*щRô2¨DU6SµÍÆÐ1’*ï® â™P0çlDHG:ÌÛ]�Ï“d=ï"=F¬ôxÉ[žFꇊ”ƒ(XOk ˜%@¢þ¨�xh1Ί2µ©N}ê3/JšŒjô�c T³ªÕ­rµ«”‘*j¨ª#’Š�^=+ZÓªÖ¦‚U4b«U †ÕµÒµ®v½« ÛZ÷¾†¬xý+`+X­ê•¯‹ë`«ØÅ2ö”m5l_ÛØÉR¶²–•Ü eY×äͨ›¼,hC+ÚÑzª°›ÅÛæ‰€�$-lc+ÛÙbÆ´§U�lðZb¶‘J%óœ·TÚ·¸Æm¬mo«9ÑÀäµ*è! Q0³bpŒäq³«Ýíª5¹·í¬Q1y<Äù’€w¡'¹«Þõ²w¢Þ=­lTË—¼4©ƒXˆCì7{þíý/€ìXT^S¹¸Ýœn}(1â£|9f`œ)à S¸Âß{ïfs»Z4àw/Y! ÑÈ+ü�>lhz-¬â³˜m†,iºbTEdÐz�ÈótêÃ0"Å-þ1�ƒÌ)]bN�_»Gì ¹ÉN~ò�{d¿âe �€2–³¬å½Ø°TÖË\·,æ1“Y1]æ«ú$ÜX5O”Íe~ó]ÏüÖйÎt¦Mezã=/¤~†(PASàY9•„E¥0ºÑŽ®Lýg PÐÁRôèCŸ¢iz¡œá4à €ºÔ^‘¢€B­±ZŸqYö£¨ÊT¥Ö¶þÖ@r ]ï:×9Pòâ”fëac€0¡Î@8£2ÎΟœÅjç;צ2-ãÎBéISÚÐIttíèqW6·¶!�h” ‹%îî´<#€P‡Ú¥5ªƒ’¯¬ºÕñúÌŸÚ¥¨T÷„ÖÄ®u¯}�ë]'-iJÀÁkM˜d;/.^™ñgsœrѦ괧mmö�݃&´¹½Ýpk7¹)Sém›\ݘ^wÞíîúxÔ¢u½!”¨¯ôÛß®öÌZüt/D<â»NxÂglˆG¼ØƒI6²)Þ� ´@€ÿa^4Þñ±3îã ¹�óÌêôÝ”¦þt¤U>,m€ù£eŽò™›ÛJ~Ï´¦SÂúÆ™J ½[͵¨H¥™xiõ6p•»HØÍ† x¬ÕxÊT ÿ¼`2u˜ÔzP¶Fêjd—Ù~ðıƒ�ì¶—›ùŠ<Õ#Ó ÷¾ÿ½ ‚/üÿ3¸� l€üäÛ`Êw~ó�?ƒPÿ0€� `@ýp1ð>÷c`üñÏàøÌ??ú¹/ƒ¬?ü1H~ àþÄ€þÖ¿¾õÝ~û›<Òè†@ù__ PÚPÀPZPìIxÀ~^�T î€þ€vàèò‚ þ”  ž ¤  Á¨ ü@œ  ò@Ò  ÖÚT…é± öàxáá¨aÅ­áÚÓ=�®‰à6áÂ)\êÀNaêÀüÀè@þ ˆ�,mÞž¶�ÙeÓï�! Ÿ _òAú5Ÿó•ŸÞ€õQ_öißö…øÕ_ ŸÞ€ñ½¡ Ä_üÙ@û©ßúŸ 2þÙßõñ¡÷ÙŸûÑÍ™[ùÉ€ÊñÉÀÛQ�% Ý]@H*N ¦¢®âVaæÀ ž ª ®` R Æ .ê¢ Æ ÜÚ°ù ! á¨5HÈ ®-Ô-¡þ:a¯=#º¢n¡fᔓ…aí�¡7.Lªßª!ôù!ú¹áùÍ@#â¡öyøÑßþ�ßò-ßùb!†_ðéóá#óÕßõä;~Ÿþ…Ÿ¤õ¥a¢&nb ¥™R€ €â(ˆ¯c®y@^ *z` v`¾âÆ¢ Òb Ò"Išà.æ" vž0ö ãfEÐdU<¡¯Eã:cHBaN¶b~ 5^£$øÁ×=RØ}#SÎ�”Ø8ú^9ªa=ªc¡&_õ½#Ê£ûy%óÙã"Þ£>""û¢ ¤@ d÷ÕŸ<ÂÀŸmÛ¶QÀ ðþ ÝM$DN@¤KFþ¥¦"r$+z 5ŽdJž¤ ž$/öbcRà°dÄiÅKò`1#΋fÆKÂ-!O†$h2ã%P‚à^a5‚ ”AK�7*eSƦÃ<¥rEeïMeñý¡Æaùbó5äýUõ ¤Wòá ˆ¥X"Zžß>ªŸsÊúÕ@âá÷=âꟹQâ¶Í€n¢&RÚLdD& RT…Q\d)æš`b`avàg6aJò@ žd-¶ /Úà ®g.6ÈÁU& ^f1z0ÆKH£N#zæ+NcP6a¢¦ˆA°f`ÐlÊ&†–mÞ–m¦!nb"ùþ‰åñIŸÖ€;Ngr_q¾ßXÞ#t–%?º(#Fâ÷Ýøí!ýyŸ\þ_¤qçB CJdx`H@ºl@Fà)&¦` ²"–ægª¤ Z`}Ò¢Üâ}ê" úâJÛúgL"iLZE2jE2Þ$èå$hîäh²éa‚à\áœjáèÀH¨8UèRf¨ŸnÊièÞÃY,tèz§ ´(ô5dó1Š '<^g÷%òÙ£X.gò•eû)âX®e@¾¥º_ÿõß%6¤]ÖÀHDêeR€_á)ázVàD)+¶iƧ ¦`-f) Â`•îb—R`Õi€þ`€Â*þn¦ç�&ÔE!4&!¯1¨+¶bj!èàŽ`X¨…þ©·¶H8¶QÞà¹âÀ‡&j‹6jCÆ¡ @jnß�$÷%gº¢!"û…ŸŒÂŸ§6b÷Å£ŠÒœ e'(¤]*¤Ž'mL@º”bLB`“vd{F+b’d}*¦-Þç-FæJâ" º¤!fB`ƒ\EMš¬3úd´z¦2£¾,¾ìˆÀµê€ø�ñÌ^Ÿ~+ϲH¸Úи–+nrgº–hNŠve¼Æk¥Ö+üÅŸs6'÷A§½&_¿dAòa PbºU@]j"&Î@'>$šÀ«*i’n$“R`{j N:¡®b)}Þþb/:fÝâ"ÈV&€–©¡ÌK³J+Æé3&Pîaf¡åìÎvcÏ6.hü,ú-¹ž«¢¶aü¹aÒ§ûÁ£¾¢½¦e"êãúµèÕb-Ab§ÀÂr§©jbùQÀÂnÙNdâí)¢âz Á|ä­º"¯Åç|òê :i|âb ¶¤š^U«16¯€f�œlgâ$®¨õB«+b!�ŽàXÞ1[Q9®ø~äfΗyo‹°ÙáEúzÊúRh¼oÃFûFýrÊý¦žýæïøö/¸n(|IåÈo†°ÿ°Œ”/æ|7°S“ß ?0W°(Epú°o0þ“»su°�ð }ð*i0 §° OŽ k ¯0 Ç0Ü´°ªL° ß0WgØ ç°ÿ°†‚¡Ú0±» WU171XX;1WñWAŒ îž÷°w±FïÈ1—qÃÕ—±¯1^œ1�p1Çq»ña¥±ß±ÓqdÙ1÷1ë1gÁ±2 rkŒ1!'ò2jñ±"?ò 3rl2$WòKò�9²%o²c2’Q2'‡²ãzòr­€(Ÿr“2"£2+g¨*ƒr+Ç2SžF�Á°@ÝL,˲/ßÞi�!„Ø �Áþ.o±&ÿ²2ϲiB!  ³r­ò2_³³�F! 4C35W/c³8‹ÙiD³9Â7p2�3;ÃÙi@ó6{óåðò:·³=“³i@s4s:óp=ß3@CY9WÒ0‘¹=›r@/ô˜ió ps4÷3Œ…3CW´€•3kôùp´JÇ4‹ 47o³D{JË´NËÖ@ë3:oôIÿóNµq9tˆ�46%4Q/5{ ô6»tRÃ4SOµv±ôCÛ4Pƒ³PS5W_MÃóM£YNw5YViÐÐ6§µAgµ:wtY¿þuha´9BX¿•5Ã5^Ç™i¨5V¿tP»u^öb9ôS£sI#´T ¶b'ÖW÷uTÿõbG¶`}57×µXݵdg¶Sö>[6Ua¶f‡¶{åó0Ãóa#6d‹¶jŸF«µggh¯¶lG•iœ3T³‘R϶n?•QCôkgSlï¶pŸMÇóo«Rp·rÃKöq‹ëX/·t¯eÿ´_k5`O·v_ð^Ûösmto·x{Kô ¤Àiqx�7{GNH—¶ ô@zçv{×7sç37—v ôÀu·µ}ÿw^‘viz÷·?g7€'8´‘F  À€sð÷cc·‚W¸ø˜Æþts7÷ øDoµ…‡øÛ” ôÀ€s3‡Ÿu§¶ˆ·8ä”F„ŸøyK8n'¶‹ß8îíõƒ§ø„û7Žÿø ×öƒï·‡ã4ˆ9’Jyws‰¹Xy’G9�¼74Ç÷|Û¸”g¹¨P9„_9‹k9˜+9~x“÷ø�‡9š1i<8!ðx�yšÇ9Œ,94¹™8‚˹ž×HmÏ5]³õ™ï¹ sÙ^«õŸ;¹]¯÷ /:`ô´9wä*:£O:8ô9CºùJ:¥/zq;ö›Sø¦‡ºe”÷Cú�yž‹ºª—›:ƒ£6¨¯º¬ÆY§À‰»z*Ñ÷¬ïzhù¥:žóºþ°7:iÇóZ“ô§ûø°/{sƒ5°£ú²G{cû©?yªG»¨;úmç:–c»¬õ3S{²º·ïºS_5¦/°¦—;�[õ£?»µ³»¹×¶±[wµ':”Ë{š«8_‹;·Ã¹¾oº\7¼ãûµ¼ s¶§ÿ{¬#¼Àw·s{yÃ;ü¤O{ºKðºS¼…S6®cT·k|ÂC|güeg<È'xo/¼ÇüÉëyqoûÊO|Ë»|w·ôÅgp¾Ïü�—³9ü³¼Î£9F³½�{°ýž§¼ Ü<<ÒGù¹ûüŠËüÓƒùËû{Ì+{Õ ½�wsÑ3¼Öo½Õ“ö>wüÏS½ØC=½?þµÙO}ا½”513ý ç<Ü£|3W’ Âн ÛýÝÿw0_u!3ɶÉþpÛ ä²ß×pâ+þn¿2àS>{[¾Óc~…k>ç'¹ç~»´‘éºè»xèŸþñ/ܰþg¸¾¼E˜ã5 ì Æ«›†.>l�ÆÜHî— ü¾^µÕiƒUñKÕñ¿ò/?ó7¿ó??ôG[­ÁT¿õ_¿õ“�xŒ�`?øƒÿ÷�A„¿ù›ùc?”ÿú³ÿ¬ÿú“ÿûg?û»¿ùw¿õ§ÿù»ü£ÿù›?@xCÆ‹À1v9¨aC„FÃ0bÁ‡1fœØþ�"ÆŽÉPìB†ÌÀ�Cl2åʆ^NFTXr¤F“4U.ÜHRaÍ–KÚ”8¨M–CEÚpEÓ± F•:•jU«R�®“•kW¯_Á†;–lY³gѦU»–m[·oáÆ•;—nwñæÍ›â.ß¾{õö ïàÀ‡'V¼˜qãà |Brá¿„#–¼ʼnË�£ˆ¬·rhÓ§Q§V½šuë¼Y¯Æ–;ëÖ®)è⦻›woß¿o«[8XâŽGþÕ®æž]G—>�zuëz)_×¾�{wïªaÏ?¾vXËѧW¿ž}{õÊÝï“8E}èßñç׿Ÿþÿÿ o¼¯*¬óÎsŠsã >¸|+Âø(¬ÐB 'l«¹ÇL(¬>A QÄI,ÑÄÓ$PE¬œ²�+6dŽ9dœÑÆ9à€ÃŒ yìñ· } RHô6Ô‹ Aj¼QI8Ê8ÑÉ'¡ŒRÊ)KqÅ äÊ )¸äò‰/ŸP‚ %”@BŠ!ÑLSÍ5Ù”ð­"óÚ’K(¤óK%Š(S *ùìÓÏ?�ÎÊ+ ¬ >/½“L<‘(¢ˆ6!�TÒ´€œôB8ûêò‰:¡øòˆE‹@ˆ@I-ÕÔS©”PòZT¡+Mí\”LG‰°ÔÖ[1Ä•«J×ô±9;UTL%hHÂQT‘þíγû’möTUW�-ˬ`}"‘-dm‚"jÕÕÛ²z€dRÖ˜d…&\éá«7@1«‰W€CÅÅ6yý1¾S`ÁWìRè´Î/¯]t‰'¸%ÂÙƒã¢MÂè# î #±J²H!»Å"æŒ>îØ4h£¥­E¯6}âÓ'¼xD %Œ%b‰$|øVf±Þ¥‡’ ã\XÀb×]žéJbǦäUW- A…Tþ˜9«z-Äô.(‚ 3å•i%b[�©´80ʼÎë9M¸¨�‹ –»»4yXìµ)ÃCíÎZƒî컾àxµ,øø„>FåºO�C¶jZ§ì ³ˆ-‘"O­‘ˆÙi\þü ‘E¸j£»z#”Ÿw›c‘SÑy-¤›XÁ V´˜B" V ŽP$Š"ˆ@"‰$z`ŒYÁû}º‹ñ¢ì¹ßOèÁ“Àñʘ´ç(~~¯ãå.¾±Ò:ã+MÏ›50ÊN>€ç“ð©:¼©Xg•¢�'�à¶…Ê]Oó GYåAV¥é¦@ˆ¬b¬(�ÏÞ¥®$Hb©øŸÏV Pè�+`ÁLá T4AsM™C)’�G8‚+¨Xƒ)VŠ*ø€uMñ�+QÀSb¨ð)V1 -(ð©X#º… 6à�­ãÊ(ÞP?HI ‰k!š°;$ô€åƒþÙú ‰>! œð¼ˆ—#ôÁ•àà ¢ç¼/T¢€³XôR@‡…ñá”18Á‰:¤ c=¸„òrN!/_ÐC4Q 7Äqx¡øà KTÂ_à4¡ ;à —à%¸€·<Ø!“›Ô ,aÅ)�},jʼ÷¥<‘ T ˆu3ú)QHoxV…U¬aˆMyW³‚D4�›ë"zЄҭÀgIP0›`Ц�ac`AXÐÁ5 b YaÁþç”S„‚wˆ(Å ‘f’b†À\SN1Š‚žýS„| H�©+Ä;�Òƒ9¤™¼Tþï”§PA �ˆ zЂ¨ÒIZ4B ìЇT"”'%³”×ÏXâaË‚&I‘¢ m'�)RàMÔ§w`AŒp¹õ €,žöÀ<|Á`HÁÈV„åu†l)ðîO|F°D(»HH,|‚�`ØjWÉö—1•£Obe+¡¢¾�iLyJ�„¨€ÝåÓĵ³Ö,„-‹a¹òT8Å�ðÁ*Z7ˆHtårÌlW5×-E0‚šíª™SÞ Š8b™NÑN‘º±±)=€E´° umB€öTgƒ /ÍI. ¯ˆÙ)p[x5e°8…þØ‚X‡²…‰L“¬¤ˆ$ h~Ulk‰Ò*OÁ gLYõ’ÒΤ‘'8äsŒ0Õè‘´/_=‚'z'ñ=²�ùÂ%¹‡ð�/­ÏT—šÊ»ä!BÝã€ë £`FuÃ'R édý†dòþÍò,Or6¯><¯ûúã�.A‹fú *#âhjⲊ>Aðà.à“4�Šà.î¨>Áâ"Æ3ò€|Ĩåð"oHIÉ ÏªÓ,ÁŽ úÒú*á“Üæú$‘ü†ŽXƒä|5¾/dä*w¸e[zÀâÌ)€Mý$òzcBÚ ]¸‚ (8Àå+Dl-Þ�+¡èb÷MH˜þHk–à‰`~:�/H û#»P@ú¿æâ¤:�gm ‰c~'½zÐ=f£Å@ŽCõH@N J€J`Šq�qyä�" P Ö$¹¢ XA ”ñW€‰|‘ˆQ��÷ãÜ&/dê9¤$o~'ÑLsÑ)àJ<äÊ‚$8xðý°1¹˜‘<£QÉVÀšP�êòqß´1bѽclÊñÓ„pJNàæî‚:ajM!ŸãQ6æ1°ð± A2$•Hêe>L1²?.2%Y²;tqU²B Ô¢>R$mrÄjò=ê¢%y²'Yò% ¥#or(‰²(þÓâ }2)•’£€òJˆ–„#'›B*W€*©Ò(_pRrò*É)—ò+Áò`šKœB ¬+{Dd-*çAÔ²-4ñ-%Qå’ÍC,êRAòrAÒ-–À Â20³cÆREÌÉ „"1s1#B ó1Â1!s2Ç@2)"Ó2)Ó&0s3#³!4!Ä 433#HS"N4"4SS#Zs5%3&\Ó3Mó1»037u³T¸b#9²,ò²8‚S‰$/U@-óEqcD-ù.œ“-†“ئ3ÌØ#_¸’M’¤*«-ÍÌ7E¦) <Íó<Ñ3=Õs=Ù³=Ýóþ=á3>ås>é³>íó>ñ3?õs?ù³?ýó?4@t@ ´@ ô@4AtAñó-ij@œ=`=`BÀB/”?1Ô<-C7tCÕD%ôC4A+´B/EKô=E4= =_4DÓ³EáEiT>otEutGy´G}ôG�4H…@ôA«"+Ê”& I�ôI) J¥tJ- J­Ô¬ÔJ+À¶”K«4J¿Ô¢4°Ô\ L_àK§”Ä”M)àIáôM£tæ”N³ôNñôN#`Où´O'Àœ4&@š” õP5`uQ• 5R%•:À(R;þ€< 8µS=S55S/ÕSI5:&UT9•H•U?àUa5V/à`Vm•V'uRàvµWgU$UT1V=àU9àUsàV3@Y)5S3TYµT;µ¨µZ%tH±5[µu[¹µ[½õ[‹ÔH§I€b hNãTJé”J¯4OµÔMÅ´MéLóK¥TM£´Ôµ]Û•$@Nó•Má•`ñ´OVP'@a¶aõa $6@5ZY•U�US#ÕR3µ¤µS-õR/5S/`dŸUTYvTŸµS£5VeölµViõW%õY7–huvÕg3@þàdsõU;Àh‹õ: e—•ioT…RK5eS¶Z­Õ<¡úsΓJà[Õ“áÀ¶lÍölÑ6mó3\Å5*ÈuI™´_�T`vª´LïT^óÔMÁT^Ïôn±_×”o›ôMéJÿõpßtoá5K¶O UPVP!QUQ/sM6j1Õ¨Õc5UeW¶d'ujaÖdKfIufcµVwõfVjsuY W…¶Wsus_5isVköf©Vv¥VU—•S™–S­–ZͳA ´V?—  ÎóHÀ>Ë@ �žwEG€lôz!F@mÉ·|Í÷|å“mÛ6þÞÖ\iäÖ_÷]Í´qõ–JóõLÿöo_Ànõ·Nç¶p·NåÔÄ4×J öqý´I5P+÷P1s1v.–RuÖT¡R-õ"Ut?6UAÀxKuEÕR?ÕSAàhY÷^xm5guRk—Wk÷†�õs;˜zWie¶i›–j€s%õxK5y; <©ÀÚ` Ô`?©@zͳª—BiT`T€=s´<ø=»WAÉ`@ ¼}Ù¸�ÝølÕ·mßvIã·pLñ6oçÕ`õu�õÖLÉÔ¸�) Ø•€™Nvp8LÖ�'×I u&þØPµ‚•b€ƒw6X‰6S?`U¥µY¡Vj=Õik¸ee·UŸõ…_x–VàeQxYÀfo¹v‡6wA9i? X{7jÕ–©vV9yv;•j‹W‰…aÊà‹ÙóÄ   `Á<Å  º—á�ká �– <À�H`B¡‰!�Ïs�kÕsÔ`›à¾vák©àÂYk[`ÁÔ€z€š�è9›×ô™a{Ó¢¯9›�›©˜=À‹ß¸¤Mú¤�4ŽÅõmÑ5n›ôJ×”~í5�ïÖKôo) úöKþ]€LÉ4pøŽ 7~ ØpùO“:L÷Ô$ a'ya÷Ôa+™Q3·b@R[X”At=¸T18•tIWƒ‘Ž7dsu«9U<5V=À—iö–o5vMyŸ5‡c9h/ W…UfãÚ…oµ˜—5RyXƒM8±M5yËÓº¹Ú³y-º¾˜T <¦ø< ¡ @aBA  �k !Š ´Ëó $ð²×Ó± ²U`œÛà Ôy€¶ üA�€´%{AšËØ<ý  f› [=�›²¶3›=U@Æ¥¹»»½{m³ò+ÖוVþÌÕ¥a:‚í˜]!€¦YL»ôN ¹MÝ´þö~³tJyZJùµ¨‘ú_�z©Y�«Tœú©vP¶I)·’1(0¸“WvRA6…IUÂzUMöƒAXZKØTQuM™TÅ:b5~ø•gY‡g¸“CxV}V¯}Öc�˜u“ö0€V›v ;„QÈÈ•X�ü¶gô`Û°ù ¤w¹@Ê9ûy¡{¤ñy.»`»¶ËsÀœ[t ŸË€ž«9Ìsü@œ!aZ Š ¡æ¶Û›)Ú<ÍÜBÓ|=3º±±™ ¢x>º= £¿»ÑýÑëþS¥�”t À  4¿1Ýð¿Í”ù7Ô?�ÓÏ”_!ààWÕY}à×Õa:Õ!^ ÖmÝÖC�_€3½è7Óßv�LÓTÔ›4؃ýØcZÖ'à Ú¥Ú]`×E�àÀP àÀÛõ‡sX8FœÜ1 <€ÅY|Ýs`,Õ4€È=®é�ÞÉ=ßçý—Ý}ÝÝ]Ø�Å>t`wýÝÝAØ6€Þí]áuÆ@$^âw`âw ¬{ã1À­%¼ã=Ü:¬õ�Ãï=ß%uäQ>åɽT1´ ® ¾Vº¯X ¨V;FÓ<þ7»Š¯XF  ÁB¡¬¸<Á|z™¼ž!¡�âY�!�ž@ž÷äœÎ@¢8³Õ@·û¼<íù˜þée¾<»ÞærŒýÀë!ýíá>îËSÒt;-ÝÓ7ýÚ7½KOýÚ=ýÚÍÔ×[]Õ'@ÕS=¦]½ðUýÖŸÖýž}Ø Ø­ýñáÙß›N ?Ù)@Õ§ýÙAßßÚÀÛ Û¿ýÛõzõg¹ÜYµÜ5 àe? uÞ5@ÞK–Ü~áïý—ÕÝßÕ]öžÝã:ø‹_Ý55Þoÿö9àum‡w ú+žâ%^ãÚÇ7þã?¾Ãó]¬M¾ÞIWåÅ_þßE™SUT Àå�χá¶Ç—‰Cšè{¾ÌŸ½ü¡{>þÍS{ó9íúsh€Š€D¢…‡¨hC±…Á%G`A#�$’<¤€$©84¢dÉ6bdÚ¼‰3§Î�<{úü 4¨Ð¡D‹=Š4©R’+š:} 5ªÓXT«Z½Š5«V«+R4à Bb+¸ ë¬XlÛºµ�–­Z¶@¨‹÷î»{íÖ}ð"°àÁnݾ8{7qb³‡ »x¡8râ½w)üu€óÍÚ6öÌàÀ¡G�V àêÔ«5hÀÛuì´kÛæ �nþ®7èν۷n9ˆ?nܶòâšo><7ïéªY_¨~A‡v;¶w‡ ><Ê#H`�ëõ¯Ù{àÀ·|öôëÓç�!?þ � Ó@ )Étˆ�ЈB,QH!$!ÈI%¤H"†*42„�@aP±Àˆ ÞäÇ ‚…WøÀ‹ Ð ”‰  PˆEH%œ8##tE9$ T ��(¸!„ä¤F ÀR^~ f˜bŽIf™fž)�Tj®¹U›n¶é °Ö\g•µ–cn©egvM�ןêµ ƒ˜až‘5–þXdYÆVc/Àb’Iö ~m¶Yf�>æ™[¢•FPj©^ð`�®e@›qÍÕª[t¼7Ÿk¸i‚È=wœ´›²Ì›ÃÄýö^sÀ±¦jµÝq§]wÝ] ^xè•'À·åÙÇÞ¯»þʹꮗ_»r€P#‡Ôd“‡\¡È!,9É$ à‡"Štøaˆ,àG#…¨%ˆT8@Â�¸„Ój(Òˆ Œ ˆ‚�pÔÆm¨±ÄŽ=¶€Ñ½ù¸`#�Xh1Æ~Ù@#λ2H @BN;C4$1¡ItÑF�tÒJµfÓP½ uÔ±<%PZx^mþ§žq™W\ðå^_JÙ^ˆia/P@A£cA`Ùg’Òuéd_+f§™9 Zg¡ŠZà¦Pmá ´§A¬²apÜrŹŸ®ÒÞ‡\åÄ)k¬²lÐøs¸ùöÞ®»bWø9\»ÃµÜ…Çmxå%ð-yåÉÊ.xÀýŠ»¹ëÚ×n» Ì0N0ÙôòÒI �Ô!ÿ[¼ñÎ?}ôÒO_”ÓÖ¯ uö[Q £sÕiµÖmqÍ'Ý‚B¨^éSöõÙ‚éÙÛ‘5ú™cuc*ö{wšÙß 8Šf«) ª^“�õÀ 6µIŽq w.tÑg:®©•åg[9çVÎÉ|x“þÒ]GÙÚ¶vÐ-×�+=ãj•lÖ#øˆ.]»#—~|'¨!x79Ä„J²„Q/(Ê;ÞÐ$Ä!*q‰Ll¢—r=§ioŠWáÞZ¼W§²ˆo|]SË¥ %MAÀ}‰òßcàFF%ÆŒ‡¹_d2¥ð�sô¨Òæ‚Á�ê4 YÍÆ‚¤à®Ò•.l 7ë!Ö-˜¬Ú K¶brD'Ãøp„«Ñ– ·žÖ©�…æyÍ«èã*ʰ†ëÊÀ~\ùä�ä>|âNdi”"ž—¶ì¥/ ÌèE±iT,æÔœ"�,ò |[ì“\, ¶8Þel™¢Ì]Èx˜þ2Æ2oK#ÛÞ·¸ñnaóËÞø¶·;:&p¤i W@Ø$P�­rœ­ èÑ�‹‘údÖsŒ£,å<’ƒÓáU&= N&ôªKw¾“B \€”éqí˜Ê^톕õÙÏ+ƒ Ò�Št¤$-iõ†©&cRÑŠ�²žÒR)ñ9Ó,“�ãdê’>õaÓ1ŸaÛ•FmÎÍ�•Á‹M#€NþmFm¢2�àBc@NÎ> Ä`¤~æÎW¹�dr–ÐaY2Z¼]B©•P¥h)wUB�£0œUïøcÒ¼êu¯|í«ôPšR•j�{éH ±<�ÊCÛ“‚þìD²K¤¬Ò$kY¡dÖ'˜]Úfÿå×Њv´¤-íÒ+Á™~š# 1]’$Kø¬Nhk[_–AfG4­o Üà w¸¿EmTT›=î 5@AÌÜ"E #XBˆ‹Ýìjw»ÜíîÑŒû4äF�°¨Ñ,¯pE ¡ H8cAÒ?,Bô²I{§¤³„)Â_=C ±¯‚ éÕÂñ¡‚½+ˆÅ��x¿#(¬“‡(Ȼ? âí‚÷)â/2Ab^�¢ jeñ+�2ÔD?Ä Œ †šHxY#2‚FÌØBч@…@,þ@C]òƒ–b¡ÏÈa2žòNT°ˆkyË\î²—}9â©”øMäMqFÒ<¼7ÆmØpNn\5¸Ù&‡^X‚ ÃÎÆ„h�½T@Ù�5–dˆ³› Í“DøËŒn´£ i(†{cvS™—WˆV$7^Ànõ¥“5ÏèÓû¢³ÃÔà¯4¾�ÉJ‰#uD^‡(Ä!zæé|í+× ÆIRé` {ØÄ†ô¤)]éí�X f†îÃŽH$’´`$9‰6H¦-¼”ø�#!ÉcÒgŠ$V$8Ù�e±�šÈDºÅn·»ß o½;ÙpZvy—§ËM«x@*è� V�“@”ºþþ8�1€ +„! †XĈ]!´JTÄ¢Š\ ýÀ¿q’%çBvŽ(u¼OŽò”«|‰ó¦·V.}X‡éÛã‡hKð…`Þä%6�‘"A�—ä^N ÃKrà…�$^~pðÅ2æ"Fä\ 8úM~´¡qÌä+ïº×¿ö0�Ø+Èvyí==Ù†}ílo»ÛIÚr³Ÿý+J¬åÛïŽ÷¼ëýyq—{UÈ›öÞî}ð„/¼áOE²‡×ïGûáùÈK~ˆ}g<Õ‚†ùÉk~óœï¼¤ÃÌx® óAó¼éO�úÔƒ¤ò~¿<é[­úØË~öƒg½Ü]OzÚë~÷þ¼ïºíÍŽûÌ÷~øÄ/¾±£¢øë…¾ñM�õë�ýèK¿Ã¿wùSœŸûékûÜ/mõéüÒwüä/H¿Ÿìë¿öæo¿ûßÿ×I/Ÿ*áüï�ÿü£ ý•®?ûõ€(€CÁcÖ*°Ø'|È€ è€%Q€%v€~¶~h�€(^þ‡�è�í§�ÈxH‚%È}!¨Z#h‚+ȂÇ‚‚¥‚-(ƒ3˜z/¨R1Hƒ9¨ƒ’gƒÆ„ƒ;„A¨w=XL?(„Gˆ„`G„+åxIè„O¨rà¥xówLt…Wˆ…ð–xl2F˜…_†\¶„Sä…ah†gØ]c¸ZþVˆ†mè†i(]Ø„oH‡uhZj˜\sh‡{ȇò‡ËW†}(ˆƒhKx(5�Hˆ‰¨ˆñzrȆ‹‰‘8=[X€¨‡’ˆ‰™H4†hb�¨‰ŸŠd‰Pƒˆ¡hЧø£Hf—ˆŠ­èŠ;¡Š–ÆŠ¯H‹µ˜&z¥h‹»бXožÈ‹Á芾¨lÀ(ŒÇŠÄør³ˆŒÍ¸ˆÊ˜ºèŒÓh‡ÐˆÒH�ÙØ†Ö8w+ �ßøŒ¸hyÌŽåh†Ü(zÆhŽëx†c×Tˆ�ì(�AˆŽÌç�óˆ�ç(Ž­GŽùè�:ˆRÉ'f–¨Žÿh�;X�ô×�É�%˜�Ux� )‘¹þ�··�‰‘ø�ñ˜‘™y‘)’ø')EŽ‘#©’è&y=,�!¹’39~““(I“9)€Mã’P‘1Y�:)”5)d�@Ns“™’Cé”åW”ƒP‚PI¹&?‰“O©•ä'ÿ%•ƒ'É”[I–'VB•V"–¹(“eé–¦'… •SYk9ŽAù–y©zR1Vâ—vÉ�x©—ƒéyqٗٓew—MI˜�‰z|)—…`%)Ð]‘ZY阙yzRA_9™‰ ”Œ©™£ÉƒQñ•‘ ˜)˜¤Éšyg˜�)—© |mÙšµéu�陲i}´i›½þ‰rœyšƒ ›àÇ›¾iœîÆ—Vr˜Ã™~ÅyœÏ)lœ ›RÉœýçœÐ‰�Ž™‘)œÄ„™Ù žQr©œ„P�x�ᩞ!–œ¹é�c¹žñùn†É�ç)�é)Ÿù)b¦É�…�˜Š˜¢©Ÿúe¯ù•ö¹�øI  Z\gé—}‰ "¨  J¡£•œV"™š‚Z¡ÊWÒ©œuùžl¹šj¢À…|~©œ ƒz¢/z~M‘^Ñ}i£"Ê…ð £;J\-Y™) ¢± šßÉ£Eê[-9£@ú æY’ïH¤F ¥¢å£”¤Ý™£$* Qª¥y%�§É¢7è¢[*¦Î³�6ú¥þ>¦cª¦Hc˜^:¢‹¹¦q:R|œgZ„i*§y*Šüé¦W §z ¨…8ž7j¥•ˆ¥�ЍM„›äi§LX¢‰ ©Eœõù¦©—*Lgy˜}‰=Щ�ºx‡Š©£zZª¢žê© '¡Iª­Ê¦¦ ›= «ŸúX©£®Š«f¢„ —©Šª¡ú§¹*¬cÒ¥¹¾ªªªšY:¬ÍÊ4°ª–Ç:«(ð)ªÎŠ­Iœ*ÚŸZ™$ö¤Ù*®AQ¬ÑŠª(¬–:®ë*¸é—ÿɪì*¯>ѦÆê§ê:¯ùš“ºœ•º¬ú °7±¨Ô鯳ù¨‹­mŠš»›‹°Í þ™ÓÙ¨dˆ§›§œÉ�…z™·j±úʯ{¯ÿÚ±û±8j¨Á:²ùбƒp  Kœ›²­±r©±Ç®1+® ²'‹¯8Ë®í¹°!k°Ìê³ÃJŸfê²Í ³E©µÚÁi²N«¬CË´?;¨›´Ö¹´U‹¨îº³{­\›°ƒJ©BÛ°D+¶¤ ´šµè¹µi{±dÛ²fû²h ·—JŸöʳ"{·Fkš�¹¢m{ŸoÛ·jz´_k³[¸2˧�K·Jk·‹¨G›‚› „+¹ZêµQ›¸a›¹£ œsY³éÊ·Ÿ ºšJ³Lú¸Z¹¦§îZ¹«ë¶­ëº†+·l+»ƒþK»µ+¦”;ºàª¸¼;¹�‹¸¤KµÂÛ´Û™¸»·Ç‹¼‰Ú¥ ò»ÖŠ²Ï ¨eZ¼Àë¹Ö·PAžz ¶Õ˽ršœeÛ¼g;¾Ãë½,˨–+¡˜›¾Š›s9±k¸»ñû¢R!›–ð‹¿Z”åi•¹{¹÷À*,@€c`=ñšÀ¶»&,Á7;ÁFJ‰áÛ³¬¹é¼üÁ�¼"Ì£î8µèkÂ#L=Üc�ÄEÜÄGìÄ=œÄ]ðÄP\ÅB|Å>LÅc°ÄPüÃM¼ÄYŒÄþFÜÅ\ìÅZLÄe¬Æd,Äe¬ÅAlÄ`ÌÄq|ÆDÜÄS,ÆvLÇi<Åu,ÅJ<ÇQ|Äy¼ÅclÆÅnŒÈm,ÆŠÜÃlf/|l’|l•lÉ—ŒÉ™¬É›ÌÉ�ìÉŸÀÚѽͫ Ê3*ÝÈ—Õ†�¥¯¬ØP°Ø‰°hMPÀ×D@Å�ÞÂÌœÐ`À'pžàÕÄ }`ËFà ¸,ܳ\ Y`ÙéýÖ^àÙ  Þ{Í×E`Þ×­à+`¨° m0 ]ÐŒÀ¹Nq l=æÉo )«Ô½àaâף݋ÉÝÞÝØ^ðV€ÖJ@H`ÞŽÞnÐMaYpø½ÓöË™�߸<£²Ü\�Êý­Ì=@–à }à2ÞÖaÍØ*nE€�mÞD ݉9âþüœ hp¬fÝŽPáMq ÇÝ4�0½#†н�IÊÓ° ª0 Mp•{âÙ\âúÊŒ}ÖEà‰ ¤mÞ>ÐPNÜt`«äÁ�Á�Ê4žÊû-é@€A^Ë^ Ì»}n€GŽã:-˨œ¤�JG€Gð ¬ÎèI-RÁØŽ­[`è4ð0N\Ñ_ÞåPA¯�PAákrá ­&^Áæšœ zç~¬Q1ÏNñДÙo`²º™Ðç“6â­x¾¬�Ú�-„PI�è©ë$ýz`š  n�NþÛøžÊtÀ Ÿ` Yðö­ã)@|@ïOŽþõÍËGМ` �np ŸÐG ã¡Þç}Êwp9] õÍÞG�žë=ð nÐï–P'ðð”ÀÞþ½éy  œp,`Ê–ðë¿-}ðïŽÔRŒ�ÖIPVP»Žà>à”üíª=b¨ ¥°  d� ßJ«0ÏT ¯p ÞŽ Iyìqn «� ÐŽ ̆àà™PMÑ‘° «€+ 0+P¥ðOQ©`OñŽ€©� ‚°… N¡ø  ¨p IÀሠ ª Nñ¤° ¤0ÕqîŠ`ø—j€ ú ìƒMîCkî̽×RE@4þ è‹ô#ýž)pša@ ¦ŒßF°Êuðè(ð9.ß÷^)`š€(Ðã(àœ­Žï\À Y ûÌŸßõm¼íÏË•�ñ)P}�wPü4~u0ê¦| }à(>Þvà{�¦LþF�t}P D‘ÂM'#'.dØÐáCˆ%N¤XÑâň'¼¬àØqÅ�JŠ(IR„J $DhôhÑÂãK˜1eΤYÓæMœ9uÒL� T’ˆHõ0…†£#D/‰¼J™Œ ­ ³ªÊ D¢œB-$* A§z¬Å ‘&+ ò1JÐË&�˜x|«ÍŠ*þ©Ö4iÕ´*5=`5]Q÷ÍŠ$©Ì¬£J cVW߸zÓC‰â±£€z»ÓógСW¤]š±ÇX©U¯fÝÚõëÕ>QR»H‘$P!Q¡‚ˆ,0'^Üøqä(¾T"H'Ï MNPÐÙ£pa�:¿4â Ew)NÔÁƒ"ŒÀ0”º˜§ŽõݹX:2ñD%0{x:r„“ø>ܘ®¼�.…#¼óÁ“P8!ÁP¨ä‹‚$HO,ÉϺä:ôðC%Ò(¦ÚJ,)Š?ˆX¡Xba´�HMFœh4íFq\!68êá•*ÉdÅUó(‰Á8:*©V@Š#$—þXaÉPYlN1 X|ð¨B@1ä¦7PéHRÈ$�´H¥ÁÒ*씎 $ª¤ŽJê�TFëá‘ÎiGÓ±ÆÏlŒñPFOë6H#…T6‡h+‰Ûšø£)VP!8BuTR‰C íªÃŽ.á><ñDÀU:U„îR˜ÂMzå$åú8!Ž=ÂÂX¨»>A¼ˆ*áÂ:N²@¡0Œø$B:”‹�,Ì‚“¬ãOBk¿óD‚R8B K•w^zê"¦'F"©ÝTôÔS¸&E•3:Z… "Xib�QÚ„–UVXÊ_*™j“UþRñ˜+¦cXBæ(”7ƨøKUV鬦7@éè�PV0£”Iêǯ “y4GY!“?:*ÄŸ; úÈBPÉÌ`¨£–º£‘•ôj¬)mH$‘h( $V AÒXè´^´Óþð dªXÿÞm�C°K5…îdp ë¶;�½õº#;‚º3"> �6Þøû›7òP¨[‚*…"Jèþ'9zdª˜•ڣߩÐ}þ›p§Ÿ£ª9ÂzÿHµfH La *yíþµÔ%°CtKÎÅ#fXþ…(0ê`ºüÌh«�„í\ a ÃÄ›/}³&ݧm}Uq‹]LÅ×ÈÃðqˆm|c ¬Æ¹+±‰%eßYÈC&òocœãçXÉKfr“=#†û¸¾(.r•­|e,ÔÉ[îH’¹üeƒG=–2l€œe4§YÍkvˆxÅüf8ÇYΫ1™Ë;;;&7CHAKT\!e!#j#DKT]6D,>|øëïÇr¾xýAüà_|ìIgƒ€× hŸ üÝÇ‚Ã]ÝqFÜ!�Ò¹çàwÊÇa{ö èˆÖygÝtñýpb†þ) ‡ÃAÇÔQx_ ÷ž}õ©xœqóñ€`}7š7�‰ò˜£ŒÍ ¸wÐX„õ¡§ã‚èâ€/²ha’ݧ ŽZnDƒ\nYœ‚ð­Xa›kâ§�Xšxá‹Å]I܈Ǖ‡¢uä�$�êù#–>*÷¦rl"•< '‚>hbŸ+bç   Æ™ |á5×{áÁÈe‡=®Äp-Îyªvþ,ŽXˆmI£~l®7«™Ýí¸#t[fiC…íeÞ•zÙÝ®%jÊ^z.Fù"�´¦(Þ³›*—]§ð…`J!<ÇÃ�úÕÀ¤ŒÕé§îºä²Ëª~® %»�.Çà¸òº¡™�V«!»ñîë߸jˆoŒÌ±«!›é^«®{ÿBìîÄÿ;qzQz(¯ƒ ÏyaÅ«Â;ç¶·©n®¬Ê²ºË5÷ŸÂ2×0–2¸îÌ—¼1·9çû²Àfþ«¦Î⛞Î7»›0¾'îLsÍþ¹×¨œS«{ðË=S\µÄîæêŒ Ëë^r�Vý5–â™�5®öwêÓ@¯[oÆ)"M°Éý"½îþÁó °ÞÂ&m* (! ð¹2š< ²®Æk±ÙÜv¼s¥’“gÍL>LµÈÿzWæ€KƒÉñ¹PÛpØ–}1Ð^5€y¿ÎfæÜæÛ\¾T<:Ô+›³Á‰Y0tóÑyèïì^é¢ÛµËéàƒÙ>¼µäù}Ùj‡ÕãŽ+öšë¾½óŠ×0ÌÌ&òq§ô¼ß{Or¯ào|êGç‰ïÒ•C½dÍ órÜâp'�Ó•NNË“|èµxÍO{DBŸ¾Ä�J¬lÄ[YàR—¾�iJqñ©Jü†¥æ˜€9æÊ_ÉÆÇ2¸ T-œß²Ö(®]€³a '¹ºkmþ"ó¡õCÂqˆ,¡æÞ¥·qÝîˆA„¢§HE¹M1‰?Tâ�³¿©o‹8,ÙÁœx2ŠÙ°ŠHÃ"­·D2n�:;#Õˆ´ÆÈUúñÁ làÃÝ1Ž÷ñ¿W18ò�YDä ¹¨ÈF 1‰LÓÒIEJv p–t$"3¹ÁDjr�t4`É8ùI!JŽŒ¥Le áhD§±pð©ÁºŒP„#øàBÀ%vI!�¤Tå e)ÌbS]ļO2�ÉLd6ó™Ð|æ2m0M*V3šÈ¼&6U©ÍmzŠÕC#HÁ V�‚`0P7Q"Ks©Kl`CæÙ†1ØÓžbþÈg>�ØMiÓ‘Õlç}ú#P.“˜¥X?‹9Í…R3¡Bt¨»ªM‰RSgu&Ò ¸xT™ë èE­9Òor³¤&�¨BC:1‹¶tŠîT¦C¯YQŠ–Ì¥©D¨§à‰Wøô§¯p(¦ðщîšA�–z„# ¡©Gè¥/µóÒ‹¤±©H¯jU•–+¢-h7ÅZÒŠjn«U'KËzS…fT É«A±*W¬v¥k5«3? Wvõ³®YÕ)^¿*Ø´¢”¦,mè`¿Ö¬ª”£…õk[¹šÑ»fÓ°Œ]¬V{W—’±dÕhGÙªYÎ:6²’5+W‹ÓÁ¢¶¥þ,`M؆&tµõÂ*€êSWøÖªðaõÃNxÔ\îìA”°„#4ª¸‚„„>ž �±å�BQÎPø¡\Ç ìHûJ^«’—¯áÅîxuZTõº×¼�}(z×ûÞù~–¾]=.zš^ö>T¾p+ÛKÙü8½òý/~õ`ó²7ÀqÍïUà `÷—Áê=°}õRþþ—¶ð…ûZaìr˜¯N0aQœa¸½®m\Q¬àñ&8Ã6°x],aðž¶¼-®ñy a Kø¼761€[lbÿR¸¾%v²ecy\ãNXÁEÞ±{¼å+™ÅHÎq�O¬U'ÈNPÅþoyÛ[àRÆe'†‰¹æB7º?|Ð4gµžðmP}ê‰"PyΈN4ƒSìeEï×Ñ�~ôœ—,â%GzÆM¦4¥›¬è?™È�ft¤?¼éK›ºÁ–Î4–S}b2+ª0áQS€á¼lø§?íQTx¡ÓúmÄ!=i?7á¬>µ²—ý_6xÂ]¾4±¥-ãSO{Ô¤Þµ©oœäOXÒÌV6±=}m '»Õ‹¦ò‘E½ìIYÝçfvO]á L°�¦w²åŒá"á©PEÄ|)]ô±ÜÇíiP]TOÄ»Ý�x¼ñŠ[üâϸ´5ÎñKûð)¯þk`ëIçúÔ¾65#Ñî" ›âŽî+Žë‡R¡ã5p6ÌqÎóžûœç;÷Ï9tœƒ� —ÃO1aL´â§�hzoo=i~×ÀW÷(ž Õ%‚ >ž®h?°¨rºÚ×Îö¶»ýíp�ô!�aSŒÃ'7u)îia·[ ÈÆ¸Ì=ÊTܼã6pvÜÏøÆ;þñ¡îßõ †.œš C¤TGÔÀÞ¯ˆ:$~êŠE$š�&ÀºGUoƒ;ûr ƒX‚žõØGý¦þ¸&ÅÙ Õg}õǽ.ð‡ÿû9—÷XO>|Lp]ßß>õ6¸=ð£ýä#÷«_þ |þ_|ç#?úÑÇ>ñ|ü”ßü×~÷£/|Û‹¿úÞ§¾ ÂuîÏŸúà÷þöŸï} —¿ýWô§~Þç|ÎGMó—€(~«§zÒ÷€¾Ç� ª€ �P†uU� _…  ª`kŠGrÂe‡� ©°ð‘wDð©  ŒÕ Ð ©€t§ðkS€ \€{�ÐÃljk¤ y5@˜€ ªàð1Ÿ€ ¨À5 ­Px¨€ C`}ÝG~Ãg3¨ÐÇÅ£° £`„Œ0˜° ¨à5 …@XgP†gHxšàQV ª@ w÷| €�X²¤~§F€þïç{Í÷}©'|X‡�H|ýç€à‰ô|ýw~Ç瀘˜€ 8ˆ„(‰ŸH|Ž8ŠhŠŠˆŠ…È}úu~œØ}“x}ñÇ|u|^À F˜}e° |wо'¸X¸¨…îW¤À[L 8z¤W ª·}¸guZØzMEp¾t`KÖ� ¦z¬TS0@5s¡xŠ[(Š5ÝØ�Ô·Žî˜uÖ~íg°h{x�¦xŽúxiôØ€—h~öGŒ¿g}�ˆuÜx\›ˆb÷·�sF�…ØŽ �î—zñȉ ÈŠ—&®€tM€ fP­ps6p eÀ`sçQrðl5°w5@ŸÐþžp9§k6  Š`�ð5P«ð]EP9è)÷{€p�>˜ b¬@’¿¥€tšæBT.§‚M‡©‘ýƫШ0Õ©@TºET�  fÙ D`žð²d–jYgY“5@ª`5«@9‘ɇ�ï赈�‹ 8~ó‡}Õ·|®x}¹ü7�Ì·˜†ˆh”˜€‰Žý8�Åè™Å§l\ù™­ø—�I‘ŒÈ˜¡y\»xkªÇ—'9~sŒF(Œ± �œ¸[@Õ XÌHz¬`ˆª‡YœU�¾4]·$@0.'ðœÏXœ©÷�?Žãh‘õ˜†þ)�Üy~êx|›ˆ›ÒIˆü‡�âY˜ÙIžÆ ˆé ‘¹z’É�ãY‹šèŠç×€ò™�“y�æ9Ÿ¯ˆuàY‹¨€Ø)Ÿ  \8Ÿø)ž�‰‘ê~�Ÿøé‹åÙž„W èw<) �``ùPåwZ™s,™ƒ$Gešk&P®ðPMà =@†üw lPryžÕ‹P~Ÿà†Á65 ž`ÇårbXr@kë™hZVÐ ¥À Du|‹@“Ç÷4ÉtçQ©`¤‹à UZ~w|~Ç¢r èG fPžšŸîÙ|ϧމ~æ—�¹žÚžÚ™zÊŸ·çŠã9‹þžX¨ˆ¨…ÿg§ j¡zª�ú¨pš§�ʧîÉ�Šžà)¨ê©Ÿ}j�ÊŠˆu`Ð ±Y­í8© ŒePœ´9¨Y§›¤×›‘ÀfÁ �©Gœéw{Ö8]¹ÔBÐŽØ ý©{»÷ ½§Š÷iŽ—Yšï‰�º ågžÓÚ ²8¨ú¨Ë*˜êW÷ù­Ï·‰ˆŸ© ­Ö:¡È¨èç§uª§Ÿ¨€|J|ÞZ­éZ™Š«W—��ê¦öH€Ùú¯µÈ @sϤ0¤4¾Å“ˆ0¢'gvWÀ…©€ &À¢]° ¬÷ J0‘Ч` ¬`šŠåÇ4éQ�ÀrSðþŸð ©@ÇWs `yTøP+™£‚Ð[AÕ ¿Õ ­ ¥M0¥ ƒŸ¤5pRx¥ÇewXWs¡à%€ Jk@xrƒ`ƒPø“—­¸z®êé{߉ž›ÚŸûi®éY˜çê{¡Š‰õ§¯*©Äj¶x:¡’¹¶�ª·iûŸüj­ªú¯€ë§‰J~�º¶c»·øé­âŒ`К¸¹ ÏÇ”b€‘ŽË©õW ¼%zËÈ[ªŸõGœÊ½ G¬$�º#Ÿ*Çú r0·ɸ¡j©™Š~O ™ú¦¼{§œÊ»Àk©ê*·Æ©»¹k º»®òI¼Áû¦YžÍÛ¼¹«¼Z»Áþ›žÐ½Ú‹¼9g µxsx \� Nà¶K«•û ©µ¨`yÊ¢Hà A€u1jiŠ}§p…×é¦êøƒå ߥ HW� ¤èǬÀ.°yºÙk¾iûyΩ\½ÉFŽá žàîßs­ÑìMÐ?nÈ}lÕ>æËûäoãz}æm—î]HÀ >€ÓÇkÒ~ßA�Çz<Ø` åþÕ}»@þ§…\½ŽÓH � ½æô™èvú�È›æNmè7MÈhè®Ñ@ýÔú}ÓO>ä¾ÕâÊéÆ�Ñó=ÃGÎåÁŸnå�餾éZ>Ø Õ#žá/ Öã,Ø›>êCnßOíã¬þ×’ëºî§M «löÒªÐH^âX€{¶GzdÛWgì­ª+ç¯-çÚêc�íÞ>ÖÈ]Õu^Õ÷ýíL-ÉQ~×ä>ß\ÝîíÎíù�íàîUM¸õ.ïä®îgîîóžàÚîéðÎîçžßõžÜ¾^ðþýîîþíP^ðÞþðùŽÐàží¹6ï5 ˆàéûNðØÎñ÷>ð�ï\Ö ?þïänëùîñðþç,¿ñjžòOðænï Ïïøîòßí™ñ)_ò5oò9®ó1¿íù>îâ.ïHÏï ?ò ñJ¿ï3oòIïïZÞñ¡Žåæ®ôYõ ?ð6�ãî9<«ðÒg÷•?ª%ô"€Æ$ p!ð!° p÷ péðíòQ/öM/î0×(¿ñ4ø,ðDÿòROòÿ¾çIßõ ðô¾ô+ï7?ñ7Oódmó†ïã�¯ø_øiÿøšÿóæçlUý�Q·ðŸ�øyýê^ÿòHùëóö>øxMõ›Oè¢_á³ó§è9ßï~?ü¬ù„ßóN¿á9_újþþù@Ïñþ.ð)oôU?ó¿_üª,ôœïüº¯ñàßõÆÏ÷Êÿó®OùÝßóŸýØ gö„6ÌÛží(aø,~ÿg<mïöÕnøþÿÿQBà@‚ D˜PáB† >„QâDŠ-qQãFŽ="ÌøQäH’%Mž´åJ–%¼("µj)E^™SçNž=}êdHÂÄ@•-�EšTéR¦M�>…UêTªUŸþÄš«U®]½~VìX²eÍž«UíZXhݾ…Wî\…E)Ú¥›— ^½}²üÓï`Â…Ëò5œXñbÆ-?ÞqáÉ+7Æœ™²fÎ�MBþÝÖóhÒ/c>=7uâÕ¥§¶NòHØ®×^ˆ»©nϼmöý›e𓲠7�¼#qåÍ�3ÅÍüy×ÚÆ‡•>];ÒìÛ5óín4ü^ÞßZ òíxóÙÛ~ßþh|ùõ“¢gkŸ.}èúiû°¥Ëø£Ž¤øð[+@d°A9".Õz°B¨"Lé.ÕÂP=÷$³�¨'Ô*¥¡ìp© [±¢Q ñ«š‘$³º¨ÆÜb®(Ýb¯yÜ‘H“˜kíÆ­&aȳœ4’C¡Lî!*£œ.¼uTR°‰H8"J8Å&£0‘£1$9I LÆbC‘þä¤%¥ êF¨�BDÜîÊÅ-ˆP,ë²§‡$y¥ÑW©a �ÊüW\y¥LìB³„"ô,�Œ1+ê¡%"åŒ @e)WZa¥”Cjˆ(Î9iR¤"0Y…•PÀHHT�‚V 0l¸Ó¨@"!è�Fb¤�C#šÌP׬EÛƒUÔ!I1!Ü‚L1³#@ج!Ý‚jè4$P)â"H¤h VFU–Mš åOˆØðä Z ˜!?Ie‘lè"ÞƒÞuXEžZ– B�Ößi�Ò6Ãç‚‹JB¹åiQp:âZ)-áŒSTÑÄaŠ*.èSTI¤S6>1ÁWLþ9%ˆŠÀ@e DžÝ¹ÞPTŦoj$•zU±(H2äÙ¦øDSh OT9ìl­¡¤Q$Tü`ƒU"³ÏRPñÄaT¸n¥F)¡Rü(Á RR9…ðŠP)v  Açzþ9qUV9å2mb$L¼fc @^VD‘ÐÏ8¼Å=Lˆf�ÁxkP˜¶i„ƒÙh¯§ ¶ŽÑÒÒ,C‰û�4’#óVŽ;SžÔ¦.TY¢„B@©šY‚Ä@¥‰–]IàjpEfŠjHEOòmJ:UÊ( U‚ðÝTF™¢„@©šÍ� …ý R9ƒ§J‘¦ÿ•¡H(EþÎ&§D@‚Vlp…½à HØÀ¡„@¼`ƒ<"v¥ EÆô· ‚Í­pB ‚ >‰"„!Ä'¦ i�Ïa…˜XóJÐUÔÏ ­ÂÀ�Š&˜‚~8a WX½f-¢êcV!´ƒ�áf(APÁ¾�¡¤x+™Ⱥèâ(Á+R±Æ)áC)“í 1,W¡"�pÔ+LjC„„Ý ˜ ^1� ‚í3…@¼Ç†P ¤M¸—>Qˆ?a‚ ÉŠH Z±ÉWb(Œ(A©Hê�„ÐĨØPŠ�´ÂL+ÞD&?dÄv¨¤W hÂBŠœAªˆ)Àþ0”2�¢6Ÿ@p8S詇ÈÞv¨9�üQ—Šà¥D¡FT¬‘¥.±¸i­r …Àß]´>_¼PI̘“E"÷4�‡R ›h‚¨è&+R8³«DHˆÎ>qÌVA#SPE©™4A´ ¨XáL1…D¢ ¡¨+Š€IˆTÌB€þ¬6¢Ù¯ .I$V¡ Iœè Ôˆ@BŒA E)¶9�ØNª0[à ‘ŠG8ô"&˜B)êÔ Ut3¦0� …&½•œÌ´ &V§ �ÈæC<!°õh%„EªŠ4-âOlÀi ü°þPuöկ͙'N•Á�è³ä*�#4všÙÒ¹ ¿ç §^„fh…öÎ09ð• �;EWQLÆZ¯}­8[#¹Ðpvöl£°)̆×�è´„8$&1&ïU ŒPDVQ¿�Ø@CÕˆ†X‚˜ ™%0Dìˆz°`:sŽ EšRƒD w"®+Á!ÙÛœ¨w=Å@á@ýµ‹ðtoqûLü� †¥”Rq…òZ!ß ×°w*1po³YEãªV ÕX}—4�ÆdQ³Œ5ïh½ @"`ê `J:ç“¡³•XQ?òÚ–§%Eèlà‰þ¡²ìÂw5l@\9äU"6(ÃlPPH«Ÿˆ—4›àòþ':…35† =-^`à^ ¦0”3Q½Ïzpº$\»ží¡kÂúâ»f6g&°¢iˆ$ì[XWÀÑL†S£t¿{©FéO7KÄ"$«W€¦õ!DpÅêÎð‰Œ8Á¬P…'z ÂVЫ ®Pðw3EÕFØQvª(Åœ?êOÊél¦èÁ�»Š�âY^(…(,ijæ^¸N%xÝ<á6O t"6ÀD*\qŠBÐÊ…0Å*L¡@½šÂEÂØqX›|µº ÄË�&h ž ‚—_;±UB+sœᵤþ¶ÙÝïöË›¿Œ€¤ëDɽ-C”‚1EßK’É�×!ðÍ‘€Ø(± Ü+5’7¼_ãp�a ´+*»ƒKÜã?Ï|Aù¨¢C¿•’´õ|„JHZ3s+Èå¿yÍ®ó†l)!|‰øÈ…>t¢]B"—ùÈ)nt¦7�AAwzÔ¥>uª�êUÇzÖµ¾õ‚\½ç\;ŒÂ>v©x�ìÜ9{ÚÕ^³ÛçG"P9Wrµvº×H �:ô¾w¾÷Ýï|à?xÂÞð†Àáx`ñ�‡üßïøÈ žò•Ç|æ5¿yÎwÞóŸ}èE?zÒ—þÞô§G}êû®‘¼€¯g� R0{Ú§`)€}îaßÞ÷Þ÷*~ð…_{Ú?¾ï= xŸû(ø-H¢/}ê§@ø×~ì_¯‚íï^÷,p0þñ‹¿ü)?Ô¯~, ý €û � X€þõ§4° Ä€ÿû—�˜�@”�@Àþë?ÿCÀtÀä€ÿû¿�� ¬@ ¼Àt@äÀœûË¿úA tÀ 0Áä€Ô€ �Àý〴@ ”Á ¸\Á¼ÿ[@T@ôÁ à€ È ”�ËS½#DÂ$TÂ%dÂ&tÂ'„BÌc=¤+½Ëþ=¸=ë#¾ïÛ>äóBì>Ù3¾Ú[¾æó=ïÛ>h¨>õ‹¾ÙCìKÃÐ>Ýk>å ?˜õ+?˜>7”¾ð{ø[€x¿BAüKÄÿ‹@Ô€4ÀHL@äAlÀœ@$ ´ÄHôÀLDL€°D<ÁSTA¼A¼ÀM”� ÐX¬ÁŒ@äJôÁB!ŒÁ(ôÅ_Æ`Æa$Æbä¼)œ'‚È=-$¾ÙCCå{=/ü½8”½g¤=䓯åó¾54¾6¬>k>Ýã¾;œÆÜ#?ó#¿ñûÃ<ôCB4€�?û³ÇD$ELÀH$ÀtÄIþÜAKÔGVlÅ”ÁRüÄH¼¿ÁRDE´€ÿcÁdÁVÄ@¤ÈÜÅœDäÁ\üÁ!´@¿ëƒà¼à;Cøc<@HC`ɘ”É™¤Éšô<Œˆˆ¼kØ»FÛƒCÚãÂÝ£FÞG-Ãk,GåóBiܽo|Cé{JqÔ¾ì˾s´JuL¿òsÃô£> p¿B$DB EEŒÈ ü¿DKO¤Äý£ÄRÔA}tE"tKLËXH²$E‡|HœÈ¼È¿”�TÅ\¬Ä·IÉ ì»020ÉÌ‚/à»AXÉÇû‚Dˆ–ÇL€IÔ;@`É´ÉÑ$ÍÒ4ÍÈCFþ3RFfœFlJs´Ã¡ôÆ8¬=1Ô¥콤ìB5ìʧ„J©¬ÃØ{>¦4Ã×ÓÃãìC?tCô[? h?zŒ?ø{€»$Ë ¼Åþã€$À œ�JTÀ¶$ÁëÄĸÄÀ¹LH¼¿{¼?·ÜËTdAXä¿ €AÀ|EœE�\ÀüüÈýÉŠɽc‚>8& ÍcÑÔ»B¨ÌÅû€x”ÜÌÎ<½8�/@ÐÓÔÐ åPÒLÍãYMžtFŸœ=: J¥JqìÉg,Î3„M¥ >ß|Ã7´Fí[�¾î ʘ€>TÇ® ¿ÙcGB¤Gèl¿PLOýÓÇ(Àí4@€¼D·DþÀFôË‚”K„øCè¸Ó<ÝS½ãL¾ÛÔS ¼X-„=íÓ?õÁó‚ íÐa%ÖbõÅ%™Õä¾×sMh´C¡LQk,¾ÚÄ=mL>;TC6”Q7”JÕÑàd>ãLGwôçäJ¯Œ¿"]X{LÒúã¿·ä?º„ÄüüNðÔ@}¬E‹þ4HƒìÄ,u×äR½lO SùœÏ1µÏýÂÌ€¨ÅëLÀ‰dSÿ¬À7ؽë@ÍTÀkLPE¨ÓB ½ U8P¾#„3€`„ÄÛX8F@IB(PxY½ãÌ,YÀë/H0Ô3x½c”ÚíHÉüØ�ÍÔZÝ;V ZF Ú¿«ÚDÙÇØÓ'�L¬âUÙÇT„¥}„L“$Y½Z½x‚BH<¿SšÕ;2¨T.@Ø»èƒB}„8�5H_X ÞY5Þx„ìý‚Yõ;OÕØá-^?VaMÛÿà†»ÝÂ=Ãp½ÃÅmÃèSQž¼>Ø„ÍãìQñëÑsmNõ[×°ŒÇy¤ÎÉ�×[„Øítá¨ÝÂÈ�S×ÅÒó$]XKÜËúcÁF„O!ìÜ+µ@ ØH‡�]¶¬ÝÄAŠ%þB8è»/xFxU¯­L2HZœ.HZ½KÙ½;Ù¡e€GÈÚ=-„*žÞ”ÔLK%ÔB(T&PÚG¨T¨[ -PB`_õ½â.6ÞxB07îâûÝcÐb.¼²àEfäFþ;NeÜÉnÖUCÙ”½~àf¥F¦Nžt[¨|J£ŒCPÜnÜÆ áñ›€VaWf¿xüJvu¿ôÔaûûG*}DîôÄî È€,EìdÄþëW¾á„¼ew5]}@/ýÒX„EX¨TuvÕÁÃPGÆèŒ`Öa¶•€�–€yé‘ÄxŒÇx€0€ “fé“6€–6éˆG™&€›ni`éh€èé ê•ÞižÆé£¾i–Öiž6j¦&€�阆ꖞê°ê«žêÀi«Nj¦öjžÆê°.¿£<ųFë³EQ<€¶vë·ÅH¹FëÒË»ų¦ë¹žë¶îë¾~k¹v낽€ÀìÉk¼Äv¼´Þë½æëǦk¿vìü‹k½îë4x €/`zöXþ@?ÎÔ! „Bà; _|†çW�ÙH„š…ç¥eîíç' ¢ P”}Ú âÎ~hßõ=�„6ѾÔLî{îƒ'€ø€®õã9^îÑæZ½3mÔ¼Î&ƒìÕèñ&o…ä.QFé�†€˜Çuéhù–¿“Vi•^j“6騦é˜Fê£êêû^ê¯þï£Þi�Vð—žêœžiÿîo¬~ꪞê¯þj¬€¸j €Éã»ÉNë×kÃvëÇñÀë×a·lÁlÀ&ñ `]Š<Á¶€¸q·VìÆÓ»ÄNpìÆñglËþëÈžk xþÝåE½Ó\}‚—ÔLx„A€èƒDˆí‚žmC~TBèÙ0î€xÔ¿éYM½LFèèÓyfT Èš3ðÞ©€BP€ä†Õ(7û€!P„í2ÀÌ5WZFÐLCÐó)¯rÁ‚Gpt9.ïH—t›ð�€·ï®Vê/ðYoê—öðTë ·j €”¶ðëÐp«Þ;#Ô;´qµ.rÃ6qeEWq�ëGr¸lÕ=ÅÏq×qÄÞq—ìÆñGò¿wÉ&I}ö»í>Mw×öïqjwywöÊNë¦ùúãä—·ö#'q¿~HûÌ€žÇñw"oögGú—/w¿#ƒ3·b¿‚ÕŽ÷Í¢À‘}0’“ ƒ!Ñ€ü˜È ( å $Lô„!Œœ!Ñd(bH˜ ÉÈ’!¹•q¡âŒ}úù' � :¨r5Ƈ#¢÷ÍD�rÆ02 DONDDº‘¥}‘é¦V’!g"PvÀ“0@™’ ˆvOå öñ…ª‰¬ú©¦ÿñjF^ðI(±Å{,²Éºh¨s‰Þ¨#�ö'#«V ¥§g`Ëé¶_�±­•qž±e�Pªi§ƒ„iˆ™)ÀHwdø…¶y nF{*»/¿ýúû/À.1Ëœþ³Ï.:‘´$ ëDF²ù"†l©Q§b~‘H!_dÈd0ù�!1ðÈD}lùˆµ•kH!„ø¨éÅK¤@Ìc$lÀ9ë¼3Ï=;pM#lX0‡Ð&üÃ×Zt#1‚/Lg§D}ØAÈa‡m™°KÑ»IMÑØù y>«½6Ûm»]¨†BL´}Fÿ×ÒL$HœQkGB"©; n% œ %ȵ5¼ÈJ«­O Hª…@"i~Âáàç–+äÛ©«¾:ë­g´Mt×}ðŽw+œ4$ÂÈ–OìÞµFºó.ùïínüÄ›qb]ÑœÀKžþf‰µ¹{«úΈ í^Ÿ}FCì÷ˆ� €é:ùå›þ¿°Ï';}ЪgˆÞèË??ýõÛÿºú8±�ûéq|?(À®-úÛŸth·ø°�| e@™ :ý‹ 3¨Á ¾m‚C«àO.ÈÁ’°„&$”Aè!ž°….|! '”Búd:û‘Y s¨Ãò�F¤a ö½I*~=<"“¨Ä�ØDn†bO„"€!JN§š¼ )7Qm‰^ü"58C(êĆ«úÊ@ˆvýïV_ÊÂ(Ç9Ò}c$£¢d€|¯JO`Ðòà°/ ®Ž†<$"ÕvG2Jþ1� D˜Ð4.dêT-3„!ª”ÈMr²“ÉÊŸÜðXF!ÆO_Ä ¶‹¤Í“®|%,W´H( …�PÕ ˜ ‘!b9Ì©Œ…Œ%2“©Lö̈6ÔO"p5³GhrzŒ¨ž�Ñ·er³›Þ4sœ˜!QæD„‹ã7Ó©ÎuúЀäÌc 4@°³žö¼çr~HNs²ìnøü'@Š!}ŠÒœ�P+ªP6“†:BŠ"ª€Ht¢¸(F3€`£mÙ(G °µ¨¤&iI;ÚQŽNd£{< I×R•n4+�Kq*‘s­¤2¬I€l#�°†þ5. *RѦT¡–d4® n€ªмæO� Q­*’ïñg¡^mQCU8Àh� QŠJ”­µè[WZ“ªe¤ue H_š×›ª´¯zÝéKIb—˜¶…¦)½éqªS—�&!X*l–šÕ¢bµ%H½ìeI€³yL@KŒ$ƪ”M(Ežz‘>l³"$£Èk¿*[ …„B1ëQ$0ѵ¶®-€F°Ò›†´°l!ib‹Ó¾ò•¥ç¢OC—˜’´¹ÎM,v£›¨ú4³‘͈g){TÌmCIQ�:Ühä©W•L{+K‚ ­m˜kg«_ Õ¶‚þ·EDy»[‰ £�«MWJØãŠô¤fnG/ÚÒ–j÷# >n[>ÊWäº4»*Iw�J^� ¼Fe%jÇ#Ì$uÄè /žZÇ,„•qªiå{‘ÖT„ }èFu‘ØVj¿F†³Äy@<þ·Nv”£ å¸à0°2 ²¬e,¿ÀÊ]¾2 Âf¨@ÌNfÁ™Ñìd-³¹ÍlÆ2 ¨ì'ÓyÍ[ns˜[ æ=ë¹ÏyF3=¤€Ð˜@•«ü(wÙ˜À£ éGsF3ˆA .�éMã Óžþô B-êPÓ`285ªSMƒU³ºÕ9ÐAb-kXÓµþ^µ­kýé\wšªùµjt ìa;Ó8ö±�}Ôœæ4¸@ d€iiS;Ú©¾v´­�íiŸ:Ô©µ  †7äƒø †_عÝê/›üd)ÓÛÍoî2¾ó�‚2�™+ ³Ÿëf{oÎ\Žs�ç<çdÎ ‡ÁžÑ,fÐYͧ€]èBO€ô^ô¢=H�\Òà@¥1ƒiM_ZÓžÎ`îéR“zÔ¦¾vªy�k\ÏÖÂŽõ­ƒ®s[ïÀ¾€‚Mì¥o:ÙÈŽÁÑQƒ H[Û˜¶6Ω]õjsû樾ùÍwð†TmOèd7ÚÝ­võÀ{MFþ3½ëÝfƒ{ßW†x ú]f€çùÌt&øÜþ‚'×ùïwÿrÃ%®çˆ[\ñhþJ  yE':Ñ/(ù¤I.Ì œ3Wwº±_Þi˜Ã¼æ¦÷zÖ[�ë[ÏúÕ³Æ5щ~ëOÃéˆúÒ—þôdû×ËVͶ¯Îí¬ŸzÚÆÏº·eö¼! GÄ#X‹"€úk¿þwÚÎ>yË9îP>|à¯üå|‡9ïxšÓx‚{9ár¦²½±Ìx‰[\àzŽ< �ñ@‡¼ÿŒ& É�ÉUÒYÀÊ Ë�§ÙÍ%ñÍ^«­¬ÉZ¬Màê±Z¯e ®]Ô)]î [Ë…`Ëýžjþ,ÛÖe›Ö_µiÝÕ)Ÿ º ©ÝÜÀAE|Á0‚¥]‘afA1Ù¢˜UÂyŸ <œÃ‰_ÃÕÝ °¿ýò›ßU ¬Ÿ—=œ–^á!¡BÜüýYŸEáþa\ ÆU^£EYf^l^Êà¦ÛÕ½œÌÅ0ਡÖ¡Zî T ëùáì]àΜ§u j|à°5�±µ\ž×=¢ n�$Fؽ`·Í@¼�P„ÐŒ&é`õõ (&‡öÉ÷á÷¹—å+’¾â+.^úÙ&áÝ-œÂQ™“ÜÝeYö�ãõþEÞþQ^”5Z¤`Q´¡†àþçÅ~šÌ•ZÞ¡ î¡«ý\N ÐA !n ¯""‚  ³• ð Ÿ:f�ÕUݶ}Î�¤Á`À�0* Y~�¢?ÇM(™�ìS¢�¡bÎ]Á�Ÿ—©€ù¹" œš1ÜúYá–a!áÝY/.!ýEÜN¤“‰áþAôÈ1Ú¡IšJR3²œ"jÀèíZé‰Z5: ÎEàþ¡NæöÚ ÂÞ­=[Ž£,b9¢ã9^À#¾#Îa�ñ1¥×Å TÎX@ó¤É•L_?þ£WþF)Ò�Ž$*¾™Bæ›øqäCò[™É¢V¤"^..œá—ñþ™GÊ"ýµ¡ÚHz\å1”©dæAÚf Ê)"Êd4Î@M‚Û5 "Ïù¡Ï½šÎ%ì�PâÞ8:�gj€!B›:¶`$N¢¶Á#êÅàT±Ê}­›k~%l¶DXÍXÎR!â� ðÛ>a[úÙDÂ¥*".ê"EÜ/£ýmœtz\ÈYÞ¢eaN@KàBc¬) Í=fSê!6ÖÚR`òš ÎÜ �8vfvò°-d@ñͧ$_ð%¢f%ÊÀE��ñXõY_l¨IÌfÁÔ& d/Æ+â[ù�™+*^À¡\ Þ•gáUáBvd^N¤Åõe þŒÜ¡•¡`Fçå)㤱¡f &*¦4ÆdŒZMFbOºÚ6¾7 �yþ$®'"Š g&ej\ŒæñEâSÖ'ê]b¨mW (�FiH¨³ˆPŠÇw¥ÖH\©IT˜‹p©”†i Qi¢”˜ž)šö ™"Š™¦©›¾)¿¬)Ž´)œÖ©�ŠœŒÝ)ŸöéÏü #±�Ÿ*¡¾Hžv�ª¢.ê»*- *£Fª¤ºÇ¡ ¤N*¦f*xTêì쩦~*¨’¢£:Ó¥†ª©žªlŽªC•*ª¶ª«Ž„ Í;%ê«Öª­VD�ä’ª§Þª¯þêEpj}Ð*°k¨ kû°ªþ±.«©"+ÿ(+³Fk¦:«A«´^+£Rkõ*¶v+¦jë q«·Žë¢‚kY+¹¦ë›šk‰«º¾«�²k¡+¼Ö+�ÊkŽÐ«½î«WâkPè+¿lúë(¹«Àl¿ªªX,Â6¬‘l91¬ÃNìWÝD tA ü�ŒA,Å~ì‘Ý„ É‚èê®>ªÇ‚,ËÊÖMüAÉ‚ÔÊ~®lËæìAÝDÌ’,Í6KAI¬Îí7ñlÏŽ@ÍΪÐ-Ó*ÓM!É Â@-Î6-Ö­MDmÔ’lÕÞlÐòÂîî.lúÊåB㞯m-íúίêmãZïMnøÒ/ÿ²ïÖJ­à¯Éoÿ°ššnÞ:oþ®®7ðùà-ã p¼°Wp¿àîéJ­»þ[°# w¯Ç° ³Íë°ìbï ·ð×ìzí ‹® ×°ÎàíýïõÒ° ÷°¿,oónðöu°±,!°ù6Çè¯71Û‹°›";±#Ùÿ&ñ÷òîþ^±Ãåªð£o±1 ° �qü¦ï¿±ƒ´ïûÎðñ±£±ðŠ0þ’0ôÞ±W÷zï×ñ2×Dãæí‹e²#åê.ƒï#W2¥šnõêð÷±%w2zà.× ò&³°'—²Ö„ÔÂí"Óf#›²+‹sí*h+¿²-wD[î,Wi-ß²/T—ì¯'ÿ²þ1¿DùJí ìr™öò1s$Ï1°?³53‘éš­&ó1)_³7“„çŠñ Sò7—3¬B1×®ñ8s±9·3H,o"/ó$³³;×36333›:³=[²Ëó4Op5÷ó3‡ðs74A—­8�2+´B{nÉêóœò3Dr$3/Eëi_ô7SoÏ3{tDo­û2ï6#ôC“´;Ëñ0Ó³´='³.‹tw´LC3ïAÃt ç´9ãðén4¢Z4P›1Mô:�ôQ5:W/Q[ê@7µ?#qCsóJSuAï´à¦´O³Vou›mTw*N‡µ)‡q÷–õ°5ZþqàN­Ô@ „À\×À\‹€Ífõ[ßòâæ-^ßµ„]×À^r_ÿ²ýžna×uaöa“sb+6Gq`Óõ`çudÓódû2 B Èó\6^Óõf3ugû5“¬ûÊDi¿vœöM§¶g¯öD—€c ¶aÇ4m¿òbËp^×µfóvo§õN¯<Ó5duBwU#2ñ†¶LÜul37_;÷#‡³ “vu§¬dcw'ÿ³k—¶J#6xWòM„vÉþ�L,wy÷yg·MxA*›l{6V›w|òM˜€ü�Æš€k/õlï7z׈ �€Cë·�û1ÄÂSƒË·;ýt„;¸Âx…þÿñƒk†ñ†/­—v8Ï€ip�8p˜8ùÊGͦíìš�½8ŒÇ¸Œ͊ϸ�ß8Žç¸Žï8�ÇGx�XA� y�w�‘w�\ÁxÁ‘7¹“wÁ ù“?y’Où‘9�[‘ 9—¹—ky–cù—�y—sù–‹¹–gQ™“ù™ƒ9™¿¹™‡¹›kù’¯y™·y›Ãù—o¹›'¹’_9œãy–ç¹� z�+¹žÇùžËy¢£9š[�š³¹œwy‘�ù¢·yÌ9–o9’Szœo9€¹ sº¥ºœg:Ÿ y¦ú˜‡úÉz7wA�Ï:­×º­ß:®çº®ïz�X�ü:°{þ°�€°;°»±'» »²7»³?;´G»´O;µW»µ_;¶g»¶o;·w»·{³[7ßÊ:¯—»¹Ÿ;ºß¸8Õ8�³ûŽ»{�û:¸Ï;½×»½ß;¾ç»¾ï;¿÷»´‹;ø’{º<Á¼Áç8¼ü‹'¼|È»¿?<ÄG¼ÄO<ÅW¼Åk;ÀǺ|˜€€—€‚›€ÐxÇ/<�ç:識ʫûÊ{�Ã;±“À ,;Í‹ \<Îç¼Îï<Ï÷<¿g<“»8™€ÿ�ýÑËAÈ�Ò·A´<ÔG½ÔO=ÕÃÎË;Ñ=Ò3ýÒ7}ø<؇½Ø�=Ù[<Ðß´ÀÓÄ,þÛ³ý¼½�Ì= AÕß=Þ£-ìœ|Þ÷}|\=°¯}Û/ÁÛþÜ �,�]—=ã ûâ7>äG¾äûÙSs ¤ýL¸½æÿÜÏ}øø½è�>é—þ�þ¯·}á¯þ̽€þÍO¾ìÏ>í×¾ÙÃzÐßÛ#¬þá»þ뛾ðƒÒðÿÔ_A²>áû¾Ð=èû€íGÿ·‡ÀãK¿õÏ~å ôåßÄ,Aï¿="PÁ4ÿóó?ú›.* ‚ ‚a3‚W�+Œ|†,‚RËx‚ú˜Â«£;ßÏ8@Œ(1�`Aƒ&T8P ÂVDD”qIÅ#QrþDˆ�>lL9’dI“'Q¦T¹’eK—/Eþ˜´©N�5"~p2igK:YV‚3 ˜G‘&Uº”éÈ‚° F•:•jU«R vA¸äâ‘"B¬4râ��'¦U»–m[·oáÆ•k°«!6jˆȈPÂ"®j´]4¨-OrOy™ÛpncÇ� 2†¼"I®]\i´DˆY'@6=štiÒ!òÐ1âÇ�=8�b$O‘³I†ÈÙR(Q£¦�NúéUãǯfEص«�+ˆ–œµacruë×±cCŠ!A¾~]¹]Ô—­áµ#Lœ½„döqÝ¿g_y$s"B„PIý,þ�à PÀ–p“h>°ˆè?œb A6‘l»-7€Z©)D Í¤%*jÀAü°8äH$Q¹ƒ0ƒaü– ‰Ï>CK¾ƒâ›±±m¨ˆSØ@E•Flð•Àj2½Ê(¥S)â•jø®6NQ“"Jø+0 9eMšˆHVYå�+!ˆQü(ˆ�RŽœRER1%��jhä”T<ÁÒ†GPá3ˆNñC”UB™"ÇEm´úDê꾎| ‚þN8Á„CìÔS˜ìXC�M.©B>ÖÐU÷èd“Jj°�BkÈcKR !Âôè$“ž"Ú‚N2ù�BBþÈâ’ ¼$ÕC¨d�K6Ù㇈ªà#“LÖ�­¨L¤!‹=èØ¶ŽOÑM÷¥Klת b?�–ø|¨A_Gùí²¿©ÁO)¡½J`ƒ¶Óñ•Fàk/TQ¢„C@á/J” Läê@Yä^,¤†Pî,È‹E bã•2²D…æ¼Ð÷˜ �$0EK0%”AÅߣ»ÃGÇ �è"üæb 9bŒqu±ÎZ";6¹‹NxÈ‚’ÿ` É@#’µA>Îý!Smãa-pZµŠM¤¨á(¬a�Jxé) Á>˜�ƒ’ˆZc�‡8ù=þÄå$ x°ÄT­9ך]wA� Þ‚|à?³êõa 4åX®¥o|}!ØËN "ZÁgUJø“lP… ƒÈ0:ËW6•¸‘C Â"œ ì0BŒ¿¨óŽL YëM‚y$æßÍ0…z‚Lac &ZAÚý÷ÙjZ"Ó£N�|`ý„Îù÷ÔŽs#²ªb�` y è&µ� o³©ÃØË(vP�D(>ô@C'©ÄåV9‘dC—°i“…JH$7éß ;õ¹Ð�nt�š:r{•€=4ÁáD!/{M@^X„2„¢Sp…+x†Q8 bÓþÄ*…ŠU4ac%p… 6X�wY„*VѲ´°‘Ã'Ô”ˆO€¢‡)u"”HPþE@!Ù° bA.:A7\b "øÚÞ~ 7N„ ÎI†b Ã…„‹Ã‚%þNH�5Ù©¸xzUÝÎ÷(\íêq r…‚x³8ôˆ xÀ´Øö‘3Ê…N‰}(æ   €‹–Ü–!¨Ž‚œMZ­¶«>WW�Rt!Nš"¦‹œê÷¼–UÃK 6ÕÃöšÖµÁêDš‘ŽEMêJ4´k¨ ;7º¾ nf-cךÙ%At¢«’_‡d(ø€< l{ÛÚ–ªoû= 6ˆ Œ§j¸�[ݦnëk%rµj@Û ¨w@ÐlZGh"6°Äq ý }Ü$φöT®YÚˆ u¸AØ}4ˆµ!æV8RŠ0Qœã_5ÁA¾~ðW!79Á ~pÑ}Õã-wùËßñ¤iÓð>ùÍqžþó�§\å°Hø·=­´ Ã¼ã2wH‘�>›ëœéMwúIy®ò‚”èU·ú�“>£¬!Kú×ÁöOEýàÒnËÖ7Šö«¯�í±ËŽ×Åw¹Ï}4d‡öD… Ali[WûAô%‚ÀO¼ã5 Áà×ݨ¿—šï|'áâxƒ”ú-Œ±|\^ ™¤R BpÝAzÑ/Åî‰.ˆ œÀ/t¡ \肸 zÖ[aõ]°®p/X�õ½ï½tÏ{Û¯ž ´÷½¢à{忞öÂ7~ï­P…ÝϾöÔgýî�ïú.L�û¼çýZ¿|ñ÷^ûʧ}ñŸ/þòk¿ü¾þp_}þöû>öÞgýüÛþû¿ŸüÀÿÿõoùÊö`OõbOgOöî�üè/ÿPù\Oø PÿÚÏõ°õò�þho÷T�kOeïöª/?ðÏú`¯ÿÈ/ýZo©¯üNpÿ(p³ï÷¸àó–·JBG¯}°Ù ¢ç¼ÊÝ.ónÒ8->�00.oòô…òºcS"= âBíñžñôŽ ùÅuÂI!z(S o ¼ b~ˆuL­ÀNï _,óÎÐŽ ~H 8F ©Ñ¾� ›2å›ö«‘‰¤È�¬¯Àd !d$óš°-à0ƒ¨áÚŽæ"Qï&qv°nœ„9 8þqV " #FQ'àQ1+`Y±Yq^qO‘& *]q?‘Vào1OQ…Ñk1# 1sYqà¡Cñ"€«± µqààÁñ3`É2À3`4`×14@. .. íÑɑ׸±ý1òýq 7  Ò c ,@!ÒrÑ"Ï1@R!)2ðq#ññ 7 4$7Ò!I’$- Û‘ëÑëÑâQÔÑ9±&mò&q2'ur'y²'}ò'�2(…r(‰²(þ�ò(‘2)•r)™²)�ò)¡2*•ò12¿‚ÀW`‡�?Qe±) a+àÕ’xQ'€�±#À&€. år$@]‘[Ñ/e1“ Ó¯qsÂq1#2Q r$Òã1 Óq²2ùQ3!Ó1+S cÀ#²$²$5²Ï‘’"€!_3=“I³ eÒ4-€Mrg3ý17ùQ0  %s¤ò8‘39•s9™³9�ó9¡3:¥s)©²*m jR+¹Rã’/sñý²,ó,ÃÕR+Óòárß²ñ’þ;�ñ+ó0ûR,Wq0£ñE‘0# 1·‘1ÃÑ7r5³@=³2ç±3y3"³9“iS2ÛÑ!ôkÓ -Ò,rC3ɱ"-#-’<Ô@3S&er,€r3C54%=ôs8-´@3`:q4GutGy´G}ôG�t*Ã:¯³ÀÐ@;K‘+»ó+W ,=Qϲ/É'@-W‘Õ2×ó-ãs;ƒã%À>—ñ>ñS­‘?ûÓ?³@`ÀCͱ7g“2)t WÒ3´i”ÛqO”B!2O/´ 1€"qSC%2 RQ25%s2m“4MÒ$-þ’$ɱ&µP³ù4m²N€)À& á‚ô'! aUa5VeuVi5V«“H©b‘TI%Lå-í“Ëò+@<“‘X™ñIÏ3=Ù²=�µïrL£5LåóÿÒ>Ë´ÓTM§q?ÝôMt À7A C•A7³ãB;Õ7u³ QÓS9óP7ÀE3tCW3ò5_SC_³7)Õ Sô6_Ô$Q2F7S8rA ²&OÀÈ T“r¾UU•(½ A,6:;àU™ó <: VUveY¶e§óVq+®rW=q;‰‘.S‘.W1‚5áYþy¶†V»iqZ™ô÷ò+C1KÍt,Yq[ÙÔ0©ñ[Nç”aïtRãA1 2T3ôAÙ•N18s4/”!QÓ! Ë5[³n1rAÍÕ`=8K2Cë•ÇÑaï53ÓQ€ ú ˜€ ”’ 2¶& ac‡ò`N€@:G¶9½ € HÖeEwtI·t‰fc*t5IkÖfõWé“hYqX‹uJiQ=ƒ–XÓ2,S À“=Õ“ZÇ4é’�Ït³õZ©v.õ“ MÝTkç4"S2tR53O]2 W’3�S{õq5+T 3óB÷U`K2þF1`]#aT6ióPMó$_t³·4SÞµ$OR|s¡¸ swÒ¸ a ¡&¹à F–Á a€˜À��€ú²6Ö×<¸&;à08'€ ÁJu"dXU‡€ƒaN`¡ÈÀR��€…C€ *8dqRˆ‰¸�‚w'à Øt±8‹µXtQ7uWww]vÑ’wËÒw‹õŒÉ²v“±,iKÓ2†õhOÑ(@§õ.çXLå3jg—v+à 3Z«‘M±6½1@ct%'uûQ t7²@¿þö]]1 Píõ‘‡!Ù¶ Oómaôo3#ßW,²‘É12m“mÏWa[tFÖl‡p�ú ‚€')‰17ƒI€gøp§ á à!p™ tX0Ø2–a…Ÿ€‚ƒY'ûà €šw™.ø ž€+÷ƒÁrû�†À  —@ xskr›»Yv9'{™!s !›‡y'I  w‹ ú úGÙbiRáf |w+ø;}±‹vvÁ“v­”=¡tg‹µ-Û3hÙ��ÖŽë²Å4vc1�§ö@�ó¥ Ó[¿‘YU3mÉ1Eבþ2A7 47!o9ù#ù#ßñ“U´}ánòz+2”ÿu§½$Q´4/ au3~1óOQB ô–¡²YŸ-ö Šø 2–žÀ­QÕb¡rU˜AÖ˜³9òyà ¡ oRî¾ ˆ �…ûÀ‚¡N€q �"û�-¶® ›™ û„o2‡€­ã:c?àˆY´:µU{µ—³‹cvu ¶›•-›õ-iû¶q›èx¶u;¶£Õ·çÒ¨1�}»à¸w{—�·“[¹éø¹Oz·i1·)�};¶‰—¸cz™€ À»_º¨›¶ Àþ à¼Ï½ # #3&·š·z«ß1ß1¿Ûñëqíñ¿ã1&õCò` <¿Ýq9`ß19À¿/³”á{Âc@*ÜÂ7ÀÂe&9|«K¼Q ‘»Ñ![ò%ëÀûÛa’$O\Å雿ùÑ&¿€ Ar?{c¿`œ©9b¯&×&˜¡àê™� acùú³ÓÚ&S˜,ØÆùƒá„á ˆ+{²I€q‡™ ª®SØÁ‚™@'‡yÇ?˜Û™wRú ÊY»ÎíüΑҵq¶c;K§›¹áXYiÛ¶uû¶}».=¶`»‰û¥ ¹!þ}¹á˜º¯»Ò¥û¶'ÝÒ+}Ñ9}ÑcÛ»½½¿Û»i{ÒoÛP½ ½Ï{Âß;Þ»¾M<Åó[õ\s=Àí‘ÁÝqÁo=¿±$ üÜ¿!Â/`Â'\ÃcàŸ=<%QÄ»ñÚ¯�Å]Àq]×u]ÅT¾éÑ$Áh\Ø\­9³9À9e91È!—ÉíÚ€Ýݘéý& �‰kRÜ's2—Ÿ9±Š? Ë€ÎÀ²×=³C×ßuR°—ÛÝ�˜'ÏÎñ|ã9¾ãsRω”Ï¥›Ðg±¼?z¤oû-¯;¸�[Ó_¹aþ¸ËÛ¹¯{¤/�Ò+�åþ5]Ñ_Z¼ ÀçgžØ{èSݽ™}ÖQ&o=Áï;À•}Å�¾×‹�Á—úÀ «œêñ±¿u½™¾+üÙ-Úe}ÜÇý±Ý@T\¾¡þíéÀM<î9<îãþ-€`¸ ¬ùra: ®9q �l»&—œÈS6—u¸š3˜Þ€  ò9'3>€r7�Ÿ �€:@¾àÀ ÌÌ?… xôó9´¹€óI ŸSø„ ¡õ †€�'Éà `…=¾ø�¿ÎAÞ:ùœ¼m»äÙ’hŸ_¹ÙŽ ýÏ{ÛÒ�çµ›Ž�[æ›{Ò¿¿Ð5=þèw~Ó¯›ÓAÝçI}æÏ[Ú½  `�"_½€Åeݾƒ=¿ÁýÛÁ½Bƒ@ Ôc`„ äÐðB† $Väp!�Æ�cÈð¤G ,\(iÒä�V¶D3åI“kV¤X3Mš6{Öä‰óB\ 12Ä€Ò¥K =IdèÄÒ�ž0 Ð'Q¢ }PšˆY ‘Pê,ƒ}1t–)Ód1êàƒ"J ÄУ3g†œ `ƒg8…*u)�D�Äø‚è®Ø±Œ";~u©‚GLä2ý(õ£¦[»~ ;¶ìÙ´kÛ¾�;·îþݼ{ûþ <8€Ä‹?޼8¬åÌ›;=zsãK8˜@!;… صk¯�=E…Ú±›ßNÁºúë¬7pðÞ=|äSاPŸ<þðÞ׫OOŸwúG zñ¹×Ð@‚  Ÿ€Ù-Ha…p´ÑJ œÄaI¢>ÝÔM6…X�C1„�A ‘@/R”ˆbÒG�TÑN$�´R�,¹ôS�æd¢ˆ>Ú´d‘6apS}xÖš!¬™ÆÈfÂmI›•Ày‰¥–\ŽIf™fž‰fšj®Ifrn¾)]œrÆI�Rÿe×zyf^x–GA{×_ƒóÑ—Ÿ}õI¨_þ ƒf·ÞJH  „Î÷ <¨ ¦ @(j¨£fx†2iAˆÑx%"i“Š T‹-.ôbC*Š(�"Ú¨‘7†äÑŽ:¢ÄìLC>¤‡=ò4bµüh-­1EF\®ò•\C¦™à~.Sã–Ën»î¾ o¼òþöf½Ç͉o¾°Ô À� Xxäõ)!¤xZ×Þ¯W¨{ŠÞ§¨§”: uéIZ0¤Êwàz›2˜à¦ �ñ„ŽJa©f¸ÑO<™äê@HN+¬�ØPή2Äb¯)¾hNÀft£±è’ J‡tíLGZàRÔAºÌd°6gÛþ#”Njk˜M�;¯o^÷6¶ce‡�vÚj¯ÍvÛrÙ w úÎ-¿îý‹çžŽçç€ GÊð¡ìqÄûNA [ìßáo 9‚ ‚ü1É%ª…ÀÑÊ-¿,SÌ2šˆõÍ�ž3²õŒ+ê°ÒLã�ÅZ ÒŽ ¡tmJ*µ$$ïÔIú‰Fÿòˆ%fàvòÊ/Ï|óÎ?fÜöÒM½süB�=lŒ¹½šÝÏö}ÛágO~ùæŸ�~úkK_oõîï[ÜkiÉ5W·Qe¿úÊ�¯ÿþÿÀ p€cbœÞG=êœA5©aDWÒµ” �²‘ ±÷»ôáJì ?þŠp„Â1 ›˜Àø`…ŒøÀ¶7?ÂÆ P@QdˆÃêp‡<ì¡ÛL˜Òíz-\J!˜`ˆDü0‚ÉY‘«¸&0�à‹RЖ(¾)€”Ò�A 1R!Ë[Òõ£4tYD!"cÆD$e,Y”"lFcEêq�|ì£ÿø6 ÞKˆú"bºч|€XJ—ò/ lÍ#•B†¤(r‹ˆQÀ^°H)Š¥I9#¸ò@ˆå�ìÃÀ€°à I¡¤RhÉÆJ¶†¤/ Ì` Ó|‚$!ñeH#F&KŽÌßÈ�GØ\r–ÐŒMhЇþ/.2øSÊ Îbo ~˜üËÎÍgFó5xã0ß ÏxÊsžÑ+¦rŽ‰Ìø-¥ˆh —,•2MdÐ_”f਴Já"Ci:`¦0àKéÃ_*Ô0¢… „!J#PCôŠ]hk°bGzªt¥,méJíyO|Ê)™ý ±N˜26Ì”ËN¿‹‚2¢Sy„ý!°]±—­ùéRœÚš3HR.t©U¯ŠÕ¬v¦Ä‘éœhú@µ,©K!_H�ˆØ�U)$à Z·ˆh”/• )`JTª$h%VH±´•¯ˆ`«[Óê2|A+\J±D­þJv²”­ìó¸*7¯ÒIŸJágXðOPÚå/id!ÄÙšå‹OaÄ7·š‚ÖU¢m™_JÛ¥ÐEƒbiíkà[ÔŠ†��W,©dÙä*w¹ÌMS F HèfV³Ñ¹ÞÚÎÖÜìjw»ÜÕf©[7βM©Ý-¯yÏ‹^µ}¼Ð±®Ú á�ôÊw¾ô­oÙ']c²w:ⵯÿ à7o½ûå/qŒà+xÁå"p�—ã^Kx®0lüà:©Æ‘µ°‡? bûb¸ÀÔÀj@‰Ñ«xÅ,nîˆ÷K�¤8*m$p¿ÀF2¹-î±�üÇää7n6p *–EŠþå´J9—+K1yÊT®²_Ì^êPE5R|ŽU;ÎGØø ¶²™ÏŒæôa¼f ÜÉrÁ*�-„!ìœÒ4ëyÏ|þ!W‹Ìœ§˜ 8 Nà%0U.6î³£ é5­™ºmË ¤8Bb)gà‚[7Ó�F˜4Ò¤.µ©u3iÍ–X5‰(4«2• jF)qõ©o�ë\/%Õ^�0Szªë` {ØLáµL‹cج‹ØÌnö­��O_›M—ή¶µÑ ícJûÚÜî6¶ÿ høxK(—}oÏ‹¸©Q·»——mB‡ôfò‚ïzK Þ `@¿û½€~Ûxþ…h¤ÁWxp„ÏÆÆ›2 ð„+|à“LwÑVUª `ãßxþ„ƒî¥|�‚wMÇO¾ñg¥|ãØÿXŽòñ…Ïå0¯(�É@HPôÝó™¯|(_ø¡e@V8Á¼é9�yWýêUÃjüß•ûØÃ^ð²÷{íe ùÜç^§þ† ÃÍÀî�÷|øaû9 �Ct¡?Ýín'{ÔŸîõ¦‹ëkŸ;Ö]ýìÃ@ìYW{öµîö´ÝèãwAÉ( öô‡ÝîuoûùÎ,Ç3Þó‡¼â/üC~ÿ�§¿å7Ÿy—wy‡z†WÀB3"rz ˆ# Ò+1"ñwúg{²—,2ðzH{±7¨4÷y4À}`QkdÁG|+(/Ƈ@È7tÇtMWv6˜}Ýw}WGƒØ§}^W}Ü}ewƒÖ·u=htçW}G‡tuÇ~Üqwð‡�§xþWx�W�އ¨yZØV€(† W€Ÿ—½‚$ hzþ—wz¢3‘‹y˜�x�Œ7{³xº—{‚ð^�S8G\,Hˆîâ‚ïtÊW~4}Nç|Úv;¨ƒKØ?„Ó—~Ïçˆá~D§tm§“‚‘vt×~xÇ~RXò·xV˜®g±è…{èwô‡yŸ7†ºçy842Rz Èy)Ò:.â*Ø�²W{¯÷�‚JÃŒ¸Ç‡�×^&eL¡‚…ˆ�’fOÐnâVŠè‰Hˆƒgg‰3HŠàWŠå(uW§~•hv7(tsg}Ÿ¨tJHŠéÇ~x… Š}ÇŠ�Wx„çyÆ‹X˜{ù7®¸yˆyšgxeøy°s€ÃÒþ�À膷â*rèxÈh‡¸Œ¶÷ŒË¨�Ñx 0�'u�ÙÈ’f"=C¦_V1˜|HHt^÷h }D'wG׃êuíØ}6؈L׃GHƒJè“¥xŠùXwý¨ú犲8‡s¨‘´y®ÈyÑ‘•G3@c¨·†©—3«'#p˜"÷‡‡:‚�)’Í‚ψ{´¸{³Äže�ìÖ’{Ù&àh‰Øv‹Ø|RW”^gu×G}�è“@ u‘(‰Î׈e瓇9™áH~æ÷)ÚA}Ü×îwwPØ�Y…Vø�´—…jY•�§]Yx¹‡z®Yy8£‡€Œ¦§zfÙ:É–Èþx�èŒr—Ð�·$PyPzɗˇè>A—|E'Ž�IŽÑד¥Ø‰YǘM·™Ü§‰ÐG™•)˜IW2)ÀŽ“™�y焚X˜…SézV)šüxd8y†Øy÷ù�9@#BS§Ç†¸éf)�¨‡±yi‡Êx‡Ã‚ÑH@0\À‡fIÊÉœÊÎY=0ƒ�IƒÛi”›‰ŽZ·uãXއ‰‰•øˆŽ¸}”Y}ã·ˆãíH}ŸyŠO©ŠŒ‡ðI…¥yš¨©�[‰¤üY†`)+x·‰y(3 "+:s•€ç Îœ"ù–p™xÑXG1G)È¡j¦þ·¢)DÑ9ž5h¢ŽÉ}•©u,j‰(:”1 u4ˆ”lGžcžìØ}yGêù™íéz i�QYšVÙ…_›­¹�‘º‹“ ÁRc¹€¢ƒ‘(‚•òW‡É’œyHœ|¨Sò+y¦«:i:Dýr·qÚ1ƒÙaP%‘£'§WA³ ¡r 5-ñ«1·qò‰…BB­€&¼Ý’ªeʪÝê®:7Û/Ÿ’,€«Œ¦6¾#7Á*¬Øz `,1¯*Ó)Ǭ,ñ¬ÐZ�RC­ç£ªÞ*°Åæ—E&®‹° {e+“㦰 þ± SÝx°k±KLp“1Ib°Š± ²Ù®…ä±!k²'ëgn‹²-ë²ì2²ù²/K³5Û—*û—%k³;˳[³ùä°=+´CK/ Û±AK´I«´µñ³_¥³K µQ;F cO+µW;´M;SV‹µ]k³Z»YHëµc˳`^bK¶iû²ÛØU+˵j ·k¶Õõ¶qk· ;·íU·wË·Þj@;]»·}K¸fš·Ï1³…«¸Ëy¸Ö3¸‹ ¹ÙظF¹•˪“h�k¹›Ës˜ ašË¹¡ÛmBf@ º¢‹ºÎÆU5`ºh›º¯ën«ÛºG»µÛ¹05³þk»»«nnâ^`Ó㶮˻Ç+lÉáƒ@�0]@¼9k¼ÈK½Ï†0Ì;€½‚;½Õ ¾‘–Ù„�½ÝÛ°´¾ë{jã˼Í;è{´ê˾õ iî;‚@‚0€Û¶ÒK¿ö+À{–úkÀ„05p@<À LÀÈ‘¿Ù›¿5à¿�›¾ŒÁg澂�½\Áº›Á!ÅÀ"¬Â*†¿ù+ò[µß»Â3Ì`$¼¿0œe§KÃ;<_ø»¿„€CÂò\ÑõLÊ Òÿ3Ñ%|Ȫ–È!�ÒhÃÑ%Ýk'�Ò/ /6Ì¿üÏ0mÓ—…’ŒÀíÐ5}Ó?­<ã[ÂLÓ ÔG½>„\ÂlшŒÑH ÕlBÂçûÇÕW=/mÆMmÒO�Õ_}_ÇQÆvìÑÕ\Ê`�Ö.IÈÌëÇÆ!Ä>�Öq­ÖÇQÂ[ÌÒÇæÒr­×¯ÅdmÐâ¼×�Ýœ9­Åm]Ö}Ö‚­ØhŠÆœ¿wmy½Ø�ÍÆ… ÙÚ&Ù“­×t¬¿~ÝÓF­Ù¡Ýª9ÝÇn|ØN�Ø¢­Ú+Ö…ìÙkÕ«-ۮɜþÌÊ}ŒÌíγÍÛJÑÉŽìÉ’Ê\ÝÒ^ÝÛ”½Ê�¬Óô¬Ûö|ܼmËšŒË¬Ë¼ÜܱýܳmÎÄÌÖÙ›ÛŸ ØÙ½ÚÎ<ÌÑL Ûf-ÞÐMی΅ìÍê÷¬¼L>^ êáZé­~ÉnÒ€ð²>ë$Ëê¶nçT«çÀþÒžë�ÄnÓöRÁ1åãÈîéÂèÎŽÒlkÁ.ímìF¬ÄJlðZjÜžìÐŽÚ^`änîWà] î]€îW°îëŽîï.ïé>ïVPîånîönîù~ï^Àïû®ïø®ïÿ.ðùîïýNþîðoð ?ð÷Žð Oð /ñð¯ð¯ð�ñïðñ�îŸð!Ïññ Ÿñ_òïñ)Oñ0¯ò1oò!ïð?óßï'¯ñ4¿òÿÎþ^˜EôEoôG�ôI¯ôKS°<¼LõQ/õö2ôÌž¾"€õY¯õ[Ïõ"0]öa/öcOöeoög�öi¯ökÏömïöo÷q/÷sO÷ooS�÷y¯÷{Ï÷}ï÷øq½V?¿uoø‡�ø‰¯ø‹Ïø�ïø�ùi÷�Où•où—�ù™¯ù\5øá|õ‘ú¡/ú£Oú¥oú§÷“¿ù«Ïú­Oùîíú±þ�ô�_í¤\ö_�ú¹¯û»Ïû½ïû£¯únòô²_/ËNüÇ¿ùÃŒáÈÏü‚Ÿá ömð ÕoýrÐÖaðûÝïýßþá�úÁ_f0ýÔýןý`Ðüíïþ–ïÞþþóÁߺ¸¿õ#pKÀÿý�GGŽ!Øc‰„ .dØÐáCˆ%N¤XÑâEŒ/†ÐØÑãG�!EŽŒh¥ÄI”%®,aÉÉ�DˆôâcIJœ9uîäÙÓçO A…%ZÔèQ¤IOÖÁÔi ¥Q¥N¥ªÔKJXYµnåÚÕë×­)¶„ ó "…ñdÅ’qåÎ¥[7áˆþxóJÜk×ï_À�1^ÉI¢%$úP ¤æˆª‘%O– ™òeÌ–K4mêTfÐ=5‡Ž:º V°©UƒÛ�¥bÅLÒôQ[ðmܹ+r|Â7\Þñê&^åp‚É9!˜t²­ù>üQ© ï �DJ¨a $J`ñ»œ�1(i´‘(QzÊ;Ñô””²!K0òR®Ô[HIè–@d‰¥¤rMRÿBlh„*2Qˆ£2_ áë ¡y³c \HU„ÎDØá†ã(8SͯTfëj3%Ø~ ˆŠDjKT&Ý)Ï ¸ÕÖ¨PÊð% -A3Cƒ2AF+Í)O6>q”³Ê¾µ¨J1Õ7=$‚M-!– ‰)k3¡Y„G �‡L¶àÏŽªèäÀVUm�“JæðÁ@þÊÌâ’Môè!„‡›£’�@ȇ<:鄎Žä�^Zƒ`³Ð£ŽM*©9@”ùè¡©<2Ù„Dð™’NøH"a¨ãz¥pLà# ®Áo§êú^ð UH£„Oþ@é?�¤TJ±!]³ UPQ»„F�ÂUL %9Ji¥”"Ø-AŽQ‚H©6p:%�QVÑD‰“ÀÁO*ƒÎÛf“CòFÄ)°S¿,ß}/ÕT!Å£m‰?€°Á†j5jÞ1*°ù¨C>ªPf…ªpp„�½AŠMTžç�œžº5*�‹ˆkZÀøø2!(ìx5 þNºì¡,Š®<îaŽ<2¡J�žßÁÞû×hês=Gv6©Ý ^`ƒœ@u ¬J#Rá„��E0ƒ(NR„V8.%¢×èæŠl}"+JÅUÒÕU˜¡H EJUtÁMÔŒÌPŠ!àd‹:É)>ab'i �àŠ"Ô 4 BJÀWD.¨­ˆ“<}ÍBåi�‘^—�ÅÔ„-GøƒJ@Ý�à`þccD D´JkÈ€:¡2…hÁA(¨^ÌD€äñ€ò1 *a<üE�V5Φ7>ˆd¡ ¡ßB²°há�ªªDRE«6†r"þ,Ath$”ñ$<È]¶¾†’W^O‘2Ê"�PÈÁª˜B ‰œˆ¢Š5x‘ \±¢WØà$ЏeºØ ”<“o‡H ‹À` 'ú¤§ˆ\ l°Dœ˜ÂB¤XD6Ÿh ”<©óV,·wJÆ$\ì"z¾ˆ�#ÐD:´3ã‡F�À͈R  ©ª@ɃMø@�# ':!,àLÕ Á‚øÐ þdâŽ}äÄô‡,@o!vØÄ&jaÍ‘ «øp LŠ |°Ä8R‰( „V˨@I °1ªÒŸ&Øš�º¥“xÊÒ(íüI# aH¢‡PD HqL¸âmþNòAtÁ¨ «*Q‚� ‘@‰Щ‰¶‘«§0…2S œ”bQ%,A0 P´â4=0*1„Äë;ŒD ’ê#¦*5'¬«çyî)‚š0†-cð üIËôt Âc6á¥<¬Xqüójµ é@}<ä°,¡HŽ%ÄwÈ’PÉI"dxÁî 5Ä´Wþ‘‚ÒÒ&ÑFw‹8Q �Æ„œÑ³g”lx{”Eœõ$ ˆ\V1T˜ë$¡¨"ZaT. ª·˜Ò<‰äõj’ëAh&r¢Þps)¯hÂVÁ ‚<#°A"<1‚F�„±çºþÓw»û“éZª²–í×B¤ªíŽ´"AtCÉGNÏÂCˆ.ºÆä-¯z6!¸x´yûÞöˆW‡s©lA7ã�B¤0<… / Í„BÒT¹T?HZ .‘ÓJ4WÐ5ñ@{™“µtÈÁ‡RåÏ §¹'�8o Ì0Á“hR ¦WùöÞc¢MÊLœ{K„®‹‘+C*¦°.ÈÈ6 …ŠR’w¡A0A!Da2œ*^xEþt9|Â9’ð Ô|jžÌ“H6Ïei#ª)i´î2ï˜×‰5<ì@#à¢< Øy!Ðc'6!¿’¥j–ÈDþðøƒ=¸l@eʃ’| KÖ�g>ËK.A‰=\RY`vÆð—&PÖZ FÉ|¦„À”�بٱ0…Ks¯E‚l£8›ç¬a~•#¨ÛŸÙÌ Qô2]%˜Â'VQн•À§8\âfT*ð2%ƒ6W)ÀP¹Ë•À�0Å' !‰.Øp¦ðF�’F©õBux(ËêÔ\¶66ðA xÀJB.#ð€ºû7«Ü*d™È©Fxz«¹¬ÖèSgˆªQB‚ùÜî[; h�3©zÏ\– ÇI¼jžÇþwž9JØÏ>™›ãü+—í•o>ðx@!�@üÞP=j!ØñBþ¦ç‘á0„§ |ãbõ« ’Ÿ<ßAÀûoo»¤ú–“)¶÷íiÇISÖޔ̨ÙíF‰»Ü»BwÇG7Ù 1-ð{ÜC òGå|Í‹™O¤âPœ’gr·ú·#Ÿ–©F�ëçNëÜG_úÓWÓî/£ü c¿÷H ’w„Wiùöâ¡ó�tê§_ýë/Žõ'#~YÂûó—ÿ…[oþ¬Àžýûçÿ3â®ù @@܉ûÃ?ýó?\@Dˆ/+À“¨?’o‘À ŒŠ4?Xj@ìÀtJ‹ Á ,ÁŸAÜÍ ‚Ô0üË?9=(©Û¿œÁŒþ�)˜‚.à‚.øA üA´/Â4 ôA ô+PÂ$B+@B.`B$ì"ä‚(”Â,¬B/ÀÂ#”Â*ôB $Â*üÂ2ì&ôA',Ã5LB2LB-DB*4Â6ìÁ!| |C0DÃ&4C6dÃF”D!,Ä>,D.àÁG4D=ÄÂ4,ÂD0zzA­ˆ@ð#�U‘¤¿àšV´ŽY¤7¦ªE¯AAŸÐEZ” ^ŒÀwY*ªˆ'åCÇ"=Þ‹¿`, ì#F¥0—ÑøÅUCE­p,\LÁë�Fœà�ïË{³®a'oþ @mdFlìÅñ Ôƒ¥sÌÆ]ÄŽùÅ Æ­¸�ãkÇ ìFÇe„,|üG€$ ¦H‚¼ G­ˆ{,H†lH‡|Hˆ Ó~ŒHʨHZD� @HŽìH�üH� I‘I’,I“.lock`` beside the resource, or a parallel lock tree. ``huggingface_hub`` keeps its cache under ``/models/...`` and its locks under ``/.locks/...``. Keep the name within the filesystem's limit. A path component caps at 255 bytes on ext4 and 255 Unicode characters on `NTFS `_. A cache key pasted into a lock name can exceed either limit: .. code-block:: python import hashlib import os from filelock import FileLock def lock_for(cache_dir: str, key: str) -> FileLock: name = f"{hashlib.sha256(key.encode('utf-8', 'surrogatepass')).hexdigest()}.lock" return FileLock(os.path.join(cache_dir, name)) ``datasets`` hashes over-long lock names against ``os.statvfs(path).f_namemax`` for exactly this reason. A hash also sidesteps the separators, spaces, and non-portable characters a natural key tends to carry. Hash short keys too: an absolute key would otherwise discard ``cache_dir`` when joined, and a short key can still contain characters Windows does not permit in filenames. The SHA-256 name above is 69 ASCII bytes including ``.lock`` and does not require the cache directory to exist before constructing the lock. The explicit encoding also accepts surrogate escapes from Unix filenames with undecodable bytes. Two more constraints worth designing around: - **All contenders must agree on the path.** A lock is a rendezvous on one pathname. Resolve symlinks and relative paths the same way everywhere, or two processes will politely lock different files. Bind-mounted containers make this easy to get wrong: the same file, two paths, no exclusion. filelock resolves a relative path when an acquisition starts and keeps that path until the hold ends. Do not rename the lock's directory while a lock in it is held or waited on: a waiter then fails with :class:`FileNotFoundError`. - **Create the resource's directory when using a separate lock tree.** filelock creates the lock file's parent directories automatically, but it does not create directories for the resource being protected. ***************************************** Keep correctness independent of the lock ***************************************** An advisory lock binds only the processes that ask for it. A different tool, an older version, an ``ENOLCK`` on a network mount, or an operator with ``rm`` proceeds regardless. For a cache, this argues for a design where the lock is an *optimization* and the filesystem supplies the correctness: .. code-block:: python import os import tempfile from pathlib import Path from filelock import FileLock, Timeout def populate(target: Path, produce) -> None: fd, tmp = tempfile.mkstemp(dir=target.parent) try: with os.fdopen(fd, "wb") as handle: handle.write(produce()) os.chmod(tmp, 0o644) # mkstemp creates 0600; widen before publishing os.replace(tmp, target) # atomic: readers see the old file or the new one, never a partial one except BaseException: os.unlink(tmp) raise def get(target: Path, produce) -> bytes: lock = FileLock(f"{target}.lock") try: with lock.acquire(timeout=30): if not target.exists(): populate(target, produce) except Timeout: if not target.exists(): # the lock only saved duplicate work; do the work anyway populate(target, produce) return target.read_bytes() ``os.replace`` is atomic within a filesystem, so a reader always observes a complete file. The lock stops two processes from *both* producing; losing it costs duplicated work, not a corrupted cache. ``huggingface_hub`` says as much in its cache code, where a comment records that the lock is best-effort and that cache correctness does not depend on it. `pip `_ takes the same position by omission. It uses no file locking at all, relying on atomic replace and documenting the duplicated-download race it accepts. Reach for real exclusion (:class:`StrictSoftFileLock ` or a native :class:`FileLock `) when losing the lock costs more than repeated work: a non-idempotent migration, an append to a shared file, a resource that cannot be produced twice. ********************** Handle lock timeouts ********************** When another process holds a lock, you might want to give up after a certain time rather than waiting forever. Use the ``timeout`` parameter when acquiring a lock: .. code-block:: python from filelock import FileLock, Timeout lock = FileLock("work.lock", timeout=10) try: with lock: # This will wait up to 10 seconds for the lock print("Got the lock!") except Timeout: print("Couldn't get the lock after 10 seconds") You can also pass ``timeout`` directly to ``acquire()``: .. code-block:: python lock = FileLock("work.lock") try: with lock.acquire(timeout=5): print("Got the lock!") except Timeout: print("Timeout after 5 seconds") A negative ``timeout`` or ``math.inf`` waits without a limit, and a blocking acquire raises :class:`ValueError` for ``nan``. A ``timeout`` may be any real number, including :class:`~decimal.Decimal` and :class:`~fractions.Fraction`; a :class:`bool` raises :class:`TypeError`. ************************ Use non-blocking locks ************************ Sometimes you want to try the lock once. Either you get it immediately or you don't. Set ``blocking=False``: .. code-block:: python from filelock import FileLock, Timeout lock = FileLock("work.lock", blocking=False) try: with lock: print("Got the lock immediately") except Timeout: print("Lock is held by another process") When ``blocking=False``, the lock makes only one attempt and raises ``Timeout`` if it can't acquire immediately. The ``blocking`` parameter takes precedence over ``timeout``. If you set both, ``blocking`` wins: .. code-block:: python # This ignores the timeout and tries once with lock.acquire(blocking=False, timeout=10): pass ************************** Control polling interval ************************** When waiting for a lock, filelock retries at regular intervals. By default it waits 0.05 seconds between attempts. Increase the poll interval for long-lived locks to reduce CPU usage: .. code-block:: python lock = FileLock("work.lock", poll_interval=0.25) with lock: # Will check every 0.25 seconds instead of every 0.05 seconds pass Or pass it to ``acquire()``: .. code-block:: python lock = FileLock("work.lock") with lock.acquire(poll_interval=1.0): # Checks every 1 second pass Change the poll interval anytime via the property: .. code-block:: python lock.poll_interval = 0.5 ************************************ Probe a lock before blocking on it ************************************ A lock that is free costs nothing to take. A lock that is held can stall a user in silence for minutes with no clue why. Probe first, and you can tell them: .. code-block:: python import logging from filelock import FileLock, Timeout lock = FileLock("py_info/5/a1b2c3.json.lock") try: lock.acquire(blocking=False) except Timeout: logging.info("lock held by another process, waiting for it to release %s", lock.lock_file) lock.acquire() # now block for as long as it takes try: ... # critical section finally: lock.release() The first ``acquire`` returns immediately in the common uncontended case. Only when it raises do you log and settle in to wait, so the message appears exactly when it is useful and never otherwise. `virtualenv `_, `pre-commit `_, `uv `_, and `huggingface_hub `_ each arrived at this shape on their own, because a silent wait reads as a hang. Exactly one ``release()`` balances this, whichever branch ran: a failed ``acquire`` rolls the lock counter back, so the probe leaves nothing behind to unwind. Do not reach for ``release(force=True)`` here, because it would discard a reentrant hold your caller still depends on. ******************************* Report progress while waiting ******************************* Probing tells the user *once* that you are waiting. For a wait that can run for minutes, keep telling them. Wrap the acquire in a loop with a short inner timeout and an overall deadline: .. code-block:: python import logging import time from filelock import FileLock, Timeout lock = FileLock("cache/.locks/models/bert-base/a1b2c3.lock") deadline = time.monotonic() + 300 while True: remaining = deadline - time.monotonic() if remaining <= 0: raise Timeout(lock.lock_file) try: lock.acquire(timeout=min(10, remaining)) except Timeout: logging.info("still waiting on %s, %.0fs left", lock.lock_file, remaining) else: break try: ... # critical section finally: lock.release() The inner timeout controls how often you speak; the deadline controls when you give up. Keeping them separate means a chatty log never shortens the wait, and a long wait never goes quiet. ``huggingface_hub`` runs this pattern in its ``WeakFileLock`` to keep a stalled model download from looking like a crashed one. .. mermaid:: sequenceDiagram box rgba(21, 101, 192, 0.16) This process participant C as Caller end box rgba(230, 81, 0, 0.16) Coordination participant L as File Lock end box rgba(69, 39, 160, 0.16) Peer participant H as Holder end activate H activate L Note over H: holds the lock loop until deadline C->>L: acquire(timeout=10) Note over L: 10s elapse, still held L-->>C: raise Timeout C->>C: log "still waiting" end H->>L: release() deactivate L deactivate H C->>L: acquire(timeout=10) L-->>C: acquired activate C activate L Note over C: critical section C->>L: release() deactivate L deactivate C ***************** Use async locks ***************** For async code, use the async variants with ``async with``: .. code-block:: python from pathlib import Path from filelock import AsyncFileLock lock = AsyncFileLock("work.lock") async def read_shared_file(): async with lock: data = Path("data.txt").read_text() return data .. warning:: ``with`` does not work on async locks. ``acquire`` and ``release`` are coroutines; await them. Use ``async with`` as shown above. By default, async locks run blocking I/O in a thread pool. You can customize this: .. code-block:: python # Use a custom executor from concurrent.futures import ThreadPoolExecutor executor = ThreadPoolExecutor(max_workers=2) lock = AsyncFileLock("work.lock", executor=executor) # Or disable executor (only if your filesystem is non-blocking) lock = AsyncFileLock("work.lock", run_in_executor=False) ``acquire`` and ``release`` always run on the event loop that awaits them. The ``loop`` argument only matters when the lock is garbage collected while still held: the finalizer schedules the release on that loop if no loop runs in the collecting thread. Without any loop, it releases a built-in backend synchronously. Async locks default to ``thread_local=False`` (unlike sync locks which default to ``True``) because the acquiring and releasing threads may differ when using an executor. Available async lock classes: - :class:`AsyncFileLock `, platform-aware (recommended). - :class:`AsyncSoftFileLock `. - :class:`AsyncStrictSoftFileLock `. - :class:`AsyncSoftFileLease `. - :class:`AsyncUnixFileLock `. - :class:`AsyncWindowsFileLock `. - :class:`AsyncReadWriteLock `. - :class:`AsyncSoftReadWriteLock `. ********************************* Use locks with multiple threads ********************************* By default, locks are thread-local. Each thread keeps its own lock state, so nested acquisitions from the same thread don't block, while two threads exclude each other just as two processes do: .. code-block:: python from filelock import FileLock import threading lock = FileLock("work.lock") # thread_local=True by default def worker(): with lock: print(f"{threading.current_thread().name} has the lock") # The worker thread and the main thread take turns: each waits while the other holds the lock threading.Thread(target=worker).start() worker() # Main thread If you need one hold shared by every thread, set ``thread_local=False``. A second thread then enters without waiting and only bumps the shared counter, so the lock no longer keeps threads apart: .. code-block:: python lock = FileLock("work.lock", thread_local=False) # Now the lock is reentrant across threads, not per-thread ********************* Use singleton locks ********************* Sometimes you want multiple code paths to reference the same lock without passing it around. Set ``is_singleton=True``: .. code-block:: python from filelock import FileLock # First reference creates the lock lock_a = FileLock("work.lock", is_singleton=True) # Second reference returns the same instance lock_b = FileLock("work.lock", is_singleton=True) assert lock_a is lock_b # Same object Acquiring through one reference counts toward the same lock depth: .. code-block:: python lock_a.acquire() lock_b.acquire() # reentrant, lock counter is now 2 lock_b.release() # Lock counter is 1 lock_a.release() # Lock is fully released Parameters are frozen when the singleton is first created. Requesting the same path with different parameters raises ``ValueError``: .. code-block:: python lock1 = FileLock("other.lock", is_singleton=True, timeout=10) # freezes timeout=10 lock2 = FileLock("other.lock", is_singleton=True, timeout=5) # ValueError! ***************************************** Use shared read / exclusive write locks ***************************************** When you have many readers and occasional writers, use :class:`ReadWriteLock ` to allow readers to proceed concurrently. ``ReadWriteLock`` is backed by SQLite; a ``.db`` extension is a convention rather than a requirement. Use a local filesystem the active SQLite VFS supports and a database file of its own. Two kinds of file raise :class:`ValueError`: a database in WAL mode (SQLite's write-ahead log, which never blocks readers behind a writer), and a database whose schema takes more than a few kilobytes to load, because the acquire timeout does not bound that load. On POSIX, SQLite opens the database by name. It also uses the names ``-journal``, ``-wal`` and ``-shm`` beside it, so a file placed under one of those names changes what SQLite does. filelock therefore names the database directly only when no process of another account can create names in its directory: the directory belongs to the locking account or root and is not writable by its group or by others. In any other directory, filelock opens SQLite through a hard link (a second name for the same file) inside a ``.filelock-*`` directory that only the locking account can read. That directory must be writable by the locking account, and the filesystem must support hard links; when the link cannot be made, the lock raises :class:`OSError`. Both routes assume that no process of another account can replace the database directory or any directory above it. That holds when every directory above belongs to the locking account or root and is either not writable by others or has the sticky bit (a directory flag that lets only a file's owner rename or remove it). A process that can rename an entry in a directory above owns every name below it, and can put a replacement at the database path between the lock's checks. filelock compares the name with the file it checked before it runs any SQL statement, and raises :class:`OSError` instead of reporting the lock held, and leaves the replacement's files as they were, except that SQLite's open creates an empty ``lock.db`` there when the name is missing, following a symlink at that name. A swap that lands after that comparison and before SQLite's first statement still makes SQLite look up ``-journal`` and ``-wal`` under the replacement: it deletes both, and beside a non-empty lock database it first replays the journal into that database and unlinks the file the journal names. On Windows the lock makes the same choice from the directory's access control list (ACL). It connects by the database name only when the directory's owner and every entry that allows adding files, adding subdirectories or deleting entries name the locking account, ``SYSTEM`` or ``Administrators``. Otherwise it links the database into a directory created with an ACL that grants only those accounts, and SQLite opens the link. The filesystem must support hard links, or the lock raises :class:`OSError`. Where it does not, as on FAT or some network shares, keep the database in a directory other accounts cannot create files in. The per-user temporary directory and ``%LOCALAPPDATA%`` qualify, ``C:\Windows\Temp`` and ``C:\Users\Public`` do not. .. code-block:: python from filelock import ReadWriteLock rw = ReadWriteLock("data.db") # Multiple processes can read simultaneously with rw.read_lock(): data = get_shared_data() # Only one process can write at a time with rw.write_lock(): update_shared_data() You can pass ``timeout`` and ``blocking`` to the context managers: .. code-block:: python with rw.read_lock(timeout=5): data = get_shared_data() with rw.write_lock(timeout=10, blocking=True): update_shared_data() ``ReadWriteLock`` is singleton by default (``is_singleton=True``). Calling ``ReadWriteLock("data.db")`` with the same path returns the same instance, unlike ``FileLock`` which defaults to ``is_singleton=False``. Use low-level methods for more control: .. code-block:: python rw.acquire_read(timeout=5) try: data = get_shared_data() finally: rw.release() rw.acquire_write(timeout=5) try: update_shared_data() finally: rw.release() Read locks are reentrant from the same thread: .. code-block:: python with rw.read_lock(): with rw.read_lock(): # OK pass Write locks are also reentrant from the same thread: .. code-block:: python with rw.write_lock(): with rw.write_lock(): # OK pass But upgrading from read to write (or downgrading) raises an error: .. code-block:: python with rw.read_lock(): with rw.write_lock(): # RuntimeError pass A write lock belongs to the thread that acquired it, so ``release()`` from another thread raises ``RuntimeError``. Threads that share a read lock each own their levels, and ``release()`` from a thread holding none raises ``RuntimeError`` too. ``release(force=True)`` and ``close()`` drop the lock from any thread. One instance coordinates processes, not threads: a thread that asks for the opposite mode of another thread's hold gets ``RuntimeError`` instead of waiting. Threads that must wait for each other need their own hold, so give each thread its own instance: .. code-block:: python rw = ReadWriteLock("data.db", is_singleton=False) # one per thread ``ReadWriteLock`` opens a SQLite connection for an outer acquisition and closes it after the final matching ``release()``. Reentrant acquisitions share that transaction. Call ``close()`` to release a held lock and invalidate the instance: .. code-block:: python rw = ReadWriteLock("data.db") try: with rw.read_lock(): data = get_shared_data() finally: rw.close() A ``timeout`` of ``-1`` or ``math.inf`` waits up to SQLite's busy-timeout cap of about 23 days, then raises :class:`~filelock.Timeout`. A blocking acquire raises :class:`ValueError` for ``nan`` or any other negative ``timeout``, on :class:`SoftReadWriteLock ` too. Leave the database file in place while any process may hold the lock. The lock lives on the file, not on its name, so after an unlink or a replace the next opener creates a fresh file and acquires at once while the old holder keeps its lock on the orphan. On POSIX an acquisition that sees the path replaced while it waited raises :class:`OSError` instead of taking a lock no later opener can see. SQLite prohibits using or closing a database connection inherited across ``fork()``. filelock invalidates an inherited ``ReadWriteLock`` or ``AsyncReadWriteLock`` in the child. Acquisition raises ``RuntimeError``; ``release()`` and ``close()`` do nothing so context-manager cleanup cannot release the parent's transaction. On CPython, if no SQLite connection was active at the fork, construct a new lock in the child before acquiring: .. code-block:: python child_rw = ReadWriteLock("data.db") with child_rw.read_lock(): data = get_shared_data() See SQLite's `fork guidance`_. PyPy can leave process-wide SQLite state unsafe after a fork even when every known connection was closed. If the parent used SQLite after importing filelock, a PyPy child therefore rejects every ``ReadWriteLock`` and ``AsyncReadWriteLock`` until ``exec()`` or exit. Importing filelock only in the child cannot detect SQLite use that happened in the parent. If a connection was active, filelock rejects every lock for the same path or database inode in the child. In the normal path, an outer acquisition owns this connection; failed cleanup can also retain one. The child must call ``exec()`` or exit before using that database. filelock abandons each inherited active handle until OS process exit, consuming one descriptor and its SQLite memory per active lock. CPython 3.11 need an extra raw reference to stop their base deallocator from closing the handle; later versions suppress finalization through the connection subclass. Both paths avoid the ``sqlite3_close()`` call that SQLite forbids after a fork. filelock normally rejects ``fork()`` from a callback that runs during a SQLite operation. The guard is an audit hook, and CPython 3.11 fail a concurrent ``sys.settrace`` call while any Python audit hook runs, which coverage and debuggers make on every thread start, so filelock skips it there. Without the guard, whether skipped or blocked by another audit hook, a child created at this boundary exits with status 70 before it can touch the inherited connection; the parent operation continues. A native or signal callback that forks while its thread is executing inside SQLite can resume in inherited C state; filelock cannot make that operation safe. .. _fork guidance: https://www.sqlite.org/howtocorrupt.html#_carrying_an_open_database_connection_across_a_fork_ ******************************************** Use read/write locks on shared filesystems ******************************************** :class:`ReadWriteLock ` is SQLite-backed and requires a local filesystem supported by the active SQLite VFS. On a shared filesystem, use :class:`SoftReadWriteLock ` only after verifying exclusive creation, no-replace hard links, unlink, and cache visibility across participating hosts. A peer evicts a holder whose heartbeat has stopped for ``stale_threshold``, even if the old holder later resumes; ``on_compromise`` tells that holder. .. code-block:: python from filelock import SoftReadWriteLock rw = SoftReadWriteLock("/shared/nfs/data.lock") with rw.read_lock(): data = get_shared_data() with rw.write_lock(): update_shared_data() The defaults (``heartbeat_interval=30`` s, ``stale_threshold=90`` s, ``poll_interval=0.25`` s) fit workloads that hold locks for seconds-to-minutes. Tune them for your deployment: .. code-block:: python rw = SoftReadWriteLock( "/shared/nfs/data.lock", heartbeat_interval=30, # how often the heartbeat rewrites the holder's nonce stale_threshold=90, # evict a holder after this many seconds without a change poll_interval=0.25, # how long to sleep between acquire retries ) Pick ``stale_threshold`` larger than any realistic process or filesystem pause. ``heartbeat_interval`` should be roughly ``stale_threshold / 3``; ``stale_threshold`` defaults to exactly that and must strictly exceed the interval. Lower ``poll_interval`` reduces acquisition latency at the cost of more filesystem metadata calls. Each holder writes its ``stale_threshold`` into its record, and a peer evicts it only once the longer of its own threshold and that one has passed, so a peer tuned tighter does not evict a live holder that heartbeats on a slower schedule. No clock is compared across hosts, so clock skew cannot evict a live holder. Fence protected writes with ``generation`` if an expired holder can resume: .. code-block:: python def stop_working(compromise): print(f"lost the lock: {compromise.reason}") rw = SoftReadWriteLock("/shared/nfs/data.lock", on_compromise=stop_working) with rw.write_lock(): # A store that rejects a lower generation than the highest it accepted refuses an evicted holder that resumes. store.write(payload, fence=rw.generation) As with ``ReadWriteLock``, ``release()`` of a write lock from another thread raises ``RuntimeError``, and so does ``release()`` from a thread that holds no level of a shared read lock. ``on_compromise`` runs on the hold's own heartbeat thread, so it may release, and ``release(force=True)`` and ``close()`` work from any thread. ``timeout`` and ``blocking`` set instance-wide defaults that each acquisition inherits. Passing ``None`` per call means "use the instance default", which is not the same as ``-1``: .. code-block:: python rw = SoftReadWriteLock("/shared/nfs/data.lock", timeout=30, blocking=True) with rw.read_lock(): # inherits timeout=30 pass with rw.read_lock(timeout=-1): # blocks forever, overriding the instance default pass with rw.read_lock(blocking=False): # one attempt; ignores timeout entirely pass ``blocking=False`` makes one attempt without sleeping. A writer that gives up while waiting for readers commits itself out of the snapshot, so its own timeout does not leave readers blocked. The next contender, on whichever host, evicts a holder that died on another host once ``stale_threshold`` has passed. Filesystem calls on an unresponsive network mount can still outlast the acquisition timeout. Every generation snapshot is flushed to stable storage before it is linked into place, so a crash cannot leave one empty. A snapshot that still fails to parse (a write from outside filelock, a corrupted disk) makes every acquire raise :class:`SoftFileLockProtocolError ` naming it, because guessing past it could admit two writers. To recover, rename the whole ``.rw`` directory aside, then delete it; the rename takes it away in one step, where a recursive delete would let a participant read it half removed and fail. Every participant, including instances that stay alive, sees a new log and starts over from generation 1. A holder at that moment loses the lock: its next heartbeat reports it through ``on_compromise``, so remove the directory while nobody holds if you can. Generations restart too, so reset the high-water mark of any resource you fence with ``generation``. .. warning:: ``SoftReadWriteLock`` and ``ReadWriteLock`` are singletons by default. A second construction for the same path raises ``ValueError`` if ``timeout`` or ``blocking`` differ, and ``SoftReadWriteLock`` also raises when it passes a different ``on_compromise``. ``heartbeat_interval``, ``stale_threshold``, and ``poll_interval`` keep the first call's tuning on a cache hit. ``SoftReadWriteLock`` still validates these intervals before returning the cached instance. Configure a path in one place. :meth:`get_lock() ` is sugar for the same singleton lookup, spelling the intent out at the call site. ``ReadWriteLock`` has the same classmethod. It offers nothing the constructor does not: .. code-block:: python assert SoftReadWriteLock.get_lock("/shared/nfs/data.lock") is SoftReadWriteLock("/shared/nfs/data.lock") Writer acquisition is writer-preferring: a writer enters the next snapshot as soon as no live writer is named (which blocks any new reader), then waits for the readers that snapshot names to leave. This rules out writer starvation under read-heavy workloads. See :doc:`concepts` for the full model. **Fork caveat.** A process that forks while holding a ``SoftReadWriteLock`` loses the lock in the child. filelock marks the inherited instance fork-invalidated; ``release()`` on it becomes a no-op, and the child must construct a fresh ``SoftReadWriteLock(path)`` before acquiring. This follows the invalidation approach used by PyMongo's connection pools. **Trust boundary.** The class coordinates cooperating processes at one UID. Mode bits (``0o600`` / ``0o700``) keep other UIDs out; they do not make a same-UID co-tenant safe, since it owns the records and can rewrite or delete them directly. A filesystem that delays a heartbeat's visibility past ``stale_threshold`` can also trigger expiry while a holder remains active. See :ref:`concepts:The same-UID boundary` for the full contract. *********************************** Use async read / write locks *********************************** For async code, use :class:`AsyncReadWriteLock `. Because Python's :mod:`sqlite3` module has no async API, it wraps :class:`ReadWriteLock ` and dispatches all blocking SQLite operations to a thread pool via ``loop.run_in_executor``: .. code-block:: python from filelock import AsyncReadWriteLock rw = AsyncReadWriteLock("data.db") async with rw.read_lock(): data = await get_shared_data() async with rw.write_lock(): await update_shared_data() Each ``asyncio`` task owns its own hold, whichever executor thread runs the SQLite call. Tasks share a read lock, and a task asking for the write lock waits until the other holders release, with waiting writers ahead of new readers. A task that acquires again nests into its hold. Only the task that acquired may release; a release from another task raises :class:`RuntimeError`. :class:`AsyncSoftReadWriteLock ` follows the same rules. .. warning:: On Python 3.11, ``asyncio.wait_for(rw.acquire_write(), 5)`` runs the acquire in a child task, which then owns the hold, so releasing it from the caller raises. Bound the wait with the lock's own ``timeout`` or with ``asyncio.timeout()`` instead. You can pass a custom executor, and an explicit event loop: .. code-block:: python from concurrent.futures import ThreadPoolExecutor executor = ThreadPoolExecutor(max_workers=2) rw = AsyncReadWriteLock("data.db", executor=executor, loop=loop) Who owns that executor decides whether you must clean up. Left at ``None``, ``AsyncReadWriteLock`` **creates and owns** a dedicated single-worker pool, because SQLite pins a connection to one thread; ``close()`` shuts it down. Pass your own and filelock uses it as-is and never shuts it down. Either way, ``loop=None`` binds to whatever loop is running at the time of the call rather than at construction: .. code-block:: python rw = AsyncReadWriteLock("data.db") # owns a private single-worker executor try: async with rw.read_lock(): data = await get_shared_data() finally: await rw.close() # shuts down the executor it created With the default ``is_singleton=True`` every wrapper on a path shares one lock, so ``close()`` ends only the holds of tasks that acquired through that wrapper. Tasks holding through another wrapper, and synchronous ``ReadWriteLock`` users of the same path, keep their locks. Low-level ``acquire_read``/``acquire_write``/``release`` methods are also available: .. code-block:: python await rw.acquire_read(timeout=5) try: data = await get_shared_data() finally: await rw.release() The same reentrancy and upgrade/downgrade rules as the synchronous :class:`ReadWriteLock ` apply. See :ref:`how-to:Use shared read / exclusive write locks` for details. For a shared filesystem whose marker and cache behavior has been verified, use :class:`AsyncSoftReadWriteLock `, which wraps :class:`SoftReadWriteLock ` the same way: .. code-block:: python from filelock import AsyncSoftReadWriteLock rw = AsyncSoftReadWriteLock("/shared/nfs/data.lock") async with rw.read_lock(): data = await get_shared_data() It takes the same tuning as its synchronous peer plus the async pair, so the full signature is ``AsyncSoftReadWriteLock(lock_file, timeout=-1, *, blocking=True, is_singleton=True, heartbeat_interval=30.0, stale_threshold=None, poll_interval=0.25, on_compromise=None, loop=None, executor=None)``. It has no SQLite thread affinity to respect, so ``executor=None`` means the loop's default executor: it creates nothing and owns nothing, and its ``close()`` only delegates to the synchronous lock. ************************************** Detect stale locks (soft locks only) ************************************** :class:`SoftFileLock ` stores the PID and hostname of the lock holder. A same-host contender may remove the marker when it can prove that PID does not exist. This happens automatically. You don't need to do anything special: .. code-block:: python from filelock import SoftFileLock lock = SoftFileLock("work.lock") with lock: # If the process holding the lock dies, # another process will automatically clean up the stale lock pass Cross-host records remain because their PID cannot be interpreted locally. On platforms without process-start identity, a reused PID can also keep a dead owner's marker in place. Every platform stores a process start token in the marker to guard against PID recycling: Linux uses the ``/proc//stat`` start time folded with the boot id, macOS reads it through ``sysctl``, and Windows uses the ``GetProcessTimes`` creation time. Malformed records follow a different rule: a waiter may evict them once it has seen them unchanged for two seconds. An empty marker, which is all a ``SoftFileLock`` before 3.22 writes, is never evicted this way; remove a leftover one with ``break_lock()``. That recovery path is not fail closed. A contender breaks a stale marker without the lock path ever standing empty. Where it cannot (NFS, SMB and platforms without an atomic exchange, other than Windows) it leaves the marker in place; see :ref:`concepts:How does stale lock detection work across platforms?` for the details. ******************************** Use fail-closed soft locks ******************************** Use :class:`StrictSoftFileLock ` when an ambiguous claim must block access. It publishes complete owner-specific records through no-replace hard links and removes only its own claim. A process pause during publication cannot expose a partial public record, and release cannot unlink a successor's claim. .. versionadded:: 3.30.0 .. code-block:: python from filelock import StrictSoftFileLock lock = StrictSoftFileLock("work.lock") with lock: update_shared_resource() The lock requires coherent directory reads and atomic hard links. Use it on a local filesystem whose contract provides both operations. If hard-link publication is unavailable, acquisition raises :class:`SoftFileLockProtocolError `. NFS and SMB mounts have no strict guarantee without a contract test for the specific client, server, and mount options. Strict mode leaves ``work.lock`` as a permanent sentinel and stores claims in ``work.lock.filelock/claims``. Every strict participant must use ``StrictSoftFileLock`` or ``AsyncStrictSoftFileLock`` from filelock 3.30.0 or newer. During migration, a legacy ``SoftFileLock`` holder blocks the first strict acquisition until it releases. The first strict acquisition activates the path; filelock 3.20.0 and other legacy clients then time out before entry, including between strict holds. Age expiry, ``break_lock()``, or manual sentinel deletion voids this guarantee. See the `filelock 3.20.0 release `_ for the oldest migration client tested by filelock. A soft-lock owner's marker at ``work.lock`` (from ``SoftFileLock``, ``MarkerSoftFileLock`` or ``SoftFileLease``) blocks strict acquisition like any holder, without appearing in ``lock.claims``; :attr:`lock_path_occupant ` describes it, with its PID and host when the marker records them. A file strict cannot read blocks the same way, because Windows refuses to read a marker its holder keeps open. A file it reads but cannot parse, such as the empty marker filelock before 3.22 wrote or an empty ``UnixFileLock`` lock file, blocks while it changes and raises :class:`SoftFileLockProtocolError ` once it stays unchanged for two seconds, the grace a soft contender waits before evicting a malformed marker other than an empty one. A marker whose owner on this host has exited raises the same error naming that owner, and a directory, symlink, or other node no lock writes raises it after half a second. Strict mode never removes what it found. A crashed owner on another host, or one that left no PID, cannot be told apart from a live one, so its marker blocks until an operator who has confirmed that owner is gone deletes ``work.lock``. A crash can leave an intent, a held claim, or both claims for one token. Strict mode treats each claim as live because PID and clock checks cannot prove that another host or a recycled process has stopped using the resource. Inspect the parsed claims before recovery: .. code-block:: python from filelock import StrictSoftFileLock lock = StrictSoftFileLock("work.lock") for claim in lock.claims: print(claim.name, claim.state, claim.token, claim.hostname, claim.pid) After an operator verifies that the named owner no longer uses the protected resource, remove every claim for its token: .. code-block:: python crashed_token = "0123456789abcdef0123456789abcdef" for claim in lock.claims: if claim.token == crashed_token: lock.force_break(claim.name) ``force_break()`` can admit a contender while the removed claim's owner still runs. It validates a single basename and uses directory-relative deletion on platforms that support it. Unknown record versions, malformed records, symlinks, and unreadable claims raise :class:`SoftFileLockProtocolError `; the exception's ``claim_name`` identifies the entry that needs inspection. An acquisition that fails after publishing a claim removes that claim before it raises, so a failed attempt does not block other contenders. If removing it fails too, :attr:`is_locked ` stays ``False`` and the next ``acquire()`` on the same instance retries the removal before publishing again, raising until it succeeds. Garbage collecting the instance retries it as well. Each publication attempt uses a fresh random private-record name. A private record with another hard link is removed immediately; an unpublished record abandoned by a crash is removed after a two-second grace period. If a live publisher is paused past that grace period, it backs off and retries instead of entering without a public claim. A directory, symlink, or other non-regular node at a private-record name is protocol damage and fails closed without following or removing it. Async code uses the same on-disk protocol: .. code-block:: python from filelock import AsyncStrictSoftFileLock lock = AsyncStrictSoftFileLock("work.lock") async with lock: await update_shared_resource() ``StrictSoftFileLock`` rejects ``on_acquired`` because its borrowed descriptor is the permanent protocol sentinel; a callback could corrupt that sentinel. ``lock.claims`` provides owner metadata without exposing a writable descriptor. The publication sequence follows the private-claim pattern in `flufl.lock `_ and the pre/post claim checks in `restic `_. Issue :issue:`637` records the race analysis and verification requirements. ***************************************** Inspect and manage PID locks ***************************************** :class:`SoftFileLock ` exposes properties to inspect the lock holder and a method to forcibly break the lock. This is useful for migrating from the deprecated `lockfile `_ library's ``PIDLockFile`` class. Read the PID of the current lock holder: .. code-block:: python from filelock import SoftFileLock lock = SoftFileLock("work.lock") with lock: print(lock.pid) # e.g. 12345 print(lock.pid) # None (lock file removed after release) Check whether the current process holds the lock: .. code-block:: python lock = SoftFileLock("work.lock") print(lock.is_lock_held_by_us) # False with lock: print(lock.is_lock_held_by_us) # True Forcibly break a lock regardless of who holds it: .. code-block:: python lock = SoftFileLock("work.lock") lock.break_lock() # removes the lock file unconditionally *********************************** Inspect a protocol 2 owner record *********************************** ``SoftFileLock`` publishes a *protocol 1* marker: a PID and a hostname. :class:`SoftFileLease ` publishes a *protocol 2* marker, which also names the contract its holder acquired under. It reads that marker back through :class:`MarkerSoftFileLock `, its base class, so ``owner`` returns an :class:`OwnerRecord `: .. code-block:: python from filelock import SoftFileLease lease = SoftFileLease("work.lock", lease_duration=30) if (owner := lease.owner) is None: print("no marker, or its record is malformed or protocol 1") else: print(owner.pid, owner.hostname) print(owner.mode) # "lease", or "unknown" for a contract this version cannot interpret print(owner.token) # claim identity print(owner.lease_duration) # 30.0 print(owner.start) # process start token, or None where unavailable Every record holds ``socket.gethostname()`` with each byte outside printable non-space ASCII, and each ``?``, escaped as ``?``, since a marker line cannot carry a space, a newline, or a byte no codec encodes. A conventional hostname reaches the record unchanged, so an escape names a host whose kernel hostname is not one. On Linux, a process outside the host's initial PID namespace appends ``?pidns-``, so a container's records never match a sibling container's that shares its hostname; see :ref:`concepts:How does stale lock detection work across platforms?`. :class:`StrictSoftFileLock ` does **not** publish this record and has no ``owner``: it derives from :class:`BaseFileLock `, keeps a permanent sentinel at the lock path, and stores one record per owner under ``work.lock.filelock/claims``. Read those through :attr:`claims ` instead, as :ref:`how-to:Use fail-closed soft locks` shows. ``owner`` reads the marker on disk each time, so it reports whoever currently holds the path, not necessarily this process. To ask specifically about this process, use ``is_lock_held_by_us``, which compares the PID, the hostname and, when the marker records one, the process start token: .. code-block:: python if lease.is_lock_held_by_us: print("this process wrote the marker") ``owner.mode`` reads ``"unknown"`` when the marker names a contract this version does not implement, which is how a record written by a newer filelock reads to an older one. A lease never reclaims such a marker by age: only a peer that published a lease agreed to be superseded by one. Treat ``"unknown"`` as "held by something I should not touch". Use these records to build recovery tooling, and keep two limits in mind. ``None`` is ambiguous by design: a missing marker, a malformed one, and a protocol 1 one all read the same, because none of them names an owner this contract can trust. And a record is a *report*, not a lock: reading one proves nothing about the next instant. Never gate entry on what ``owner`` returned; acquire the lock. :meth:`force_break() ` removes the marker whoever holds it: .. code-block:: python lease.force_break() # voids mutual exclusion; the old holder keeps running Reserve it for an operator who has confirmed the named owner is gone. ``StrictSoftFileLock.force_break()`` is a different call: it removes one claim by name rather than a single marker. ********************************************* Type options passed through a lock subclass ********************************************* Every keyword filelock's metaclass forwards to a lock is declared in :class:`LockOptions `, a ``TypedDict``. A subclass that adds its own options can use it to type the rest, instead of widening them to ``**kwargs: Any``: .. code-block:: python from typing import Unpack from filelock import FileLock, LockOptions class CountedFileLock(FileLock): def __init__(self, lock_file: str, *, uses: int = 0, **kwargs: Unpack[LockOptions]) -> None: self.uses = uses super().__init__(lock_file, **kwargs) A type checker now rejects ``CountedFileLock("x.lock", timeuot=5)`` at the call site rather than letting the typo reach ``__init__``. filelock uses ``LockOptions`` internally for the same reason, and virtualenv's ``util/lock.py`` wraps ``FileLock`` in a counted subclass much like this one. ***************** Control logging ***************** Every message goes to the ``filelock`` logger. All of them are ``DEBUG``, except one ``WARNING`` that :class:`ReadWriteLock ` emits when a requested timeout exceeds what SQLite's ``busy_timeout`` accepts. Control logging via Python's standard library: .. code-block:: python import logging # Hide filelock debug messages; the ReadWriteLock timeout warning still gets through logging.getLogger("filelock").setLevel(logging.INFO) # Or show all messages logging.getLogger("filelock").setLevel(logging.DEBUG) # Silence filelock entirely, warning included logging.getLogger("filelock").setLevel(logging.ERROR) # Configure a handler to see them handler = logging.StreamHandler() logging.getLogger("filelock").addHandler(handler) ******************************* Choose a soft-lock contract ******************************* :class:`StrictSoftFileLock ` never reclaims a marker. A malformed record, an owner on another host, a dead PID and an old marker all read as held, so acquisition waits rather than overlap a holder that may still be alive. A crashed holder leaves a marker no contender removes; clear it with :meth:`force_break `, which voids mutual exclusion for whoever is still running. :class:`SoftFileLease ` trades exclusion for progress. The holder refreshes its claim, and a peer takes the marker once it has watched it go ``lease_duration`` seconds without a refresh, timed on the peer's own monotonic clock so clock skew between hosts cannot expire a live claim. A peer reclaims a same-host lease or ``MarkerSoftFileLock`` owner that is provably dead at once. The expired holder keeps running and keeps using whatever the lock protects, so a lease says who *should* be working, not who alone is. ``on_compromise`` fires when the claim is lost. :attr:`token ` names a claim but does not fence one: to reject a superseded holder, the protected resource must be linearizable and fence on a monotonic generation it controls. .. code-block:: python from filelock import SoftFileLease, StrictSoftFileLock with StrictSoftFileLock("strict.lock", timeout=30): pass # no peer enters while this holder lives def stop_working(compromise): print("lost the claim:", compromise.reason) with SoftFileLease("lease.lock", lease_duration=60, on_compromise=stop_working): pass # a peer may enter 60s after the last refresh Every contender for a path must agree on ``lease_duration``; one that disagrees raises :class:`LeaseSettingsMismatch ` instead of applying its own expiry to a peer that never agreed to it. Native locks take no lease settings, because pathname age cannot revoke a kernel lock on an inode; they warn and ignore a ``lifetime`` rather than raising. Async callers use ``AsyncStrictSoftFileLock`` and ``AsyncSoftFileLease``. Tune the refresh with ``heartbeat_interval``, which defaults to ``lease_duration / 3`` and must satisfy ``0 < heartbeat_interval < lease_duration`` and stay within :data:`threading.TIMEOUT_MAX` (anything else raises :class:`ValueError`): .. code-block:: python lease = SoftFileLease("work.lock", lease_duration=60, heartbeat_interval=20) Unlike ``lease_duration``, this one is local: it never reaches the marker, so peers on a path may each choose their own without raising ``LeaseSettingsMismatch``. It buys margin rather than time. A refresh that fails transiently is retried, and the lease reports ``refresh-failed`` only once ``lease_duration - heartbeat_interval`` has passed without a success, which is deliberately *before* a peer may legally take the claim. The default leaves room for two missed refreshes; an interval close to ``lease_duration`` collapses that margin to nearly nothing, while a small one adds metadata traffic on the network filesystem these leases usually live on. ``release()`` also joins the heartbeat thread with a timeout of one interval, so a large value lengthens a worst-case release. The heartbeat holds the lease only weakly, so a lease dropped while held is released when it is garbage collected or the interpreter exits, as every other lock is. Windows refuses to rename or delete a file another process holds open, so a peer takes an expired claim there only once the previous holder's process exits and its handle closes. A holder that keeps running but stops refreshing keeps its marker on Windows, while Unix lets a peer reclaim it after ``lease_duration``. **Do not mix contracts on one path.** The two classes publish different records, and only one of them is safe to leave in front of a legacy contender. ``SoftFileLease`` writes a protocol 2 marker, which :class:`SoftFileLock ` reads as malformed and evicts once past its grace period, deleting a live lease. ``StrictSoftFileLock`` instead leaves a sentinel that a current ``SoftFileLock`` recognizes and preserves, so it blocks rather than breaking in. Only these combinations hold: .. list-table:: Mutual exclusion between contenders :header-rows: 1 - - Contenders on one path - Holds? - - ``StrictSoftFileLock`` with ``StrictSoftFileLock`` - Yes. - - ``SoftFileLease`` with ``SoftFileLease``, same ``lease_duration`` - Until a claim expires; then the old holder overlaps its successor. - - ``SoftFileLock`` with ``StrictSoftFileLock`` - Yes, from filelock 3.30.0 on: the strict sentinel is recognized, so the legacy contender waits. A pre-3.30 ``SoftFileLock`` misreads the sentinel and can break in. - - ``SoftFileLock`` with ``SoftFileLease`` - No. The legacy contender reads the protocol 2 marker as malformed and evicts it. - - ``StrictSoftFileLock`` with ``SoftFileLease`` - The lease waits out a strict holder, but a strict contender never reclaims an expired lease. *********************************** Configure legacy age-based expiry *********************************** Only :class:`SoftFileLock ` honors ``lifetime``. The value sets the marker age that permits removal. A waiter may enter after that age even while the previous holder continues its protected operation. The age is the waiter's wall clock minus the marker's modification time, which the writer's or the file server's clock stamped, so every host must keep its clock in sync: a waiter whose clock runs ``lifetime`` ahead removes a marker written a moment ago. ``SoftFileLease`` compares no clocks across hosts. .. code-block:: python from filelock import SoftFileLock # A waiter may remove this marker after one hour. lock = SoftFileLock("work.lock", lifetime=3600) with lock: # This operation can overlap a successor after the marker expires. pass Constructing or assigning a non-``None`` value emits :class:`SoftFileLockLifetimeWarning `, a :class:`FutureWarning` that Python shows by default; silence it with ``-W ignore::filelock.SoftFileLockLifetimeWarning`` once you have accepted the overlap. Migrate to ``SoftFileLease`` when expiry is required or ``StrictSoftFileLock`` when unknown and stale claims must fail closed. Async callers use ``AsyncSoftFileLease`` or ``AsyncStrictSoftFileLock``. ``lifetime=None`` disables age-based removal; same-host dead-PID and malformed-record recovery still apply. Only ``SoftFileLock`` honors the value. Every other lock either drops it with a ``UserWarning`` naming why that backend cannot age out a holder, or refuses the keyword outright: .. list-table:: ``lifetime`` behavior :header-rows: 1 - - Backend and value - Behavior - Mutual-exclusion limit - - ``SoftFileLock``, ``None`` - Disables age-based removal. - Shared-marker recovery and forced breaking can still remove the marker. - - ``SoftFileLock``, non-``None`` - Removes a marker after the configured age. - A live holder may overlap its successor. - - Native lock, non-``None`` - Warns and ignores: a kernel lock cannot be broken safely by file age. - Kernel lock ownership is unchanged. - - ``StrictSoftFileLock``, non-``None`` - Warns and ignores: a strict claim is never broken by age, only by ``force_break()``. - Unchanged; a crashed holder's claim still waits for an operator. - - ``SoftFileLease``, non-``None`` - Warns and ignores: ``lease_duration`` already sets when the claim expires. - Unchanged; expiry follows ``lease_duration``. - - ``ReadWriteLock`` or ``SoftReadWriteLock``, any value - Raises :class:`TypeError`; these constructors take no ``lifetime``. - Not applicable. Native locks (:class:`UnixFileLock ` and :class:`WindowsFileLock `) ignore a non-``None`` value and emit a warning. The same is true when :class:`FileLock ` selects one of those backends; on a build without ``fcntl``, ``FileLock`` may instead alias ``SoftFileLock``. A kernel lock lives on the inode, so pathname age cannot revoke it. The async peers behave as their synchronous counterparts do. ************************** Cancel lock acquisition ************************** You can interrupt a waiting ``acquire()`` by passing a ``cancel_check`` callable. The lock polls this function between retry attempts and raises :class:`Timeout ` when it returns ``True``: .. code-block:: python import threading from filelock import FileLock, Timeout shutdown = threading.Event() lock = FileLock("work.lock") try: with lock.acquire(timeout=-1, cancel_check=shutdown.is_set): print("Got the lock") except Timeout: print("Acquisition canceled") # From another thread: shutdown.set() # causes the acquire loop to stop This is useful in long-running services where you need to shut down gracefully without waiting for a lock that may never become available. .. mermaid:: sequenceDiagram box rgba(21, 101, 192, 0.16) Worker participant W as Worker Thread end box rgba(230, 81, 0, 0.16) Coordination participant L as File Lock end box rgba(69, 39, 160, 0.16) Control participant M as Main Thread end W->>+L: acquire(cancel_check=shutdown.is_set) activate W loop Every poll_interval L->>L: Try lock (busy) L->>W: Check cancel_check() W-->>L: False (keep waiting) end activate M M->>M: shutdown.set() deactivate M L->>W: Check cancel_check() W-->>L: True (cancel!) L->>-W: Raise Timeout deactivate W Note over W: Clean shutdown *********************** Force-release a lock *********************** When a lock is acquired multiple times (reentrant), ``release()`` only decrements the counter. To immediately release regardless of the counter, pass ``force=True``: .. code-block:: python from filelock import FileLock lock = FileLock("work.lock") lock.acquire() lock.acquire() print(lock.lock_counter) # 2 lock.release(force=True) print(lock.is_locked) # False, fully released On a thread-local synchronous lock (the default), ``force=True`` also releases holds left by threads that exited without releasing. Holds of threads still running stay theirs. Dropping the last reference to the lock releases every thread's hold. A thread-local async lock releases only the calling thread's hold. Do not release from a signal handler. Python runs the handler on the main thread between two bytecodes of whatever it was doing, which can be the middle of a ``release()`` of the same lock. Raise from the handler instead, and let the ``with`` block or a ``finally`` release the lock as the stack unwinds: .. code-block:: python import signal from filelock import FileLock def stop(signum, frame): raise SystemExit(1) signal.signal(signal.SIGTERM, stop) with FileLock("work.lock"): ... # SIGTERM unwinds through here and the with block releases the lock A ``release()`` that does run inside a handler while the interrupted code was already releasing that lock on the same thread returns without doing anything, and the interrupted release finishes the job. An ``acquire()`` inside a handler while the interrupted code is still acquiring that lock raises :class:`RuntimeError`: the interrupted acquire cannot go on until the handler returns. A ``release()`` that a handler interrupts after it has started finishes releasing before the handler's exception propagates, so the ``with`` block above never leaves the lock held. Only a handler that fires at the very entry of ``release()`` leaves the hold for the next call. ******************************* Check your own lock state ******************************* Use the :attr:`~filelock.BaseFileLock.is_locked` property to check whether *your* lock instance currently holds the lock, and :attr:`~filelock.BaseFileLock.lock_counter` to see the reentrant depth: .. code-block:: python from filelock import FileLock lock = FileLock("work.lock") if not lock.is_locked: with lock: print(f"Lock depth: {lock.lock_counter}") These properties reflect the state of your lock instance only. To check if *another* process holds the lock, try to acquire with ``blocking=False``: .. code-block:: python from filelock import FileLock, Timeout lock = FileLock("work.lock") try: with lock.acquire(blocking=False): print("Lock was free") except Timeout: print("Lock is held by another process") *************************************** Reconcile body and release failures *************************************** When a ``with`` block fails and releasing the lock on exit also fails, Python's default keeps the body error in the release error's ``__context__``. That buries one error inside the other. Set ``context_error_policy="group"`` to raise both as siblings of a :class:`BaseExceptionGroup`, body first, release second: .. code-block:: python from filelock import FileLock lock = FileLock("work.lock", context_error_policy="group") with lock: raise RuntimeError("body failed") # if release() then also fails, both surface as a BaseExceptionGroup When both errors subclass :class:`Exception`, the group is a plain :class:`ExceptionGroup`, so ``except*`` and ``except Exception`` still catch it. The default ``"chain"`` keeps Python's behavior. filelock also groups the failures of its own cleanup, such as an ``on_acquired`` hook that raises and a rollback release that fails after it. These failures raise a :class:`BaseExceptionGroup`. ***************************************** Handle a close failure after unlock ***************************************** Native locks close their descriptor after the OS unlock commits. Soft locks close the marker descriptor after they capture its identity for safe cleanup. ``os.close`` can fail even though filelock has relinquished ownership; ``close_error_policy`` decides the outcome: - ``"default"`` keeps historical behavior: Unix native locks drop the error, while Windows native and soft locks propagate it. - ``"raise"`` always propagates the ``OSError``. - ``"suppress"`` always ignores it. .. code-block:: python from filelock import FileLock, SoftFileLock native_lock = FileLock("native.lock", close_error_policy="suppress") soft_lock = SoftFileLock("soft.lock", close_error_policy="suppress") Filelock relinquishes descriptor ownership before applying this policy and never retries the descriptor number. The policy does not affect native unlock failures or marker deletion. A soft lock still attempts identity-checked marker cleanup after a close error. *********************************************** Fail closed instead of downgrading to soft *********************************************** On Unix, when ``flock`` returns ``ENOSYS``, :class:`UnixFileLock ` switches to :class:`SoftFileLock ` semantics by default. If the application requires the native backend, construct ``UnixFileLock`` with ``fallback_to_soft=False`` so ``ENOSYS`` propagates: .. code-block:: python from filelock import UnixFileLock lock = UnixFileLock("work.lock", fallback_to_soft=False) ``ENOSYS`` then propagates as the original :class:`OSError` from :func:`fcntl.flock`, with :data:`errno.ENOSYS`; filelock does not wrap it. Two other options imply the same refusal, because a soft lock cannot honor either: ``preserve_lock_file=True`` (a soft lock releases by unlinking its marker) and ``on_acquired`` (a soft lock keeps protocol state in the marker and has no native descriptor to lend). Setting either makes ``UnixFileLock`` raise on ``ENOSYS`` even with the default ``fallback_to_soft=True``, rather than silently downgrade and drop the guarantee you asked for: .. code-block:: python # Each of these raises OSError(ENOSYS) on a filesystem without flock, instead of downgrading. UnixFileLock("work.lock", fallback_to_soft=False) UnixFileLock("work.lock", preserve_lock_file=True) UnixFileLock("work.lock", on_acquired=write_holder) This option does not change ``FileLock`` on a build where the alias is already ``SoftFileLock`` because ``fcntl`` is unavailable. It has no effect on Windows or an explicitly constructed ``SoftFileLock``. ********************************* Keep the lock file on release ********************************* Native backends handle the lock pathname differently: Windows unlinks it after release, Unix leaves it in place. Pass ``preserve_lock_file=True`` for a stable file identity across releases, which matters for ACLs, auditing, or holder metadata written through :ref:`on_acquired `: .. code-block:: python from filelock import FileLock lock = FileLock("work.lock", preserve_lock_file=True) Windows then skips its post-release unlink, and Unix refuses the ``ENOSYS`` soft fallback (which releases by unlinking). :class:`SoftFileLock ` rejects ``True`` because unlinking its marker is how it releases. The promise covers filelock's own release path; it cannot stop another process or the filesystem from removing the file. ***************************************** Run a callback once the lock is held ***************************************** ``on_acquired`` runs once per physical acquisition, after filelock holds the native lock and finished backend initialization, before :meth:`acquire() ` returns. filelock passes the borrowed lock descriptor. The callback may read, write, seek, truncate, or set metadata through ``os`` on it, but must not close, unlock, or take ownership of the descriptor. A recursive acquire does not call it again. If the callback raises, filelock releases the lock and re-raises. A common use is writing holder metadata into the lock file. filelock does not empty an existing lock file, so the hook truncates what an earlier holder wrote: .. code-block:: python import json import os import socket from filelock import FileLock def write_holder(fd: int) -> None: os.ftruncate(fd, 0) os.lseek(fd, 0, os.SEEK_SET) os.write(fd, json.dumps({"pid": os.getpid(), "host": socket.gethostname()}).encode()) lock = FileLock("work.lock", on_acquired=write_holder, preserve_lock_file=True) with lock: ... # while held, other processes can read the holder metadata from work.lock filelock does not ``fsync`` the descriptor's writes. Native locks only; :class:`SoftFileLock ` rejects the hook because it keeps its own protocol state in the marker file. For the same reason, a hook makes :class:`UnixFileLock ` fail closed on a filesystem whose ``flock`` returns ``ENOSYS``: the soft backend it would otherwise fall back to has no descriptor to pass, so downgrading would drop the callback without telling you. See :ref:`how-to:Fail closed instead of downgrading to soft`. ******************************************* Lock a descriptor you already own ******************************************* :func:`lock_descriptor ` and :func:`unlock_descriptor ` take and release the same native lock :class:`FileLock ` uses, but on a file descriptor you opened and own. They add no path handling: no open, truncate, close, unlink, chmod, canonicalize, or fallback. A descriptor lock and a native ``FileLock`` path lock on the same file contend with each other. On Unix this is a whole-file :func:`fcntl.flock` lock. On Windows it is a ``LockFileEx`` lock of one byte at offset 0. Neither function accepts an offset or length, and seeking the descriptor does not change the locked range. .. code-block:: python import os from filelock import lock_descriptor, unlock_descriptor fd = os.open("work.lock", os.O_RDWR | os.O_CREAT) try: lock_descriptor(fd) # blocks until the lock is held try: ... # critical section finally: unlock_descriptor(fd) finally: os.close(fd) Pass ``blocking=False`` for a single attempt that returns ``False`` on contention and ignores ``poll_interval``. Blocking calls require a positive ``poll_interval`` of at most :data:`threading.TIMEOUT_MAX`. There is no async wrapper. Run it in an executor, or drive ``blocking=False`` from your own polling loop. On Windows *fd* must be a synchronous descriptor. To interoperate with another tool, all participants must agree on the file, locking primitive, range, and protocol. For example, `conda's repodata lock `_ locks one byte at offset 21 using :func:`fcntl.lockf` on Unix or :func:`msvcrt.locking` on Windows. ``lock_descriptor`` is not a portable replacement for that protocol: the Windows ranges do not overlap, and Unix ``flock`` and ``lockf`` are different primitives whose interaction depends on the operating system and filesystem. Use the primitive and range required by the existing protocol; sharing a file descriptor alone does not establish compatibility. Both functions raise ``OSError`` with :data:`errno.ENOSYS` when the Python build lacks the native locking primitive. The :class:`FileLock ` and :class:`AsyncFileLock ` aliases continue to select their soft implementations on those builds. For timeout, reentrancy, singleton, lifetime, or stale-break behavior, use :class:`FileLock `. filelock-4.2.8/docs/index.rst0000644000000000000000000001106315272561277013053 0ustar00########## filelock ########## A platform-independent file locking library for Python, providing inter-process synchronization: .. code-block:: python from filelock import FileLock lock = FileLock("high_ground.txt.lock") with lock: with open("high_ground.txt", "a") as f: f.write("You were the chosen one.") .. image:: example.gif :alt: filelock in action ************** Installation ************** ``filelock`` is available via PyPI: .. code-block:: bash python -m pip install filelock **************** Learn filelock **************** .. grid:: 1 2 2 2 :gutter: 2 .. grid-item-card:: **New to file locking?** Start with the :doc:`tutorials` to learn the basics through hands-on examples. .. grid-item-card:: **Have a specific task?** Check :doc:`how-to` for task-oriented solutions to real-world problems. .. grid-item-card:: **Want to understand the design?** Read :doc:`concepts` to explore design decisions and trade-offs. .. grid-item-card:: **Need API details?** See the :doc:`api` reference for complete technical documentation. ************ Lock Types ************ Choose the right lock for your use case: .. grid:: 1 2 2 2 :gutter: 2 .. grid-item-card:: **FileLock** Platform-aware alias. Selects a native backend where available and a soft marker otherwise. - ✓ Recommended default - ✓ Cancellable acquire - ✓ Self-deadlock detection .. grid-item-card:: **SoftFileLock** Cooperative file-existence marker. Verify shared-filesystem semantics before deployment. - ✓ No native locking API required - ✓ Same-host PID inspection - ✓ Reclaims a provably-gone owner via process start identity .. grid-item-card:: **StrictSoftFileLock** Fail-closed owner claims for filesystems with coherent directory reads and atomic hard links. - ✓ Never guesses that an owner died - ✓ Removes claims by unique name - ✓ Manual recovery with owner metadata .. grid-item-card:: **SoftFileLease** Expiring claim with a heartbeat, for when overlap is acceptable and progress matters more than exclusion. - ✓ A peer can take over a wedged holder - ✓ ``on_compromise`` callback on claim loss - ✓ Fence the resource: a token names a claim, it does not fence one .. grid-item-card:: **ReadWriteLock** SQLite-backed multiple readers + one writer. Singleton by default. - ✓ Concurrent readers - ✓ Reentrant per mode - ✓ Async via AsyncReadWriteLock .. grid-item-card:: **SoftReadWriteLock** Reader/writer lease over a generation log, for tested shared filesystems. - ✓ Clock-free heartbeat expiry - ✓ Writer preference - ✓ Fencing generation for the protected resource - ✓ Async via AsyncSoftReadWriteLock .. grid-item-card:: **AsyncFileLock** Async-compatible variants. Run blocking I/O in thread pool or custom executor. - ✓ Async/await support - ✓ All lock types - ✓ Custom executor and event loop ****************** Platform Support ****************** .. grid:: 1 2 2 2 :gutter: 2 .. grid-item-card:: **Windows** Uses ``LockFileEx`` on a byte range. Enforced by the kernel. - ✓ Native support - ✓ Most reliable .. grid-item-card:: **Unix / macOS** Uses ``fcntl.flock``. Common on Unix but not POSIX; kernel-enforced. - ✓ Native support - ✓ Released automatically on crash .. grid-item-card:: **Other Platforms** Automatic fallback to the cooperative ``SoftFileLock`` marker protocol. - ✓ No native locking API required - ✓ Usable where creation and cache behavior have been verified ******************* Similar libraries ******************* - `pid `_ - process ID file locks - `msvcrt `_ - Windows file locking (stdlib) - `fcntl `_ - Unix file locking (stdlib) - `flufl.lock `_ - Another file locking library - `fasteners `_ - Cross-platform locks and synchronization .. toctree:: :hidden: self tutorials how-to concepts api license changelog filelock-4.2.8/docs/license.rst0000644000000000000000000000015715272561277013370 0ustar00######### License ######### *py-filelock* is licensed under the MIT License: .. literalinclude:: ../LICENSE filelock-4.2.8/docs/logo.svg0000644000000000000000000000142515272561277012674 0ustar00 FileLock filelock-4.2.8/docs/tutorials.rst0000644000000000000000000004204515272561277013776 0ustar00########### Tutorials ########### This section guides you through the fundamentals of file locking, starting with the basics and building up to advanced patterns. Each lesson is built around a job real programs hand to a file lock: guarding a download cache, memoizing an expensive probe, coordinating readers and writers on a shared filesystem. ***************** Your first lock ***************** Start with the job that pulls most projects to a file lock in the first place: two copies of a program racing to populate the same cache entry. `huggingface_hub `_ downloads model weights into a shared cache directory. When two processes want the same file, one should fetch it while the other waits, rather than both streaming gigabytes over the same connection. It puts a lock beside the cache entry, keyed on the file's ETag: .. code-block:: python from filelock import FileLock lock = FileLock("cache/.locks/models/bert-base/a1b2c3.lock") The lock object represents a lock file on disk. Nothing is locked yet. Use it with a context manager (the ``with`` statement): .. code-block:: python with lock: # Inside this block, we hold the lock print("I have the lock!") # Outside this block, the lock is released Run this in several terminals at once. Only one process prints at a time; the others wait their turn. Two details of that path are deliberate, and both come from real deployments: - The lock lives beside the cache entry, not on it. Locking the target file itself would mean creating the very file whose absence you are testing for. - The lock file name comes from a hash. `datasets `_ hashes long names against ``os.statvfs(path).f_namemax``; ext4 limits one component to 255 bytes, while NTFS allows 255 Unicode characters. Even a short key can contain a path separator, so hash every key before using it as a lock filename. ************************ Protecting shared data ************************ A lock is worth little on its own. It earns its keep when it wraps a *check, then act* sequence that would otherwise race. `virtualenv `_ caches what it learns about each Python interpreter it probes, since launching an interpreter to interrogate it is slow. Two virtualenv runs starting together would both probe, and both write. The lock turns that into one probe: .. code-block:: python import json from pathlib import Path from filelock import FileLock cache_file = Path("py_info/5/a1b2c3.json") lock = FileLock("py_info/5/a1b2c3.json.lock") with lock: if cache_file.exists(): info = json.loads(cache_file.read_text()) else: info = probe_interpreter() # slow: launches the interpreter cache_file.write_text(json.dumps(info)) The check and the write live inside the same lock. That is the whole point: if you test ``cache_file.exists()`` outside the lock and write inside it, two processes can both see it missing and both probe. .. mermaid:: sequenceDiagram box rgba(21, 101, 192, 0.16) virtualenv runs participant A as virtualenv A participant B as virtualenv B end box rgba(230, 81, 0, 0.16) Coordination participant L as Lock end box rgba(46, 125, 50, 0.16) App data participant C as Cache file end activate A A->>L: acquire() activate L B->>L: acquire() (waits...) A->>+C: exists()? C-->>-A: no Note over A: probe interpreter (slow) A->>C: write info A->>L: release() deactivate L deactivate A L->>B: acquired activate B activate L B->>+C: exists()? C-->>-B: yes Note over B: reuse, no probe B->>L: release() deactivate L deactivate B ***************** Reentrant locks ***************** Sometimes you need to acquire the same lock again from code already holding it. The lock allows this: .. code-block:: python from filelock import FileLock lock = FileLock("py_info/5/a1b2c3.json.lock") def read_cached_info(): with lock: return load_info() with lock: refresh_cache() info = read_cached_info() # acquires the same lock again No deadlock occurs. The lock counts acquisitions and releases only when the count reaches zero. This is what lets a helper take the lock defensively without knowing whether its caller already holds it. You can inspect the counter and state at any time: .. code-block:: python lock = FileLock("reentrant.lock") print(lock.is_locked) # False print(lock.lock_counter) # 0 lock.acquire() print(lock.is_locked) # True print(lock.lock_counter) # 1 lock.acquire() print(lock.lock_counter) # 2 lock.release() print(lock.lock_counter) # 1, still locked print(lock.is_locked) # True lock.release() print(lock.lock_counter) # 0, fully released print(lock.is_locked) # False virtualenv leans on this directly: its ``util/lock.py`` wraps ``FileLock`` in a counted subclass so nested acquisitions of the same app-data entry are cheap. ***************************** Multiple ways to use a lock ***************************** So far we've used the ``with`` statement. There are other ways: **Manual acquire and release:** .. code-block:: python lock.acquire() try: print("I have the lock") finally: lock.release() Always use a ``try/finally`` block to guarantee the lock is released, even if an exception occurs. **As a decorator:** .. code-block:: python @lock def protected_operation(): print("This function runs with the lock held") protected_operation() # Lock is acquired, function runs, lock is released Choose whichever feels most natural for your code. The ``with`` statement reads clearest. Important: Always use the context manager ========================================= Avoid this pattern: .. code-block:: python FileLock("my.lock").acquire() # âš ï¸� Don't do this # The lock might be released during garbage collection # before your code finishes This doesn't work reliably because if you don't assign the lock to a variable, Python's garbage collector might release it before you're done with it. Instead, always keep a reference to the lock object: .. code-block:: python lock = FileLock("my.lock") with lock: # ✓ Good # your code here pass ************************** Thread-local by default ************************** By default, each lock uses thread-local state (``thread_local=True``): each thread tracks its own lock counter. Two threads holding the same ``FileLock`` object each get their own reentrant state. Async locks default to ``thread_local=False`` because they run in a thread pool where the acquiring thread may differ from the releasing thread. See :ref:`how-to:Use locks with multiple threads` for practical examples of controlling this behavior. ********************** Your first async lock ********************** An async program cannot afford a blocking ``acquire()``: while one coroutine waits on the filesystem, the event loop should keep serving everything else. Use the async variants with ``async with``: .. code-block:: python from pathlib import Path from filelock import AsyncFileLock lock = AsyncFileLock("cache/.locks/models/bert-base/a1b2c3.lock") async def fetch_once(url: str) -> bytes: async with lock: blob = Path("cache/models/bert-base/a1b2c3.bin") if not blob.exists(): blob.parent.mkdir(parents=True, exist_ok=True) blob.write_bytes(await download(url)) return blob.read_bytes() The lock runs its blocking filesystem calls in a thread pool, so the event loop stays free while you wait. .. warning:: Plain ``with`` does not work on an async lock. ``acquire`` and ``release`` are coroutines; use ``async with``. Every lock type in this tutorial has an async peer: :class:`AsyncSoftFileLock `, :class:`AsyncStrictSoftFileLock `, :class:`AsyncSoftFileLease `, :class:`AsyncReadWriteLock `, and :class:`AsyncSoftReadWriteLock `. They share the on-disk protocol of their synchronous counterparts, so a sync and an async process contend correctly with each other. See :ref:`how-to:Use async locks`. ******************************* Reading who holds a soft lock ******************************* A native lock lives in the kernel, so there is nothing on disk to read: the file is a handle, not a record. A soft lock is the opposite. Its marker file *is* the protocol, and it names its owner. This is what lets an operator answer "what is holding this thing?" without attaching a debugger. PostgreSQL is the canonical example: it writes ``postmaster.pid`` into the data directory so a second server, and a human, can see who claims it. :class:`SoftFileLock ` publishes a PID and hostname: .. code-block:: python from filelock import SoftFileLock lock = SoftFileLock("server.lock") with lock: print(lock.pid) # e.g. 12345 print(lock.is_lock_held_by_us) # True :class:`SoftFileLease ` publishes more. It derives from :class:`MarkerSoftFileLock `, which writes a richer *protocol 2* record and hands it back as an :class:`OwnerRecord `: .. code-block:: python from filelock import SoftFileLease lease = SoftFileLease("server.lock", lease_duration=30) with lease: if (owner := lease.owner) is not None: # None if the marker is missing or unreadable print(owner.pid, owner.hostname) print(owner.mode) # "lease" print(owner.token) # names this particular claim print(owner.lease_duration) # 30.0 print(owner.start) # process start token, or None on platforms without one :class:`StrictSoftFileLock ` keeps its owner metadata differently, one record per claim read through ``lock.claims``, so it has no ``owner`` property. That ``start`` field is what keeps a recycled PID from reading as a live owner. The operating system reuses PIDs, so "PID 12345 exists" does not mean "the process that wrote this marker exists". filelock folds in a per-platform start token (Linux ``/proc//stat`` plus the boot id, macOS ``sysctl``, Windows ``GetProcessTimes``) so a contender can tell the difference. .. note:: PostgreSQL writes a start time into ``postmaster.pid`` but does not compare it; it defends against PID reuse structurally instead. It is a precedent for the problem, not for this solution. See :ref:`how-to:Inspect a protocol 2 owner record` for using these records in recovery tooling. *************************************** Migrating from lockfile.PIDLockFile *************************************** If you're migrating from the deprecated `lockfile `_ library, :class:`SoftFileLock ` is the direct replacement for ``PIDLockFile``. It writes the process ID to the lock file and can detect stale locks. .. code-block:: python # Before (lockfile): from lockfile.pidlockfile import PIDLockFile lock = PIDLockFile("/tmp/myapp.lock") lock.acquire() print(lock.read_pid()) print(lock.i_am_locking()) lock.release() # After (filelock): from filelock import SoftFileLock lock = SoftFileLock("/tmp/myapp.lock") with lock: print(lock.pid) print(lock.is_lock_held_by_us) Key differences from ``PIDLockFile``: - ``read_pid()`` is now a property: ``lock.pid`` - ``i_am_locking()`` is now the ``lock.is_lock_held_by_us`` property - ``break_lock()`` is now ``lock.break_lock()`` (same name) - Stale lock detection happens automatically on acquire (all platforms) - Supports context managers, reentrant locking, timeouts, and all other filelock features ******************************************* Reader/writer locks on shared filesystems ******************************************* When many processes read a resource and few write it, an exclusive lock serializes work that never needed serializing. A reader/writer lock lets readers share and writers exclude. `restic `_ is the reference case: many backup clients read a repository at once, while a prune must run alone. It matters that restic builds this *above* the filesystem, because no atomic reader/writer primitive survives the object stores and network mounts it targets. filelock draws the same line: Keep SQLite-backed :class:`ReadWriteLock ` on a local filesystem supported by the active SQLite VFS. On a shared filesystem, use :class:`SoftReadWriteLock ` only after verifying exclusive creation, no-replace hard links, unlink, and cache visibility across participating hosts. Heartbeat expiry permits another holder to enter if an old process pauses and later resumes. .. code-block:: python from pathlib import Path from filelock import SoftReadWriteLock rw = SoftReadWriteLock("/shared/nfs/work.lock") data_file = Path("/shared/nfs/data.json") with rw.read_lock(): # Cooperating readers can hold the lock together. data = data_file.read_text() with rw.write_lock(): # New readers wait behind the writer named in the latest snapshot. data_file.write_text(new_data) While the lock is held, you will see a protocol directory on disk next to ``work.lock``: .. code-block:: text work.lock.rw/gen/00000000000000000042 # immutable snapshot: which tokens read and which one writes work.lock.rw/holders/ # one record per participant; its nonce is the heartbeat A daemon heartbeat thread rewrites each holder's nonce every ``heartbeat_interval`` seconds. A peer may evict a holder whose record has not changed for ``stale_threshold`` seconds on the peer's own clock, so hosts never compare clocks. Set the threshold above expected process and filesystem pauses, and fence protected writes with ``generation`` if an expired process can resume: .. code-block:: python rw = SoftReadWriteLock( "/shared/nfs/work.lock", heartbeat_interval=120, stale_threshold=360, ) Those two numbers are a ratio, not a pair of independent knobs. restic's repository lock runs the same model with a 5-minute refresh against a 30-minute stale timeout, and treats a lock it could not refresh within 22.5 minutes as lost: the margin absorbs a slow filesystem. See :doc:`concepts` for the full explanation of the nonce heartbeat. ******************************* Stronger soft-lock contracts ******************************* A plain :class:`SoftFileLock ` is a cooperative marker: it reclaims a marker only once its owner is provably gone, but a shared marker cannot promise that two processes never overlap. When you need more, two soft locks state an explicit contract. :class:`StrictSoftFileLock ` gives mutual exclusion without a native lock. It never breaks a claim by age, so a crashed holder's claim is cleared only by an operator: .. code-block:: python from filelock import StrictSoftFileLock lock = StrictSoftFileLock("work.lock") with lock: do_exclusive_work() # After a crash, an operator inspects and clears the stale claim by name: stale = StrictSoftFileLock("work.lock") for claim in stale.claims: stale.force_break(claim.name) :class:`SoftFileLease ` trades exclusion for progress. Its claim expires, so a peer can take over if a holder wedges; a callback fires when this process loses its claim. A lease token names a claim but does not fence the protected resource, so fence any resource an expired holder could still write: .. code-block:: python from filelock import SoftFileLease lease = SoftFileLease("work.lock", lease_duration=30, on_compromise=lambda c: stop_work()) with lease: do_resumable_work() restic and Terraform resolve this choice in opposite directions. Terraform holds its state lock until an operator runs ``force-unlock``, preferring to hang over risking two concurrent applies, while a lease keeps the work moving and leaves you to fence the resource. Choose according to which failure your system can absorb. See :ref:`how-to:Choose a soft-lock contract` to pick between them. ************ Next steps ************ - Waiting on a lock a user can see? See :ref:`how-to:Report progress while waiting` and :ref:`how-to:Probe a lock before blocking on it`. - Need strict exclusion or an expiring lease? See :ref:`how-to:Use fail-closed soft locks` and :ref:`how-to:Choose a soft-lock contract`. - Want to handle timeouts, cancellation, or force-release? See :doc:`how-to`. - Curious about how locks work across different platforms? Read :doc:`concepts`. filelock-4.2.8/docs/_static/custom.css0000644000000000000000000001622415272561277014670 0ustar00/* Widen furo's body column, and keep both tables of contents on screen while doing it. Furo compiles its layout from SASS: `.content { width: 46em }`, 15em drawers either side, and breakpoints derived from their sum ($full-width = 82em). A media query resolves `em` against the *initial* font size, which is always 16px, while the layout resolves the same unit against the root font size the reader's browser actually applies. The two only agree at a 16px default. Widening the body by re-setting those fixed widths therefore pushes the layout past the viewport for anyone reading at a larger font: the columns grow, the breakpoint does not move, and the right-hand table of contents is clipped off the edge before anything folds it away. So do not restate a width the breakpoints depend on. Pin the two drawers, cap the body, and let the body alone take up the slack. The layout then fits at any font size, and the reader keeps both tables of contents. */ .content { width: auto; max-width: 69em; min-width: 0; flex: 1 1 auto; } /* A flex item refuses to shrink below its content's intrinsic width unless told otherwise, and the body's widest token is a line of code. Without this the row holding the body and the right drawer stops shrinking at whatever the longest code line measures, and pushes the drawer off the edge instead. Let the row shrink: a long line then scrolls inside its own code block, which furo already handles, rather than scrolling the whole page sideways. */ .main { min-width: 0; } /* The drawers share a flex row with the body, so an unpinned drawer donates its width to a growing body: the table of contents stays on screen but shrinks until its entries wrap into an unreadable ribbon. Hold both at 15em. */ .toc-drawer { flex: 0 0 15em; } /* min-width keeps the navigation at 15em once the gutter runs out, so the body absorbs the remainder rather than the drawer collapsing. The calc centers the wider block on a roomy screen: half the padded body (69em + 2*3em) is 37.5em. Furo does the same around its own narrower column. */ .sidebar-drawer { width: calc(50% - 37.5em); min-width: 15em; flex: 0 0 auto; } /* Furo folds the right-hand table of contents into an icon below 82em, which on a normal desktop is width the reader still has. Keep it down to the mobile breakpoint (63em), where furo turns the navigation into an overlay and the drawers genuinely have nowhere to sit. Restoring the desktop rules means undoing furo's fold in full: static flow, no fixed panel off the right edge, and the desktop type scale rather than the larger mobile one. */ @media (min-width: 63.0625em) { .toc-content-icon { display: none; } .toc-drawer { position: static; top: auto; right: auto; height: auto; border-left: none; } .toc-tree { border-left: 1px solid var(--color-background-border); font-size: var(--toc-font-size); } } /* Center each diagram and let a wide flowchart scroll on its own. */ .mermaid { text-align: center; overflow-x: auto; } /* The diagrams carry no colors of their own: sphinxcontrib-mermaid renders them in the browser and re-renders them on every theme toggle, so a palette written into the diagram source would be baked at whichever theme happened to load first and then stay light on a dark page. The source names a role (`class default recommended`), mermaid copies that name onto the node, and the two palettes below fill it in. Keep a tint's meaning consistent across diagrams: blue is the caller's own code, orange is the lock coordinating it, green is the data under the lock, purple the way out. Mermaid scopes its own rules under the generated SVG's id, which outranks anything selectable from here, so these declarations need !important to land. */ body { --diagram-green-bg: #e8f5e9; --diagram-green-line: #2e7d32; --diagram-green-ink: #1b5e20; --diagram-orange-bg: #fff3e0; --diagram-orange-line: #e65100; --diagram-orange-ink: #bf360c; --diagram-blue-bg: #e3f2fd; --diagram-blue-line: #1565c0; --diagram-blue-ink: #0d47a1; --diagram-purple-bg: #ede7f6; --diagram-purple-line: #4527a0; --diagram-purple-ink: #311b92; } /* Dark tints hold their hue at roughly a tenth of the light fill's lightness, and lift the ink to the same hue's pale end: every pair clears 7:1, which the light palette's orange does not quite manage. Mirror furo's own two-selector split so an explicit toggle and an untouched "auto" both land. */ @media not print { body[data-theme="dark"] { --diagram-green-bg: #1b3a24; --diagram-green-line: #66bb6a; --diagram-green-ink: #c8e6c9; --diagram-orange-bg: #3d2a14; --diagram-orange-line: #ffa726; --diagram-orange-ink: #ffe0b2; --diagram-blue-bg: #12304d; --diagram-blue-line: #42a5f5; --diagram-blue-ink: #bbdefb; --diagram-purple-bg: #272040; --diagram-purple-line: #9575cd; --diagram-purple-ink: #d1c4e9; } @media (prefers-color-scheme: dark) { body:not([data-theme="light"]) { --diagram-green-bg: #1b3a24; --diagram-green-line: #66bb6a; --diagram-green-ink: #c8e6c9; --diagram-orange-bg: #3d2a14; --diagram-orange-line: #ffa726; --diagram-orange-ink: #ffe0b2; --diagram-blue-bg: #12304d; --diagram-blue-line: #42a5f5; --diagram-blue-ink: #bbdefb; --diagram-purple-bg: #272040; --diagram-purple-line: #9575cd; --diagram-purple-ink: #d1c4e9; } } } .mermaid g.node.recommended > rect, .mermaid g.node.store > rect { fill: var(--diagram-green-bg) !important; stroke: var(--diagram-green-line) !important; } .mermaid g.node.recommended .nodeLabel, .mermaid g.node.store .nodeLabel { color: var(--diagram-green-ink) !important; } .mermaid g.node.alternative > rect, .mermaid g.node.counter > rect { fill: var(--diagram-orange-bg) !important; stroke: var(--diagram-orange-line) !important; } .mermaid g.node.alternative .nodeLabel, .mermaid g.node.counter .nodeLabel { color: var(--diagram-orange-ink) !important; } .mermaid g.node.special > rect, .mermaid g.node.thread > rect { fill: var(--diagram-blue-bg) !important; stroke: var(--diagram-blue-line) !important; } .mermaid g.node.special .nodeLabel, .mermaid g.node.thread .nodeLabel { color: var(--diagram-blue-ink) !important; } .mermaid g.node.escape > rect { fill: var(--diagram-purple-bg) !important; stroke: var(--diagram-purple-line) !important; } .mermaid g.node.escape .nodeLabel { color: var(--diagram-purple-ink) !important; } /* A sequence diagram's `box` renders as a bare `rect.rect` with no per-box hook, so the tints above cannot reach it and the group colors stay in the diagram source. They are translucent for that reason: an alpha over the hue composites against whichever background the theme paints, reading pale on white and deep on the dark page, and it shifts the lightness too little to strand the title text mermaid draws on top in the theme's own ink. */ filelock-4.2.8/src/filelock/__init__.py0000644000000000000000000001361315272561277014755 0ustar00""" A platform independent file lock that supports the with-statement. .. autodata:: filelock.__version__ :no-value: .. autodata:: filelock.OwnerMode .. autodata:: filelock.CompromiseReason """ from __future__ import annotations __lazy_modules__ = ["filelock._descriptor", "filelock._error", "importlib"] import sys import warnings from importlib import import_module from typing import TYPE_CHECKING, Final from filelock._api import AcquireReturnProxy, BaseFileLock, CloseErrorPolicy, ContextErrorPolicy, LockOptions from filelock._descriptor import lock_descriptor, unlock_descriptor from filelock._error import LeaseSettingsMismatch, SoftFileLockLifetimeWarning, SoftFileLockProtocolError, Timeout from filelock._unix import UnixFileLock from filelock._unix import has_fcntl as _has_fcntl from filelock._windows import WindowsFileLock # Keeps ``filelock.version`` the string 4.0.12 exposed rather than the submodule. from filelock.version import version if TYPE_CHECKING: from filelock import asyncio from filelock._async_read_write import AsyncAcquireReadWriteReturnProxy, AsyncReadWriteLock from filelock._lease import CompromiseReason, LeaseCompromise, SoftFileLease from filelock._marker import MarkerSoftFileLock, OwnerMode, OwnerRecord from filelock._read_write import ReadWriteLock from filelock._soft import SoftFileLock from filelock._soft_rw import AsyncAcquireSoftReadWriteReturnProxy, AsyncSoftReadWriteLock, SoftReadWriteLock from filelock._strict import StrictSoftFileClaim, StrictSoftFileClaimState, StrictSoftFileLock from filelock.asyncio import ( AsyncAcquireReturnProxy, AsyncSoftFileLease, AsyncSoftFileLock, AsyncStrictSoftFileLock, AsyncUnixFileLock, AsyncWindowsFileLock, BaseAsyncFileLock, ) #: version of the project as a string __version__: Final[str] = version #: ``True`` when :func:`fcntl.flock` is available. Off Windows, :data:`FileLock` then resolves to #: :class:`UnixFileLock`, otherwise to :class:`SoftFileLock`. Always ``False`` on Windows. #: #: :meta hide-value: has_fcntl: Final[bool] = _has_fcntl if sys.platform == "win32": # pragma: win32 cover _FileLock: type[BaseFileLock] = WindowsFileLock else: # pragma: win32 no cover # ruff:ignore[collapsible-else-if] # the else carries the win32 no-cover pragma if has_fcntl: _FileLock: type[BaseFileLock] = UnixFileLock else: from filelock._soft import SoftFileLock _FileLock = SoftFileLock warnings.warn("only soft file lock is available", stacklevel=2) if TYPE_CHECKING: FileLock = SoftFileLock AsyncFileLock = AsyncSoftFileLock else: #: Alias for the lock, which should be used for the current platform. FileLock = _FileLock # Importing asyncio and sqlite3 costs more than the rest of the package, so names that need them load on first access. _LAZY_ATTRIBUTES: Final[dict[str, str]] = { "AsyncAcquireReadWriteReturnProxy": "._async_read_write", "AsyncAcquireReturnProxy": ".asyncio", "AsyncAcquireSoftReadWriteReturnProxy": "._soft_rw", "AsyncFileLock": ".asyncio", "AsyncReadWriteLock": "._async_read_write", "AsyncSoftFileLease": ".asyncio", "AsyncSoftFileLock": ".asyncio", "AsyncSoftReadWriteLock": "._soft_rw", "AsyncStrictSoftFileLock": ".asyncio", "AsyncUnixFileLock": ".asyncio", "AsyncWindowsFileLock": ".asyncio", "BaseAsyncFileLock": ".asyncio", "CompromiseReason": "._lease", "LeaseCompromise": "._lease", "MarkerSoftFileLock": "._marker", "OwnerMode": "._marker", "OwnerRecord": "._marker", "ReadWriteLock": "._read_write", "SoftFileLease": "._lease", "SoftFileLock": "._soft", "SoftReadWriteLock": "._soft_rw", "StrictSoftFileClaim": "._strict", "StrictSoftFileClaimState": "._strict", "StrictSoftFileLock": "._strict", "asyncio": ".asyncio", } def __getattr__(name: str) -> object: if (module_name := _LAZY_ATTRIBUTES.get(name)) is None: msg = f"module {__name__!r} has no attribute {name!r}" raise AttributeError(msg) try: module = import_module(module_name, __name__) except ImportError as error: # Only a missing sqlite3 module (the package, CPython's _sqlite3, PyPy's _sqlite3_cffi) leaves the read-write # locks None; a frozen application that left out a filelock module gets the error that names it. sqlite_missing = (error.name or "").startswith(("sqlite3", "_sqlite3")) if module_name not in {"._read_write", "._async_read_write"} or not sqlite_missing: raise value = None # pragma: lacks sqlite3 else: if name == "asyncio": value = module else: value = getattr(module, f"Async{_FileLock.__name__}" if name == "AsyncFileLock" else name) globals()[name] = value return value def __dir__() -> list[str]: return sorted({*globals(), *_LAZY_ATTRIBUTES}) __all__ = [ "AcquireReturnProxy", "AsyncAcquireReadWriteReturnProxy", "AsyncAcquireReturnProxy", "AsyncAcquireSoftReadWriteReturnProxy", "AsyncFileLock", "AsyncReadWriteLock", "AsyncSoftFileLease", "AsyncSoftFileLock", "AsyncSoftReadWriteLock", "AsyncStrictSoftFileLock", "AsyncUnixFileLock", "AsyncWindowsFileLock", "BaseAsyncFileLock", "BaseFileLock", "CloseErrorPolicy", "CompromiseReason", "ContextErrorPolicy", "FileLock", "LeaseCompromise", "LeaseSettingsMismatch", "LockOptions", "MarkerSoftFileLock", "OwnerMode", "OwnerRecord", "ReadWriteLock", "SoftFileLease", "SoftFileLock", "SoftFileLockLifetimeWarning", "SoftFileLockProtocolError", "SoftReadWriteLock", "StrictSoftFileClaim", "StrictSoftFileClaimState", "StrictSoftFileLock", "Timeout", "UnixFileLock", "WindowsFileLock", "__version__", "asyncio", "has_fcntl", "lock_descriptor", "unlock_descriptor", ] filelock-4.2.8/src/filelock/_api.py0000644000000000000000000032005615272561277014130 0ustar00from __future__ import annotations __lazy_modules__ = [ "atexit", "decimal", "filelock._error", "filelock._util", "inspect", "math", "numbers", "secrets", "types", "warnings", ] import atexit import contextlib import inspect import logging import math import os import secrets import stat import sys import time import types import warnings from abc import ABCMeta, abstractmethod from collections.abc import Callable, Hashable from contextlib import contextmanager from dataclasses import dataclass, fields from decimal import Decimal from functools import cache, partial from itertools import count, starmap from numbers import Real from threading import TIMEOUT_MAX, Condition, RLock, Thread, current_thread, get_ident, local from typing import TYPE_CHECKING, Final, Generic, Literal, NoReturn, TypedDict, TypeVar, cast from weakref import WeakKeyDictionary, WeakValueDictionary, ref from filelock._error import SoftFileLockLifetimeWarning, Timeout from filelock._util import ensure_directory_exists #: No explicit file permission mode was passed. Lock files then open with 0o666 so umask and default ACLs pick #: the final permissions, and fchmod is skipped to preserve POSIX default ACL inheritance. _UNSET_FILE_MODE: Final[int] = -1 _PERMISSION_BITS: Final[int] = stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO #: Ceiling on the retry counter used as a power of two, so a long contended wait cannot overflow the backoff multiply. _MAX_BACKOFF_EXPONENT: Final[int] = 20 #: How a context manager reconciles a body failure with a release failure on exit (see the property of this name). ContextErrorPolicy = Literal["chain", "group"] _CONTEXT_ERROR_POLICIES: Final[frozenset[str]] = frozenset({"chain", "group"}) #: What a descriptor-owning backend does with an ``os.close`` failure after relinquishing ownership (see the property). CloseErrorPolicy = Literal["default", "raise", "suppress"] _CLOSE_ERROR_POLICIES: Final[frozenset[str]] = frozenset({"default", "raise", "suppress"}) if TYPE_CHECKING: from collections.abc import Generator, Mapping from threading import Lock from types import FrameType, TracebackType from typing import Protocol from _typeshed import Unused from filelock._lease import _LeaseClaim from filelock._read_write import ReadWriteLock from filelock._soft_rw import SoftReadWriteLock class _ForkResettable(Protocol): def _reset_after_fork_in_child(self) -> None: ... class _ForkDescriptorOwner(Protocol): def _descriptors_for_fork(self) -> tuple[tuple[int, tuple[int, int] | None], ...]: ... # Matched against the class object itself rather than `type[...]` of it. A metaclass supplies this method to the # class while leaving instances without it, so a `type[_ForkResettableClass]` bound rejects `ReadWriteLock`. class _ForkResettableClass(Protocol): def _reset_class_after_fork(self) -> None: ... class _RegisterAtFork(Protocol): def __call__( self, *, before: Callable[[], None] | None = None, after_in_parent: Callable[[], None] | None = None, after_in_child: Callable[[], None] | None = None, ) -> None: ... from typing import Self _LOGGER: Final[logging.Logger] = logging.getLogger("filelock") _REGISTER_AT_FORK: Final[_RegisterAtFork | None] = cast("_RegisterAtFork | None", getattr(os, "register_at_fork", None)) _HAS_REGISTER_AT_FORK: Final[bool] = _REGISTER_AT_FORK is not None _ExtraValue = TypeVar("_ExtraValue") _MarkerValue = TypeVar("_MarkerValue") _SubclassValue = TypeVar("_SubclassValue") _LockInitValue = float | int | bool | str | None | Callable[[int], None] class LockOptions(TypedDict, total=False): """Every option the metaclass forwards, so a subclass adding its own can still type what it passes through.""" timeout: float mode: int thread_local: bool blocking: bool is_singleton: bool poll_interval: float lifetime: float | None context_error_policy: ContextErrorPolicy close_error_policy: CloseErrorPolicy fallback_to_soft: bool preserve_lock_file: bool on_acquired: Callable[[int], None] | None def _is_exception_group(error: BaseException) -> bool: return isinstance(error, BaseExceptionGroup) def _raise_grouped_errors( message: str, first_error: BaseException, second_error: BaseException, *additional_errors: BaseException, marker: tuple[str, _MarkerValue] | None = None, ) -> NoReturn: errors = (first_error, second_error, *additional_errors) _detach_grouped_contexts(errors) group: Final = BaseExceptionGroup(message, errors) if marker is not None: setattr(group, marker[0], marker[1]) raise group from None def _detach_grouped_contexts(errors: tuple[BaseException, ...]) -> None: seen: set[int] = set() pending = list(errors) while pending: error = pending.pop() if id(error) in seen: continue seen.add(id(error)) if (context := error.__context__) is not None and ( context is error or _same_exception_tree(error, context) or any(context is root or _contains_exception(root, context) for root in errors) ): error.__context__ = None elif context is not None: pending.append(context) if error.__cause__ is not None: pending.append(error.__cause__) if _is_exception_group(error): pending.extend(cast("_ExceptionGroupProtocol", error).exceptions) def _same_exception_tree(first: BaseException, second: BaseException) -> bool: pending = [(first, second)] seen: set[tuple[int, int]] = set() while pending: first_error, second_error = pending.pop() if first_error is second_error: continue if (pair := (id(first_error), id(second_error))) in seen: continue seen.add(pair) if ( type(first_error) is not type(second_error) or not _is_exception_group(first_error) or not _is_exception_group(second_error) ): return False first_group = cast("_ExceptionGroupProtocol", first_error) second_group = cast("_ExceptionGroupProtocol", second_error) if first_group.message != second_group.message or len(first_group.exceptions) != len(second_group.exceptions): return False pending.extend(zip(first_group.exceptions, second_group.exceptions, strict=True)) return True def _contains_exception(error: BaseException, target: BaseException | None) -> bool: if target is None or not _is_exception_group(error): return False pending = list(cast("_ExceptionGroupProtocol", error).exceptions) seen: set[int] = set() while pending: child = pending.pop() if child is target: return True if id(child) in seen: continue seen.add(id(child)) if _is_exception_group(child): pending.extend(cast("_ExceptionGroupProtocol", child).exceptions) return False def _append_exception_context(error: BaseException, context: BaseException) -> None: if _exception_graph_contains(error, context) or _exception_graph_contains(context, error): return if error.__context__ is None: error.__context__ = context return tail = error seen: set[int] = set() while id(tail) not in seen: seen.add(id(tail)) if (next_error := tail.__cause__ if tail.__cause__ is not None else tail.__context__) is None: tail.__context__ = context return tail = next_error def _exception_graph_contains(error: BaseException, target: BaseException) -> bool: pending = [error] seen: set[int] = set() while pending: current = pending.pop() if current is target: return True if id(current) in seen: # pragma: no cover - arbitrary caller exceptions can contain cycles continue seen.add(id(current)) if current.__cause__ is not None: pending.append(current.__cause__) if current.__context__ is not None: pending.append(current.__context__) if _is_exception_group(current): pending.extend(cast("_ExceptionGroupProtocol", current).exceptions) return False def _grouped_errors( error: BaseException, message: str, marker: tuple[str, _MarkerValue] ) -> tuple[BaseException, ...] | None: if not _is_exception_group(error): return None group = cast("_ExceptionGroupProtocol", error) return group.exceptions if group.message == message and getattr(group, marker[0], None) is marker[1] else None if TYPE_CHECKING: class _ExceptionGroupProtocol(Protocol): @property def message(self) -> str: ... @property def exceptions(self) -> tuple[BaseException, ...]: ... def _raise_chained_errors(first_error: BaseException, second_error: BaseException | None = None) -> NoReturn: if second_error is None: first_context = first_error.__context__ try: raise first_error # ruff:ignore[raise-within-try] # the handler restores caller-supplied context before propagation except BaseException: first_error.__context__ = first_context raise if (second_context := second_error.__context__) is not None and second_context is not first_error: _detach_exception_context(second_context, first_error) _append_exception_context(first_error, second_context) first_context = first_error.__context__ try: raise first_error # ruff:ignore[raise-within-try] # the second raise needs this error as implicit context except BaseException: # ruff:ignore[blind-except] # first_error may be a control-flow exception first_error.__context__ = first_context try: raise second_error # ruff:ignore[raise-within-try] # the handler makes the chain interpreter-independent except BaseException: second_error.__context__ = first_error first_error.__context__ = first_context raise def _detach_exception_context(error: BaseException, target: BaseException) -> None: pending = [error] seen: set[int] = set() while pending: current = pending.pop() if id(current) in seen: continue seen.add(id(current)) if current.__context__ is target: current.__context__ = None elif current.__context__ is not None: pending.append(current.__context__) if current.__cause__ is not None: pending.append(current.__cause__) if _is_exception_group(current): pending.extend(cast("_ExceptionGroupProtocol", current).exceptions) def _raise_body_and_release(body_error: BaseException, release_error: BaseException) -> NoReturn: # Group mode: surface the body failure and the release failure as sibling leaves instead of letting one hide in the # other's __context__. BaseExceptionGroup returns a plain ExceptionGroup when both leaves subclass Exception, so # ``except*`` and ``except Exception`` still catch them; a BaseException leaf (KeyboardInterrupt, CancelledError) # keeps the group outside ordinary handlers. ``from None`` stops the group itself gaining a redundant __context__. _raise_grouped_errors("lock body and release both failed", body_error, release_error) def _raise_cleanup_errors( message: str, primary_error: BaseException, *cleanup_errors: BaseException | None, ) -> NoReturn: _raise_grouped_errors( message, primary_error, *(error for error in cleanup_errors if error is not None), ) # On Windows os.path.realpath calls CreateFileW with share_mode=0, which blocks concurrent DeleteFileW and causes # livelocks under threaded contention with SoftFileLock. os.path.abspath is purely string-based and avoids this. _resolve_dir: Final[Callable[[str], str]] = os.path.abspath if sys.platform == "win32" else os.path.realpath def _canonical(path: str | os.PathLike[str]) -> str: """ Return one stable key for *path*, collapsing equivalent spellings without following a final symlink. Relative, absolute, and ``./`` spellings of one lock file must map to a single singleton instance, deadlock-registry entry, and removal key. Resolving the whole path with ``realpath`` would follow a final symlink and alias a lock target the backend deliberately rejects, so the registry identity would differ from the backend's. Resolving only the parent directory and re-appending the literal final component collapses the equivalent spellings while keeping a final symlink a distinct key. On Windows the parent is resolved with ``abspath`` so junctions and reparse points are not followed either. """ parent, name = os.path.split(os.fspath(path)) return os.path.join(_resolve_dir(parent or os.curdir), name) # ruff:ignore[os-path-join] # string join matches abspath/realpath class _ThreadLocalRegistry(local): def __init__(self) -> None: super().__init__() self.held: dict[Hashable, int] = {} _registry: Final[_ThreadLocalRegistry] = _ThreadLocalRegistry() _T = TypeVar("_T", bound="BaseFileLock") _LockT = TypeVar("_LockT", bound="BaseFileLock | ReadWriteLock | SoftReadWriteLock") class FileLockMeta(ABCMeta): _instances: WeakValueDictionary[str, BaseFileLock] _instances_lock: RLock _instances_under_construction: set[str] def __call__( # ruff:ignore[too-many-arguments] # forwards the public constructor's documented parameters cls: type[_T], lock_file: str | os.PathLike[str], timeout: float = -1, mode: int = _UNSET_FILE_MODE, thread_local: bool = True, # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument] # public API: positional bool kept for backwards compatibility *, blocking: bool = True, is_singleton: bool = False, poll_interval: float = 0.05, lifetime: float | None = None, context_error_policy: ContextErrorPolicy = "chain", close_error_policy: CloseErrorPolicy = "default", fallback_to_soft: bool = True, preserve_lock_file: bool = False, on_acquired: Callable[[int], None] | None = None, **kwargs: _ExtraValue, ) -> _T: _ensure_current_process() _resolve_lazy_imports_before_shutdown() lifetime = _resolve_lifetime(lifetime, cls, stacklevel=cls._constructor_lifetime_warning_stacklevel) poll_interval = _resolve_poll_interval(poll_interval) # Validate before building the instance: a raise inside __init__ would leave a half-constructed object whose # __del__ then trips over the missing context. _check_blocking(blocking) # nan is left to acquire, which knows whether the call blocks; a non-blocking call ignores the timeout. timeout = _seconds("timeout", timeout) _check_mode(mode, cls.__name__) context_error_policy = _resolve_context_error_policy(context_error_policy) close_error_policy = _resolve_close_error_policy(close_error_policy) preserve_lock_file = _resolve_preserve_lock_file( preserve=preserve_lock_file, supported=cls._preserve_lock_file_supported, cls_name=cls.__name__ ) on_acquired = _resolve_on_acquired(on_acquired, supported=cls._on_acquired_supported, cls_name=cls.__name__) # Checked before the singleton lookup so a cache hit rejects a misspelled option just like a fresh build does. params: Final = _supported_init_params( cls, { "timeout": timeout, "mode": mode, "thread_local": thread_local, "blocking": blocking, "is_singleton": is_singleton, "poll_interval": poll_interval, "lifetime": lifetime, "context_error_policy": context_error_policy, "close_error_policy": close_error_policy, "fallback_to_soft": fallback_to_soft, "preserve_lock_file": preserve_lock_file, "on_acquired": on_acquired, **kwargs, }, ) if not is_singleton: return super().__call__(lock_file, **params) # Look up, build and store under one lock. Without it two threads racing the first construction for a # path both miss the cache and each build their own instance, so callers relying on is_singleton for # reentrant locking across instances end up with two "singletons" and acquire()'s deadlock check then # rejects a legitimate reentrant acquire; the unguarded writes to the WeakValueDictionary are a data # race besides. ReadWriteLock and SoftReadWriteLock already guard their singleton caches this way. # Key the cache on the canonical form so equivalent spellings of one path share a singleton, and it matches the # deadlock-registry key acquire() uses. singleton_key = _canonical(lock_file) with cls._instances_lock: # A cached lock resolves its relative path at each acquisition and pins it for the hold. After a chdir, or # once a parent symlink points elsewhere, an unheld lock may name another file than this key; a held one # names the file it pinned. if (instance := cls._instances.get(singleton_key)) is None or _canonical( instance._lock_path # ruff:ignore[private-member-access] # the metaclass owns the instance it returns ) != singleton_key: if singleton_key in cls._instances_under_construction: # pragma: needs fork msg = f"Singleton lock construction is already active for {lock_file!s}" raise RuntimeError(msg) construction_registry = cls._instances_under_construction construction_pid = os.getpid() construction_registry.add(singleton_key) try: instance = super().__call__(lock_file, **params) finally: construction_registry.discard(singleton_key) if os.getpid() != construction_pid: # pragma: needs fork msg = "Lock construction cannot continue after fork; construct a new lock in the child" raise RuntimeError(msg) cls._instances[singleton_key] = instance instance._singleton_key = singleton_key # ruff:ignore[private-member-access] # the cache entry the finalizer looks up return instance non_matching_params = { name: (str(passed_param), str(set_param)) for name, (passed_param, set_param) in { "thread_local": (thread_local, instance.is_thread_local()), # Compare the wait, not the value: nan never equals itself, and -1, -5 and inf all mean no limit. "timeout": ( _timeout_behavior(timeout, any_negative=True), _timeout_behavior(instance.timeout, any_negative=True), ), "mode": (mode, instance._context.mode), # ruff:ignore[private-member-access] # compares against the managed instance's own context "blocking": (blocking, instance.blocking), "poll_interval": (poll_interval, instance.poll_interval), "lifetime": (lifetime, instance.lifetime), "context_error_policy": (context_error_policy, instance.context_error_policy), "close_error_policy": (close_error_policy, instance.close_error_policy), "fallback_to_soft": (fallback_to_soft, instance.fallback_to_soft), "preserve_lock_file": (preserve_lock_file, instance.preserve_lock_file), }.items() if passed_param != set_param } # The metaclass owns the instance it returns, so reading the subclass hook stays inside this module's contract. non_matching_params.update(instance._singleton_extra_mismatches(kwargs)) # ruff:ignore[private-member-access] # Callables compare by identity, not equality: two equal callables can close over different state, so a # singleton must reject a different hook object even if it compares equal. Keep it out of the scalar dict above. hook_mismatch = on_acquired is not instance.on_acquired if not non_matching_params and not hook_mismatch: return instance # ty: ignore[invalid-return-type] # https://github.com/astral-sh/ty/issues/3231 msg = "Singleton lock instances cannot be initialized with differing arguments" msg += "\nNon-matching arguments: " for param_name, (passed_param, set_param) in non_matching_params.items(): msg += f"\n\t{param_name} (existing lock has {set_param} but {passed_param} was passed)" if hook_mismatch: msg += f"\n\ton_acquired (existing lock has {instance.on_acquired} but {on_acquired} was passed)" raise ValueError(msg) def _resolve_lifetime(lifetime: float | None, cls: type[BaseFileLock], *, stacklevel: int) -> float | None: """ Validate ``lifetime`` and drop a value the backend cannot honor. ``lifetime`` is a deliberate age-based lease: a lock file older than ``lifetime`` is broken even while its holder is still alive. Existence locks (:class:`SoftFileLock`) implement that behavior by unlinking a reclaimable pathname, which can overlap a live holder. A native OS lock lives on the inode, so unlinking the pathname by age cannot revoke the kernel lock; a contender would lock a fresh inode and overlap the live holder (#590). Ignore the request with a warning rather than accept a setting that breaks mutual exclusion. """ if lifetime is None: return None # An int stays exact: no float holds every int 4.0.12 accepted here. if isinstance(lifetime, bool) or not isinstance(lifetime, int): lifetime = _seconds("lifetime", lifetime) if lifetime < 0 or (isinstance(lifetime, float) and not math.isfinite(lifetime)): msg = f"lifetime must be finite and non-negative, not {lifetime!r}" raise ValueError(msg) if not cls._lifetime_supported: warnings.warn( f"lifetime is ignored for {cls.__name__}: {cls._lifetime_unsupported_reason}; " f"only SoftFileLock supports lifetime-based expiry", stacklevel=stacklevel, ) return None if cls._lifetime_replacements is not None: strict_lock, lease = cls._lifetime_replacements warnings.warn( f"{cls.__name__}(lifetime=...) uses age-based expiry and can overlap a live holder; " f"use {lease} for expiry or {strict_lock} for fail-closed locking", SoftFileLockLifetimeWarning, stacklevel=stacklevel, ) return lifetime def _resolve_poll_interval(poll_interval: float) -> float: """Validate ``poll_interval`` used for ``time.sleep`` / ``asyncio.sleep`` retries.""" if (poll_interval := _seconds("poll_interval", poll_interval)) < 0 or not math.isfinite(poll_interval): msg = f"poll_interval must be finite and non-negative, not {poll_interval!r}" raise ValueError(msg) _check_timeout_max("poll_interval", poll_interval) return poll_interval def _check_blocking(blocking: bool) -> None: # ruff:ignore[boolean-type-hint-positional-argument] # validates the option's own value if not isinstance(blocking, bool): msg = f"blocking must be a bool, not {type(blocking).__name__}" raise TypeError(msg) def _seconds(name: str, value: float) -> float: # 4.0.12 accepted Decimal and Fraction; a bool is a typo, and a string would only fail later. if isinstance(value, bool) or not isinstance(value, (Real, Decimal)): msg = f"{name} must be a number of seconds, not {type(value).__name__}" raise TypeError(msg) # float() refuses a signaling NaN, which means no number of seconds just as a quiet one does. if isinstance(value, Decimal) and value.is_snan(): return math.nan try: seconds: Final = float(value) except OverflowError: msg = f"{name} is too large to be a number of seconds: {value!r}" raise ValueError(msg) from None # float() rounds a tiny negative to -0.0, which compares as zero, yet timeout and lifetime checks read the sign. return -math.ulp(0.0) if seconds == 0 and value < 0 else seconds def _check_mode(mode: int, cls_name: str) -> None: if mode == _UNSET_FILE_MODE: return if isinstance(mode, bool) or not isinstance(mode, int): msg = f"mode must be an int, not {type(mode).__name__}" raise TypeError(msg) # A lock file is never executed, so setuid, setgid and sticky have no meaning on it and only widen its permissions. if not 0 <= mode <= _PERMISSION_BITS: msg = f"mode={mode:#o} must be within 0o000-0o777 for {cls_name}" raise ValueError(msg) # A lock reopens, reads, or deletes the files it creates, so a mode that denies the owner read or write fails later # and for good. if ~mode & (stat.S_IRUSR | stat.S_IWUSR): msg = f"mode={mode:#o} must grant the owner read and write for {cls_name}" raise ValueError(msg) def _resolve_context_error_policy(policy: str) -> ContextErrorPolicy: if policy not in _CONTEXT_ERROR_POLICIES: msg = f"context_error_policy must be 'chain' or 'group', got {policy!r}" raise ValueError(msg) return cast("ContextErrorPolicy", policy) def _resolve_close_error_policy(policy: str) -> CloseErrorPolicy: if policy not in _CLOSE_ERROR_POLICIES: msg = f"close_error_policy must be 'default', 'raise', or 'suppress', got {policy!r}" raise ValueError(msg) return cast("CloseErrorPolicy", policy) def _resolve_preserve_lock_file(*, preserve: bool, supported: bool, cls_name: str) -> bool: # An existence lock unlinks its marker to release, so preserving the pathname would defeat unlocking. Reject the # request rather than silently ignore it, since a caller asking for a stable identity must know it cannot be kept. if preserve and not supported: msg = f"preserve_lock_file=True is not supported by {cls_name}: unlinking its marker is how it releases" raise ValueError(msg) return preserve def _resolve_on_acquired( on_acquired: Callable[[int], None] | None, *, supported: bool, cls_name: str ) -> Callable[[int], None] | None: if on_acquired is None: return None # An existence lock stores protocol state in its marker, so a caller writing through the descriptor would corrupt # stale detection and ownership metadata; only native locks lend out the descriptor. if not supported: msg = f"on_acquired is not supported by {cls_name}: only native locks expose the lock descriptor" raise ValueError(msg) return on_acquired def _supported_init_params( cls: type[BaseFileLock], params: dict[str, _LockInitValue | _ExtraValue] ) -> dict[str, _LockInitValue | _ExtraValue]: if (model := _init_parameter_model(cls)).accepts_kwargs: return params if unsupported := sorted( name for name, value in params.items() if name not in model.accepted_params and ((parameter := model.default_params.get(name)) is None or value != parameter.default) ): msg = f"{cls.__name__} does not support non-default lock options: {', '.join(unsupported)}" raise TypeError(msg) # virtualenv narrows a BaseFileLock descendant's signature; omit base defaults it does not accept (#340). return {name: value for name, value in params.items() if name in model.accepted_params} def _timeout_behavior(timeout: float, *, any_negative: bool) -> float | str: # inf, and for a BaseFileLock any negative value, wait without a limit like -1. if math.isnan(timeout): return "nan" return -1.0 if timeout == math.inf or (any_negative and timeout < 0) else timeout def _check_timeout_max(name: str, value: float) -> None: # Lock.acquire, Event.wait and time.sleep raise OverflowError past this bound, which is only ~49.7 days on Windows. if value > TIMEOUT_MAX: msg = f"{name} must not exceed threading.TIMEOUT_MAX ({TIMEOUT_MAX}), got {value!r}" raise ValueError(msg) _INIT_PARAMETER_MODELS: Final[WeakKeyDictionary[type[BaseFileLock], _InitParameterModel]] = WeakKeyDictionary() def _init_parameter_model(cls: type[BaseFileLock]) -> _InitParameterModel: # A strong cache would keep dynamically created subclasses alive for the process lifetime. with _fork_transition(), _FORK_STATE.parameter_models_lock: if (model := _INIT_PARAMETER_MODELS.get(cls)) is None: parameters = inspect.signature(cls.__init__).parameters.values() model = _InitParameterModel( accepted_params=frozenset( parameter.name for parameter in parameters if parameter.kind in {inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY} ), accepts_kwargs=any(parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters), default_params={ name: parameter for name, parameter in inspect.signature(type(cls).__call__).parameters.items() if parameter.default is not inspect.Parameter.empty }, ) _INIT_PARAMETER_MODELS[cls] = model return model @dataclass(frozen=True) class _InitParameterModel: accepted_params: frozenset[str] accepts_kwargs: bool default_params: dict[str, inspect.Parameter] class BaseFileLock(contextlib.ContextDecorator, metaclass=FileLockMeta): # ruff:ignore[too-many-public-methods] # public config properties """ Abstract base class for a file lock object. Provides the common reentrant API and state management. Subclasses implement the locking mechanism (:class:`UnixFileLock `, :class:`WindowsFileLock `, :class:`SoftFileLock `). """ _instances: WeakValueDictionary[str, BaseFileLock] _instances_lock: RLock _instances_under_construction: set[str] #: How the cross-instance deadlock message names the conflicting holder; the async subclass says "task". _deadlock_holder_desc: str = "FileLock instance in this thread" #: Whether an age-based :attr:`lifetime` lease may break this lock. Only existence locks set it (they reclaim by #: unlinking a pathname); native OS locks leave it ``False`` since a kernel lock cannot be revoked by file age. _lifetime_supported: bool = False #: Strict-lock and lease replacements for a backend with legacy age-based expiry. _lifetime_replacements: tuple[str, str] | None = None #: Why a backend that refuses ``lifetime`` cannot honor it, named in the warning that drops the value. _lifetime_unsupported_reason: str = "a native OS lock cannot be broken safely by file age" #: Async construction adds one metaclass frame before lifetime validation. _constructor_lifetime_warning_stacklevel: int = 3 #: Whether :attr:`preserve_lock_file` may be ``True``. Native locks keep the pathname on release, so they support #: it; existence locks unlink their marker to release and reject it. _preserve_lock_file_supported: bool = True #: Whether an :attr:`on_acquired` hook may be set. Native locks lend the descriptor out; existence locks keep #: protocol state in the marker and reject it. _on_acquired_supported: bool = True #: Ceiling in seconds on the jittered backoff between contended acquisition retries. ``0`` keeps the fixed #: poll cadence; a multi-file backend sets it so contending processes desynchronize instead of livelocking. _poll_backoff_cap: float = 0.0 #: The singleton cache key of this instance, or ``None`` outside the cache. _singleton_key: str | None = None def __init_subclass__(cls, **kwargs: _SubclassValue) -> None: """Give each lock subclass its own singleton registry and lock.""" super().__init_subclass__(**kwargs) cls._instances = WeakValueDictionary() cls._instances_lock = RLock() cls._instances_under_construction = set() _register_fork_class(cls) @classmethod def _reset_class_after_fork(cls) -> None: # pragma: forked child cls._instances = WeakValueDictionary() cls._instances_lock = RLock() cls._instances_under_construction = set() def __init__( # ruff:ignore[too-many-arguments] # public constructor: one parameter per documented lock option self, lock_file: str | os.PathLike[str], timeout: float = -1, mode: int = _UNSET_FILE_MODE, thread_local: bool = True, # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument] # public API: positional bool kept for backwards compatibility *, blocking: bool = True, is_singleton: bool = False, poll_interval: float = 0.05, lifetime: float | None = None, context_error_policy: ContextErrorPolicy = "chain", close_error_policy: CloseErrorPolicy = "default", fallback_to_soft: bool = True, preserve_lock_file: bool = False, on_acquired: Callable[[int], None] | None = None, ) -> None: """ Create a new lock object. :param lock_file: path to the file :param timeout: default timeout when acquiring the lock, in seconds. It will be used as fallback value in the acquire method, if no timeout value (``None``) is given. If you want to disable the timeout, set it to a negative value or ``inf``. A timeout of 0 means that there is exactly one attempt to acquire the file lock. :param mode: file permissions for the lockfile. When not specified, the OS controls permissions via umask and default ACLs, preserving POSIX default ACL inheritance in shared directories. Construction raises :class:`ValueError` unless it grants the owner read and write, which the lock needs for its own files. On Windows a native lock creates its file with an owner-only ACL when the mode has no group and other bits; any other mode, and a soft lock, leave the file the ACL its directory hands down. A Unix lock file that already exists keeps its permissions. :param thread_local: Whether this object's internal context should be thread local or not. If this is set to ``False`` then the lock will be reentrant across threads. When ``True`` (the default), **all fields of the lock's internal context are per-thread**, including the configuration values ``poll_interval``, ``timeout``, ``blocking``, ``mode``, and ``lifetime``. Setting one of these properties from one thread does not change the value seen by another thread; threads that did not perform the write continue to see the value supplied at construction time. ``mode`` has no setter, so construction is the only place it is ever set. If you need configuration values to be visible across threads, construct the lock with ``thread_local=False``. :param blocking: whether the lock should be blocking or not :param is_singleton: If this is set to ``True`` then only one instance of this class will be created per lock file. This is useful if you want to use the lock object for reentrant locking without needing to pass the same object around. :param poll_interval: default interval for polling the lock file, in seconds. It will be used as fallback value in the acquire method, if no poll_interval value (``None``) is given. :param lifetime: for :class:`SoftFileLock`, the age in seconds after which a waiting process may delete the marker, even while its holder remains alive. This legacy expiry mode does not provide strict mutual exclusion. ``None`` (the default) disables age-based expiry. Native OS locks (:class:`FileLock`) cannot be revoked by file age and ignore a non-``None`` ``lifetime`` with a warning. :param context_error_policy: how a context manager reconciles a failure in its body with a failure while releasing on exit. ``"chain"`` (the default) keeps Python's behavior: the release error propagates with the body error in its ``__context__``. ``"group"`` raises a :class:`BaseExceptionGroup` holding the body error first and the release error second, so neither hides the other. :param close_error_policy: what to do with an ``os.close`` failure after relinquishing descriptor ownership. ``"default"`` keeps each backend's historical behavior (Unix native locks drop a FUSE/Docker ``EIO``; Windows native locks and :class:`SoftFileLock` propagate); ``"raise"`` always propagates the ``OSError``; ``"suppress"`` always ignores it. Held state is released either way. It does not affect unlock failures or lock-file deletion. :param fallback_to_soft: for :class:`UnixFileLock`, whether to switch to :class:`SoftFileLock` when the filesystem's ``flock`` returns ``ENOSYS``. ``True`` (the default) keeps the historical fallback; ``False`` fails closed, letting the ``ENOSYS`` propagate so a caller that needs kernel-enforced locking is never silently downgraded. It has no effect on Windows or :class:`SoftFileLock`. :param preserve_lock_file: for native locks (:class:`FileLock`), whether filelock promises not to unlink the lock pathname on release. ``False`` (the default) keeps each backend's cleanup: Windows removes the lock file, Unix already leaves it. ``True`` keeps a stable file identity for ACLs, auditing, and holder metadata: Windows skips its post-release unlink and Unix refuses to enter the ``ENOSYS`` soft fallback (which releases by unlinking). :class:`SoftFileLock` rejects ``True``. The promise covers filelock's own release path only; it cannot stop another process or the filesystem from removing the pathname. :param on_acquired: for native locks (:class:`FileLock`), a callable invoked with the borrowed lock descriptor once per physical acquisition, after filelock holds the native lock and finished backend initialization but before :meth:`~BaseFileLock.acquire` returns. Recursive acquisitions do not call it again. The callback may read, write, seek, truncate, or set metadata through ``os`` on the descriptor, but must not close, unlock, or take ownership of it, and filelock does not fsync its writes. If it raises, filelock releases the lock and re-raises. :class:`SoftFileLock` rejects the hook. :raises TypeError: if *timeout* or *poll_interval* is a :class:`bool` or not a real number, *blocking* is not a :class:`bool`, or *mode* is not an :class:`int` :raises ValueError: if *poll_interval* is negative, not finite, or above :data:`threading.TIMEOUT_MAX`, if *timeout* is too large to convert to :class:`float`, or if *mode* falls outside ``0o000``-``0o777`` or denies the owner read and write """ self._creator_pid = os.getpid() #: Holder id in the deadlock registry; a stand-in that finishes the finalizer has another id(self). self._holder_id = id(self) self._transition_lock = RLock() self._is_thread_local = thread_local self._is_singleton = is_singleton self._context_error_policy = context_error_policy # already validated by the metaclass self._close_error_policy = close_error_policy # already validated by the metaclass self._fallback_to_soft = fallback_to_soft self._preserve_lock_file = preserve_lock_file # already validated by the metaclass self._on_acquired = on_acquired # already validated by the metaclass self._context: FileLockContext = (ThreadLocalFileContext if thread_local else FileLockContext)( lock_file=os.fspath(lock_file), timeout=timeout, mode=mode, blocking=blocking, poll_interval=poll_interval, lifetime=lifetime, ) #: Each thread's committed hold on a thread-local lock, so another thread can release it. self._thread_holds: dict[int, tuple[FileLockContext, Thread]] = {} #: Each thread's uncommitted acquisition, so a backend can finish it for an exited thread. self._thread_pending: dict[int, tuple[FileLockContext, Thread]] = {} _register_fork_object(self) def _singleton_extra_mismatches( # ruff:ignore[no-self-use] # the base class adds no options of its own self, _kwargs: Mapping[str, _ExtraValue], / ) -> dict[str, tuple[str, str]]: # A subclass's own options reach the metaclass only as kwargs, and only the subclass knows their defaults and # how __init__ resolves them, so each override compares its options and merges what super() reports. return {} def is_thread_local(self) -> bool: """:returns: a flag indicating if this lock is thread local or not""" return self._is_thread_local @property def is_singleton(self) -> bool: """ A flag indicating if this lock is singleton or not. .. versionadded:: 3.13.0 """ return self._is_singleton @property def context_error_policy(self) -> ContextErrorPolicy: """ How a context manager reconciles a body failure with a release failure on exit. .. versionadded:: 3.30.0 """ return self._context_error_policy @property def close_error_policy(self) -> CloseErrorPolicy: """ What a lock does with an ``os.close`` failure after relinquishing descriptor ownership. .. versionadded:: 3.30.0 """ return self._close_error_policy def _close_released_fd(self, fd: int, *, default_suppresses: bool) -> None: # CPython never retries close() after EINTR because the descriptor number may already be reused, so neither does # this. close_error_policy decides the error's fate after the backend relinquishes descriptor ownership. try: os.close(fd) except OSError: if not self._suppresses_close_error(default_suppresses=default_suppresses): raise def _suppresses_close_error(self, *, default_suppresses: bool) -> bool: return self._close_error_policy == "suppress" or (self._close_error_policy == "default" and default_suppresses) @property def fallback_to_soft(self) -> bool: """ Whether a :class:`FileLock` falls back to :class:`SoftFileLock` when the filesystem lacks ``flock``. Only :class:`UnixFileLock` acts on it: when ``False`` an ``ENOSYS`` from ``flock`` propagates instead of switching to existence-lock semantics. .. versionadded:: 3.30.0 """ return self._fallback_to_soft @property def preserve_lock_file(self) -> bool: """ Whether filelock promises not to unlink the lock pathname on release. When ``True``, Windows skips its post-release unlink and Unix refuses the ``ENOSYS`` soft fallback. :class:`SoftFileLock` rejects ``True`` because unlinking its marker is how it releases. .. versionadded:: 3.30.0 """ return self._preserve_lock_file @property def on_acquired(self) -> Callable[[int], None] | None: """ The callback run with the borrowed lock descriptor once per physical acquisition, or ``None``. Native locks only. It runs after the native lock is held and backend initialization finished, before :meth:`~BaseFileLock.acquire` returns; a raise rolls back the acquisition. :class:`SoftFileLock` rejects it. .. versionadded:: 3.30.0 """ return self._on_acquired @property def lock_file(self) -> str: """Path to the lock file.""" return self._context.lock_file @property def _lock_path(self) -> str: # The OS resolves a relative lock_file against the working directory at each call. A chdir during an # acquisition or a hold would otherwise make the backend wait on, lock or remove a different file. return self._context.claim_root or self._context.lock_file @property def timeout(self) -> float: """ The default timeout value, in seconds. .. versionadded:: 2.0.0 """ return self._context.timeout @timeout.setter def timeout(self, value: float | str | bytes | bytearray) -> None: """ Change the default timeout value. :param value: the new value, in seconds; a negative value or ``inf`` waits without a limit :raises TypeError: if *value* is a :class:`bool` or not a real number :raises ValueError: if *value* is too large to convert to :class:`float` .. deprecated:: 4.0.13 Passing a numeric string; pass the number instead. """ if isinstance(value, (str, bytes, bytearray)): warnings.warn( "setting timeout from a string is deprecated and will be removed; pass a number", DeprecationWarning, stacklevel=2, ) value = float(value) self._context.timeout = _seconds("timeout", value) @property def blocking(self) -> bool: """ Whether the locking is blocking or not. .. versionadded:: 3.14.0 """ return self._context.blocking @blocking.setter def blocking(self, value: bool) -> None: """ Change the default blocking value. :param value: the new value as bool :raises TypeError: if *value* is not a bool """ _check_blocking(value) self._context.blocking = value @property def poll_interval(self) -> float: """ The default polling interval, in seconds. .. versionadded:: 3.24.0 """ return self._context.poll_interval @poll_interval.setter def poll_interval(self, value: float) -> None: """ Change the default polling interval. :param value: the new value, in seconds :raises ValueError: if *value* is negative, not finite, or above :data:`threading.TIMEOUT_MAX` :raises TypeError: if *value* is a :class:`bool` or not a real number """ self._context.poll_interval = _resolve_poll_interval(value) @property def lifetime(self) -> float | None: """ The soft marker age in seconds that permits expiry, or ``None`` to disable age-based expiry. A non-``None`` value permits a waiter to enter while the previous holder remains active, so it does not provide strict mutual exclusion. Native locks ignore the value with a warning. .. versionadded:: 3.24.0 """ return self._context.lifetime @lifetime.setter def lifetime(self, value: float | None) -> None: """ Change the legacy age-based expiry threshold. :param value: the new value in seconds, or ``None`` to disable expiration :raises ValueError: if *value* is negative or not finite :raises TypeError: if *value* is not ``None`` and not a real number """ self._context.lifetime = _resolve_lifetime(value, type(self), stacklevel=3) @property def mode(self) -> int: """ The file permissions requested for the lock file. Without an explicit ``mode`` this reports ``0o644`` for backwards compatibility, but the file is created with ``0o666`` filtered through the umask and default ACLs, so with a ``002`` umask it ends up ``0o664``. Check :attr:`has_explicit_mode` before relying on the value, or ``os.stat`` the file for its actual permissions. """ return 0o644 if self._context.mode == _UNSET_FILE_MODE else self._context.mode @property def has_explicit_mode(self) -> bool: """Whether the file permissions were explicitly set.""" return self._context.mode != _UNSET_FILE_MODE def _open_mode(self) -> int: """Mode for ``os.open``: 0o666 when unset so umask and ACLs decide, otherwise the explicit mode.""" return 0o666 if self._context.mode == _UNSET_FILE_MODE else self._context.mode @property def is_locked(self) -> bool: """ A boolean indicating if the lock file is holding the lock currently. .. versionchanged:: 2.0.0 This was previously a method and is now a property. """ _ensure_current_process() return self._context.lock_file_fd is not None @property def lock_counter(self) -> int: """The number of times this lock has been acquired (but not yet released).""" _ensure_current_process() return self._context.lock_counter def __enter__(self) -> Self: """ Acquire the lock. :returns: the lock object """ self.acquire() return self def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: """Release the lock, reconciling a release failure with any body failure per :attr:`context_error_policy`.""" self._release_in_context(exc_value) def _release_in_context(self, body_error: BaseException | None) -> None: # Release from a context-manager exit. "chain" lets a release failure propagate with the body error already in # its __context__ (Python's default); "group" raises both as sibling leaves so neither one hides the other. try: self.release() except BaseException as release_error: if body_error is None or self._context_error_policy == "chain": raise _raise_body_and_release(body_error, release_error) def __del__(self) -> None: """Force-release so a dropped reference never leaks a held lock, including every thread's hold.""" if vars(self).get("_creator_pid") != os.getpid(): return # pragma: forked child # CPython runs __del__ before it clears weak references. Until then the singleton cache still hands this lock # out, and a thread that takes a hold through it would lose that hold to a release here. The cycle collector # clears the references first, and at exit no other thread runs. if (key := self._singleton_key) is not None and not sys.is_finalizing() and self._instances.get(key) is self: self._release_once_unreachable() else: self._release_abandoned_holds() def _release_abandoned_holds(self) -> None: # A finalizer must not raise. A release error during garbage collection would otherwise surface as an # unraisable-exception warning, attributed to whichever code triggered collection. The dropped lock still gets # best-effort cleanup; an explicit release() reports the same error to a caller who can act on it. with contextlib.suppress(Exception): self.release(force=True) # Nothing references the lock any more, so no thread can still be inside it through this instance. with contextlib.suppress(Exception): self._release_thread_holds(exited_only=False) def _release_once_unreachable(self) -> None: # The callback runs once the lock is gone, so it reads the state through a holder. The state alone keeps the # holder alive: a reference from the callback would keep the state, and through it the lock, alive. self._finalizer_state = _FinalizerState(vars(self)) _UNREACHABLE_LOCKS.add(ref(self, partial(_finalize_unreachable, ref(self._finalizer_state), type(self)))) def acquire( self, timeout: float | None = None, poll_interval: float | None = None, *, poll_intervall: float | None = None, blocking: bool | None = None, cancel_check: Callable[[], bool] | None = None, ) -> AcquireReturnProxy[Self]: """ Try to acquire the file lock. :param timeout: maximum wait time for acquiring the lock, ``None`` means use the default :attr:`~timeout` is and if ``timeout < 0``, there is no timeout and this method will block until the lock could be acquired :param poll_interval: interval of trying to acquire the lock file, ``None`` means use the default :attr:`~poll_interval` :param poll_intervall: deprecated, kept for backwards compatibility, use ``poll_interval`` instead :param blocking: whether to retry until this call holds the lock or the timeout expires, ``None`` means use the default :attr:`~blocking`. ``False`` makes one attempt and raises :class:`~Timeout` on failure. :param cancel_check: a callable returning ``True`` when the acquisition should be canceled. Checked on each poll iteration. When triggered, raises :class:`~Timeout` just like an expired timeout. :returns: a context object that will unlock the file when the context is exited :raises Timeout: if fails to acquire lock within the timeout period :raises TypeError: if *blocking* is not a :class:`bool` :raises ValueError: if a blocking call gets a ``nan`` timeout, or *poll_interval* exceeds :data:`threading.TIMEOUT_MAX` .. code-block:: python # You can use this method in the context manager (recommended) with lock.acquire(): pass # Or use an equivalent try-finally construct: lock.acquire() try: pass finally: lock.release() .. versionchanged:: 2.0.0 This method returns now a *proxy* object instead of *self*, so that it can be used in a with statement without side effects. """ self._raise_if_inherited() if timeout is None: timeout = self._context.timeout if blocking is None: blocking = self._context.blocking # A truthy non-bool such as the string "false" would otherwise turn a single attempt into a blocking wait. _check_blocking(blocking) if poll_intervall is not None: msg = "use poll_interval instead of poll_intervall" warnings.warn(msg, DeprecationWarning, stacklevel=2) poll_interval = poll_intervall poll_interval = poll_interval if poll_interval is not None else self._context.poll_interval poll_interval = _resolve_poll_interval(poll_interval) timeout = _resolve_timeout(timeout, blocking=blocking) start_time = time.perf_counter() # Wait for admission before touching any state: a caller refused entry must leave the counter, the registry and # the descriptor exactly as it found them. with self._transition_admission( blocking=blocking, cancel_check=cancel_check, timeout=timeout, poll_interval=poll_interval, start_time=start_time, ): # The interrupted acquire cannot run until the handler returns, so a second one here would wait on it # forever or take the lock out from under it. Once the lock is held, as in an on_acquired hook, it nests. if not self.is_locked and _on_this_stack(self._context.acquiring): msg = f"Deadlock: lock '{self.lock_file}' is acquired again while this thread's acquire of it waits" raise RuntimeError(msg) # An interrupted release commits only the hold it started with and ignores a release above it on the # stack. A hold taken here would nest into one the OS dropped, or stay held for good. if _on_this_stack(self._context.releasing): msg = f"lock '{self.lock_file}' is acquired again while this thread's release of it runs" raise RuntimeError(msg) canonical = _canonical(self.lock_file) self._context.lock_counter += 1 self._raise_if_would_deadlock(canonical, timeout=timeout, blocking=blocking) if not self.is_locked: self._pin_lock_path(canonical) try: self._poll_until_acquired( blocking=blocking, cancel_check=cancel_check, timeout=timeout, poll_interval=poll_interval, start_time=start_time, ) except BaseException: self._reconcile_failed_acquire(canonical) raise self._commit_acquire(canonical) return AcquireReturnProxy(lock=self) @contextlib.contextmanager def _transition_admission( self, *, blocking: bool, cancel_check: Callable[[], bool] | None, timeout: float, poll_interval: float, start_time: float, ) -> Generator[None]: # One thread at a time drives the physical transition of a shared instance: its counter, descriptor and claim # state are plain attributes every thread reads and writes, so an unserialized loser clears the winner's # descriptor or double-closes it. Waiting in slices keeps cancel_check and the timeout live. A thread-local # context is never shared, so it skips the gate. if self._is_thread_local: yield return deadline: Final = None if timeout < 0 else start_time + timeout wait: Final = poll_interval if blocking else 0.0 # wait leads the min() so a non-blocking call, whose nan timeout goes unchecked, waits 0 rather than nan. while not self._transition_lock.acquire( timeout=wait if deadline is None else min(wait, max(deadline - time.perf_counter(), 0.0)) ): if not blocking or (cancel_check is not None and cancel_check()): raise Timeout(self.lock_file) if deadline is not None and time.perf_counter() >= deadline: raise Timeout(self.lock_file) try: yield finally: self._transition_lock.release() def release(self, force: bool = False) -> None: # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument] # public API: positional bool kept for backwards compatibility """ Release the file lock. The lock is only completely released when the lock counter reaches 0. The lock file itself may be deleted automatically, the behavior is platform-specific. :param force: If true, the lock counter is ignored and the lock is released in every case. On a thread-local lock it also releases the holds of threads that exited without releasing; a hold of a thread still running stays with that thread. Dropping the last reference to the lock releases every thread's hold. """ counter_before: int | None = None try: counter_before = self._context.lock_counter self._release_current(force=force) except BaseException as release_error: # A handler that raised after release() began and before the backend changed anything leaves the counter as # it was; only then does a second attempt release the hold the caller gave up. A failed system call keeps it # for an explicit retry, and a changed counter means a nested level or the backend's own retry already ran. if ( counter_before is not None and counter_before == self._context.lock_counter and self._context.lock_file_fd is not None and not _is_system_call_failure(release_error) ): with contextlib.suppress(Exception): self._release_current(force=force) raise if force: self._release_thread_holds(exited_only=True) def _release_current(self, *, force: bool) -> None: # A shared instance releases under the same gate its acquisition ran through, so a thread entering the lock # never observes a partially torn-down owner. Only an acquirer that found the lock free holds the gate for # long, so an unheld lock returns without waiting behind it. if self._context.lock_file_fd is None: # Only a committed hold registers, so an entry without a descriptor is a release a signal handler stopped # after the unlock; left alone it refuses every other instance on this path in this thread. if self._context.lock_file_registry is not None: self._finish_interrupted_release() return with contextlib.nullcontext() if self._is_thread_local else self._transition_lock: state: Final = self._hold_state() # A signal handler can land between the backend's unlock and its clearing of the descriptor, where a second # release would close the number twice, maybe hitting a file that reused it. The flag holds the releasing # frame, so only a release still on this stack stops it, and a flag a cut-short frame left set does not. if self._creator_pid != os.getpid() or state.lock_file_fd is None or _on_this_stack(state.releasing): return if not force and state.lock_counter > 1: state.lock_counter -= 1 return state.releasing = inspect.currentframe() try: _LOGGER.debug("Attempting to release lock %s on %s", id(self), self.lock_file) self._release_with_fork_tracking() finally: # A failure after the OS unlock (during close or unlink) still released the lock: the backend cleared # its descriptor, so commit the counter and registry to released even as the cleanup error propagates. # A failure that left the lock held keeps the counter so a later release can retry the OS unlock. if state.lock_file_fd is None: # pragma: no branch # a release that returns cleared the descriptor self._commit_release() state.releasing = None _LOGGER.debug("Lock %s released on %s", id(self), self.lock_file) def _finish_interrupted_release(self) -> None: # An unheld lock never waits behind an acquirer, whose own commit takes the entry over anyway. if not self._transition_lock.acquire(blocking=False): return # pragma: no cover # only a concurrent acquire of a shared instance holds the gate try: if self._context.lock_file_fd is None: # pragma: no branch # only a racing acquire fills it self._commit_release() finally: self._transition_lock.release() def _hold_state(self) -> FileLockContext: return self._context.current if isinstance(self._context, ThreadLocalFileContext) else self._context def _release_thread_holds(self, *, exited_only: bool) -> None: if not isinstance(context := self._context, ThreadLocalFileContext): return # Serialized, or two threads would both close an exited thread's descriptor; copy() is atomic free-threaded. with self._transition_lock: own: Final = context.current for held, thread in self._thread_holds.copy().values(): if exited_only and thread.is_alive(): continue context.current = held try: self._release_current(force=True) finally: context.current = own def _raise_if_inherited(self) -> None: if self._creator_pid != os.getpid(): # pragma: forked child msg = f"{type(self).__name__} on {self.lock_file} was inherited across fork; construct a new instance" raise RuntimeError(msg) def _mark_descriptor_owned(self, fd: int, identity: tuple[int, int] | None = None) -> None: state: Final = self._hold_state() # Held first, pending cleared second: an interrupt between the two then leaves the descriptor recorded. state.lock_file_fd = fd state.lock_file_fd_identity = identity state.pending_lock_file_fd = None state.pending_lock_file_fd_identity = None if isinstance(self._context, ThreadLocalFileContext): self._thread_pending.pop(id(state), None) self._thread_holds[id(state)] = state, current_thread() def _mark_descriptor_pending(self, fd: int, identity: tuple[int, int] | None = None) -> None: state: Final = self._hold_state() state.pending_lock_file_fd = fd state.pending_lock_file_fd_identity = identity if isinstance(self._context, ThreadLocalFileContext): self._thread_pending[id(state)] = state, current_thread() def _mark_descriptor_released(self) -> None: state: Final = self._hold_state() state.pending_lock_file_fd = None state.pending_lock_file_fd_identity = None state.lock_file_fd = None state.lock_file_fd_identity = None if isinstance(self._context, ThreadLocalFileContext): self._thread_pending.pop(id(state), None) self._thread_holds.pop(id(state), None) def _reset_after_fork_in_child(self) -> None: # pragma: forked child # fork copies the lock in whatever state the parent's threads left it, so give the child an unheld one. self._transition_lock = RLock() if isinstance(self._context, ThreadLocalFileContext): self._thread_holds.clear() self._thread_pending.clear() self._context.owner_claim_paths = () self._context.claim_root = None self._context.lock_file_fd = None self._context.lock_file_fd_token = None self._context.lock_file_fd_identity = None self._context.pending_lock_file_fd = None self._context.pending_lock_file_fd_identity = None self._context.lock_counter = 0 self._context.releasing = None self._context.acquiring = None self._context.lock_file_key = None self._context.lock_file_registry = None self._context.lease_claim = None def _descriptors_for_fork(self) -> tuple[tuple[int, tuple[int, int] | None], ...]: # pragma: needs fork descriptors: list[tuple[int, tuple[int, int] | None]] = [] if self._context.lock_file_fd is not None and self._context.lock_file_fd_token is None: descriptors.append((self._context.lock_file_fd, self._context.lock_file_fd_identity)) if self._context.pending_lock_file_fd is not None: descriptors.append((self._context.pending_lock_file_fd, self._context.pending_lock_file_fd_identity)) return tuple(descriptors) def _raise_if_would_deadlock(self, canonical: str, *, timeout: float, blocking: bool) -> None: """ Fail fast when a *different* live instance already holds this path in the current deadlock scope. Only the first, indefinitely-blocking acquire can self-deadlock this way: waiting in the OS primitive would block on a lock this flow already owns. A finite timeout or ``blocking=False`` keeps the normal Timeout path. """ would_block = self._context.lock_counter == 1 and not self.is_locked and timeout < 0 and blocking if would_block and _registry.held.get(self._registry_key(canonical)) not in {None, self._holder_id}: self._context.lock_counter -= 1 msg = ( f"Deadlock: lock '{self.lock_file}' is already held by a different {self._deadlock_holder_desc}. " f"Use is_singleton=True to enable reentrant locking across instances." ) raise RuntimeError(msg) def _registry_key(self, canonical: str) -> Hashable: return canonical if (scope := self._deadlock_scope()) is None else (scope, canonical) @staticmethod def _deadlock_scope() -> Hashable | None: """ Execution unit whose own hold would deadlock a new acquire. ``None`` scopes holders to the thread, which the thread-local registry already separates. Async locks override it with the running task, since one event loop thread runs many tasks and only a reacquire from the *same* task can self-deadlock. """ return None def _poll_until_acquired( self, *, blocking: bool, cancel_check: Callable[[], bool] | None, timeout: float, poll_interval: float, start_time: float, ) -> None: lock_id = id(self) lock_filename = self.lock_file attempt = 0 self._context.acquiring = inspect.currentframe() try: # Created once, so a directory that disappears while the acquisition waits fails it rather than building a # second lock file beside the holder's, and not on re-entry, which joins a hold whose parent may be renamed. if not self.is_locked: ensure_directory_exists(self._lock_path) while True: self._raise_if_inherited() if not self.is_locked: self._try_break_expired_lock() _LOGGER.debug("Attempting to acquire lock %s on %s", lock_id, lock_filename) self._acquire_with_fork_tracking() self._raise_if_inherited() if self.is_locked: _LOGGER.debug("Lock %s acquired on %s", lock_id, lock_filename) return if self._check_give_up( blocking=blocking, cancel_check=cancel_check, timeout=timeout, start_time=start_time, ): raise Timeout(lock_filename, self._unprobeable_holder()) attempt += 1 delay = self._poll_delay(poll_interval, attempt) if timeout >= 0: delay = min(delay, max(start_time + timeout - time.perf_counter(), 0.0)) msg = "Lock %s not acquired on %s, waiting %s seconds ..." _LOGGER.debug(msg, lock_id, lock_filename, delay) time.sleep(delay) finally: self._context.acquiring = None def _poll_delay(self, poll_interval: float, attempt: int) -> float: # A single-file lock retries on a fixed cadence. A backend that publishes several files per acquisition sets a # cap, and then contending processes back off across a jittered, exponentially widening window instead of # colliding on every poll; poll_interval stays the floor so a lone waiter is still responsive. if not self._poll_backoff_cap: return poll_interval # Cap the exponent before doubling: under heavy contention attempt reaches the thousands, and 2**attempt would # overflow the float multiply long before the window itself stops growing past the cap. window = min( self._poll_backoff_cap, poll_interval * 2 ** min(attempt, _MAX_BACKOFF_EXPONENT) ) # pragma: needs hard-link return max(poll_interval, secrets.randbelow(int(window * 1_000_000) + 1) / 1_000_000) # pragma: needs hard-link def _reconcile_failed_acquire(self, canonical: str) -> None: # An acquire that raised while still holding the native lock (a hook that failed and whose rollback could not # release) must keep the registry entry so a later release can retry the OS unlock; otherwise roll the counter # back. is_locked was already reconciled by whichever release ran. if self.is_locked: self._commit_acquire(canonical) else: self._undo_acquire() def _invoke_on_acquired(self) -> None: # The wrapper runs in the backend executor for async locks, preserving the callback's documented thread. if self._on_acquired is None or self._context.lock_counter != 1: return try: self._on_acquired(cast("int", self._context.lock_file_fd)) except BaseException as callback_error: # arbitrary caller code; roll back on any failure callback_context = callback_error.__context__ try: self._release_with_fork_tracking() except BaseException as release_error: # ruff:ignore[blind-except] # both errors surface via the group below _raise_body_and_release(callback_error, release_error) callback_error.__context__ = callback_context raise def _acquire_with_fork_tracking(self) -> None: with _fork_transition(self): try: self._acquire() except BaseException as acquisition_error: self._rollback_failed_acquire(acquisition_error) raise try: self._register_context_descriptor() except BaseException as registration_error: # pragma: needs fork self._rollback_failed_registration(registration_error) raise if self.is_locked: self._invoke_on_acquired() def _rollback_failed_acquire(self, acquisition_error: BaseException) -> None: if not self.is_locked: return registration_error: BaseException | None = None tracking_error: BaseException | None = None try: self._register_context_descriptor() except BaseException as error: # ruff:ignore[blind-except] # preserve registration and acquisition failures registration_error = error try: # Rollback may fail too; retain the fd so a child can close it without another identity probe. self._register_unverified_context_descriptor() except BaseException as error: # ruff:ignore[blind-except] # pragma: no cover - allocation/control-flow during fallback tracking_error = error try: self._release_with_fork_tracking() except BaseException as rollback_error: # ruff:ignore[blind-except] # preserve rollback and acquisition failures _raise_cleanup_errors( "lock acquisition cleanup failed", acquisition_error, registration_error, tracking_error, rollback_error, ) if registration_error is not None: # pragma: needs fork _raise_cleanup_errors( "lock acquisition cleanup failed", acquisition_error, registration_error, tracking_error ) def _rollback_failed_registration(self, registration_error: BaseException) -> None: # pragma: needs fork tracking_error: BaseException | None = None try: # Rollback may fail too; retain the fd so a child can close it without another identity probe. self._register_unverified_context_descriptor() except BaseException as error: # ruff:ignore[blind-except] # pragma: no cover - allocation/control-flow during fallback tracking_error = error try: self._release_with_fork_tracking() except BaseException as rollback_error: # ruff:ignore[blind-except] # preserve rollback and registration failures _raise_cleanup_errors( "descriptor registration cleanup failed", registration_error, tracking_error, rollback_error ) if tracking_error is not None: # pragma: no cover - requires failed in-memory fallback _raise_cleanup_errors("descriptor registration cleanup failed", registration_error, tracking_error) def _release_with_fork_tracking(self) -> None: with _fork_transition(self): try: self._release() except BaseException as release_error: # A backend unlocks first and clears the descriptor second. A signal handler that raises in between # leaves a hold the OS dropped. A failed system call alone shows the lock is still held, so it keeps it. if self._context.lock_file_fd is not None and not _is_system_call_failure(release_error): with contextlib.suppress(Exception): self._release() raise finally: self._unregister_released_descriptor() def _register_context_descriptor(self) -> None: if self._context.lock_file_fd is not None and self._context.lock_file_fd_token is None: self._context.lock_file_fd_token = _register_owned_descriptor( self._context.lock_file_fd, self._context.lock_file_fd_identity, ) def _register_unverified_context_descriptor(self) -> None: # The rollback only reaches here still holding a descriptor it never managed to register. if self._context.lock_file_fd is not None and self._context.lock_file_fd_token is None: # pragma: no branch self._context.lock_file_fd_token = _register_unverified_owned_descriptor(self._context.lock_file_fd) def _unregister_released_descriptor(self) -> None: if self._context.lock_file_fd is None: if (token := self._context.lock_file_fd_token) is not None: # pragma: needs fork _unregister_owned_descriptor(token) self._context.lock_file_fd_token = None self._context.lock_file_fd_identity = None def _undo_acquire(self) -> None: """Roll back the counter after a failed acquire, dropping the registry entry once nothing holds the path.""" self._context.lock_counter = max(0, self._context.lock_counter - 1) if self._context.lock_counter == 0: self._drop_registry_entry() self._context.claim_root = None def _pin_lock_path(self, canonical: str) -> None: # A path without a final component names no file. Its canonical form is a directory, and pinning that would # change the error the backend raises. self._context.claim_root = None if canonical.endswith(os.sep) else canonical def _commit_acquire(self, canonical: str) -> None: """Record this instance as the holder once the first acquire succeeds, so peers can detect the deadlock.""" # Keyed on the registry rather than the counter: an on_acquired hook that acquires again bumps the counter # past 1 before this first commit runs, and must not leave the hold unregistered. if self._context.lock_file_registry is None: key = self._registry_key(canonical) # The holder scope is resolved once at commit so a later release from another flow drops the right entry. self._context.lock_file_key = key # A shared instance may be released from another thread, which must clear the entry in the acquirer's # registry: the releaser's own thread-local registry never held it. self._context.lock_file_registry = _registry.held _registry.held[key] = self._holder_id def _drop_registry_entry(self) -> None: """Forget the key owned by this hold without resolving a mutable path again.""" # Ordered so a run an interrupt cuts short repeats safely: the entry goes only while it is still ours, and the # key outlives the registry it indexes, so a later commit sees no registry and records the hold afresh. key, registry = self._context.lock_file_key, self._context.lock_file_registry if registry is not None and registry.get(key) == self._holder_id: del registry[key] self._context.lock_file_registry = None self._context.lock_file_key = None def _commit_release(self) -> None: """Record the lock as fully released: reset the recursion counter and drop the deadlock-registry entry.""" self._context.lock_counter = 0 self._drop_registry_entry() self._context.claim_root = None def _try_break_expired_lock(self) -> None: """Remove the lock file once it is older than :attr:`lifetime`; only an existence lock accepts one.""" def _check_give_up( self, *, blocking: bool, cancel_check: Callable[[], bool] | None, timeout: float, start_time: float, ) -> bool: lock_id, lock_filename = id(self), self.lock_file if blocking is False: _LOGGER.debug("Failed to immediately acquire lock %s on %s", lock_id, lock_filename) return True if cancel_check is not None and cancel_check(): _LOGGER.debug("Cancellation requested for lock %s on %s", lock_id, lock_filename) return True if 0 <= timeout < time.perf_counter() - start_time: _LOGGER.debug("Timeout on acquiring lock %s on %s", lock_id, lock_filename) return True return False def _unprobeable_holder(self) -> str | None: # ruff:ignore[no-self-use] # existence locks override it return None @abstractmethod def _acquire(self) -> None: """If the file lock could be acquired, self._context.lock_file_fd holds the file descriptor of the lock file.""" raise NotImplementedError @abstractmethod def _release(self) -> None: """Releases the lock and sets self._context.lock_file_fd to None.""" raise NotImplementedError def _resolve_read_write_timeout(timeout: float, *, blocking: bool) -> float: """Validate a read-write lock's acquire ``timeout``: ``-1`` or a non-negative number, ``inf`` meaning ``-1``.""" if (timeout := _resolve_timeout(timeout, blocking=blocking)) < 0 and timeout != -1 and blocking: msg = f"timeout must be a non-negative number or -1, not {timeout!r}" raise ValueError(msg) return timeout def _acquire_thread_lock( lock: Lock, *, blocking: bool, timeout: float, poll_interval: float, cancel_check: Callable[[], bool] | None ) -> bool: """Take a read-write lock's in-process lock; with *cancel_check*, ask it every *poll_interval* while waiting.""" if not blocking: return lock.acquire(blocking=False) if cancel_check is None: # Lock.acquire raises OverflowError past TIMEOUT_MAX, which is 49.7 days on Windows. return lock.acquire() if timeout == -1 else lock.acquire(timeout=min(timeout, TIMEOUT_MAX)) deadline = None if timeout == -1 else time.perf_counter() + timeout while not lock.acquire( timeout=poll_interval if deadline is None else min(poll_interval, max(deadline - time.perf_counter(), 0)) ): if cancel_check() or (deadline is not None and time.perf_counter() >= deadline): return False return True def _resolve_timeout(timeout: float, *, blocking: bool) -> float: """Validate an acquire ``timeout``; every negative value waits without a limit.""" if math.isnan(timeout := _seconds("timeout", timeout)) and blocking: msg = "timeout must be a number of seconds, not nan" raise ValueError(msg) # The fail-fast deadlock check only sees a negative timeout as unlimited, so spell inf that way. return -1 if timeout == math.inf else timeout @dataclass(slots=True, weakref_slot=True) class _FinalizerState: """A finalized lock's state, held for the release that runs once the lock is unreachable.""" state: dict[str, object] | None #: Weak references whose callback finishes a singleton's finalizer once the cache no longer finds it. _UNREACHABLE_LOCKS: Final[set[ref[BaseFileLock]]] = set() def _finalize_unreachable(holder: ref[_FinalizerState], cls: type[BaseFileLock], reference: ref[BaseFileLock]) -> None: _UNREACHABLE_LOCKS.discard(reference) if (finalizer_state := holder()) is None or (state := finalizer_state.state) is None: return # pragma: no cover # only a collector freeing a resurrected lock as part of a cycle drops the holder # The lock's dictionary points back at the holder; that cycle ends here. finalizer_state.state = None # The release methods only read and write this state, so a fresh instance takes it over. Dropping that instance # runs __del__ again, and the cache no longer finds the lock. stand_in = object.__new__(cls) stand_in.__dict__ = state del stand_in def _is_system_call_failure(error: BaseException) -> bool: # A failed system call sets errno. A bare TimeoutError raised by a signal handler is an OSError without one. if _is_exception_group(error): return all(map(_is_system_call_failure, cast("_ExceptionGroupProtocol", error).exceptions)) return isinstance(error, OSError) and error.errno is not None def _on_this_stack(frame: FrameType | None) -> bool: if frame is None: return False caller = inspect.currentframe() while caller is not None and caller is not frame: caller = caller.f_back return caller is not None # acquire() returns this wrapper instead of self so entering the with-statement does not call __enter__ a second # time; returning self would re-acquire the lock in BaseFileLock.__enter__ without a matching release (issue #37). class AcquireReturnProxy(Generic[_LockT]): """A context-aware object that will release the lock file when exiting.""" def __init__(self, lock: _LockT) -> None: self.lock: _LockT = lock def __enter__(self) -> _LockT: return self.lock def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: # Widen to the bound: ty narrows the else branch to the type variable minus BaseFileLock, not to the union rest. lock: Final = cast("BaseFileLock | ReadWriteLock | SoftReadWriteLock", self.lock) if isinstance(lock, BaseFileLock): lock._release_in_context(exc_value) # ruff:ignore[private-member-access] # forwards __exit__ to the owned lock's context release else: # a reader/writer lock does not carry a context_error_policy lock.release() @dataclass class FileLockContext: """Holds the context for a ``BaseFileLock`` object.""" # A separate class so ThreadLocalFileContext can make the whole context thread-local. lock_file: str timeout: float mode: int blocking: bool poll_interval: float #: The lock lifetime in seconds; ``None`` means the lock never expires. lifetime: float | None = None #: File descriptor from os.open for the lock file; not None while the lock is held. lock_file_fd: int | None = None #: Registry token for the descriptor owned by this thread's context. lock_file_fd_token: int | None = None #: Identity captured by a backend that already inspected the descriptor. lock_file_fd_identity: tuple[int, int] | None = None #: Descriptor opened by a backend but not yet committed as the held lock. pending_lock_file_fd: int | None = None #: Identity captured for a descriptor whose acquisition has not committed. pending_lock_file_fd_identity: tuple[int, int] | None = None #: Depth of nested acquisitions; the lock is released only when it returns to 0. lock_counter: int = 0 #: Canonical registry key captured when the first physical acquisition commits. lock_file_key: Hashable | None = None #: The acquiring thread's deadlock registry holding :attr:`lock_file_key`. lock_file_registry: dict[Hashable, int] | None = None #: Claim pathnames this owner published, removed by name on release so no holder ever unlinks a peer's claim. owner_claim_paths: tuple[str, ...] = () #: The frame of the :meth:`BaseFileLock.release` tearing this hold down, so a re-entrant call from a signal handler #: on the same thread returns instead of closing the descriptor a second time. releasing: FrameType | None = None #: The frame of an acquire still polling for this hold, so one a signal handler starts on this thread fails fast. acquiring: FrameType | None = None #: Canonical lock path, resolved when an acquisition starts and kept until its hold ends. A waiter on a relative #: path stays in the directory it started in, and the release finds the file there, after a chdir in between. claim_root: str | None = None #: The lease claim of this hold, kept with it so a cross-thread release stops the right heartbeat. lease_claim: _LeaseClaim | None = None class ThreadLocalFileContext(FileLockContext, local): """ A thread local version of the ``FileLockContext`` class. Each thread's state lives in an ordinary :class:`FileLockContext` at :attr:`current`, which the lock also keeps, so ``release(force=True)`` and the finalizer can unlock a hold whose thread exited. """ current: FileLockContext # threading.local runs __init__ again with these arguments the first time each thread touches the context. def __init__( # ruff:ignore[too-many-arguments] # mirrors the FileLockContext fields set at construction self, *, lock_file: str, timeout: float, mode: int, blocking: bool, poll_interval: float, lifetime: float | None = None, ) -> None: self.current = FileLockContext( lock_file=lock_file, timeout=timeout, mode=mode, blocking=blocking, poll_interval=poll_interval, lifetime=lifetime, ) def _delegate_to_current(name: str) -> property: return property( lambda context: getattr(context.current, name), lambda context, value: setattr(context.current, name, value), ) for _field in fields(FileLockContext): setattr(ThreadLocalFileContext, _field.name, _delegate_to_current(_field.name)) @dataclass(frozen=True) class _OwnedDescriptor: fd: int creator_pid: int device: int | None inode: int | None class _ForkState: def __init__(self) -> None: self.gate = Condition(RLock()) self.registry_lock = RLock() self.parameter_models_lock = RLock() self.transitions: dict[int, dict[int, _ForkDescriptorOwner | None]] = {} self.active_transitions = 0 self.admission_closed = False self.fork_owner_depths: dict[int, int] = {} self.pinned_objects: dict[int, list[tuple[_ForkResettable, ...]]] = {} self.pinned_classes: dict[int, list[tuple[_ForkResettableClass, ...]]] = {} self.provisional_descriptor_tokens: dict[int, list[tuple[int, ...]]] = {} self.pid = os.getpid() def reset_synchronization(self) -> None: # pragma: forked child self.gate = Condition(RLock()) self.registry_lock = RLock() self.parameter_models_lock = RLock() self.transitions = {} self.active_transitions = 0 self.admission_closed = False self.fork_owner_depths = {} self.pinned_objects = {} self.pinned_classes = {} self.provisional_descriptor_tokens = {} _FORK_OBJECTS: Final[WeakValueDictionary[int, _ForkResettable]] = WeakValueDictionary() _FORK_CLASSES: Final[WeakValueDictionary[int, _ForkResettableClass]] = WeakValueDictionary() _OWNED_DESCRIPTORS: Final[dict[int, _OwnedDescriptor]] = {} _DESCRIPTOR_TOKENS: Final[count[int]] = count() _TRANSITION_TOKENS: Final[count[int]] = count() _FORK_STATE: Final = _ForkState() _FORK_AUDIT_EVENTS: Final[frozenset[str]] = frozenset({"os.fork", "os.forkpty"}) # CPython before 3.12 guards sys.settrace with one process-wide flag that stays set while audit hooks run, so a thread # switch inside a Python hook makes a concurrent settrace raise. Coverage and debuggers call it on every thread start. _SETTRACE_SURVIVES_AUDIT_HOOKS: Final[bool] = sys.implementation.name != "cpython" or sys.version_info >= (3, 12) @cache def _resolve_lazy_imports_before_shutdown() -> None: if hasattr(types, "LazyImportType"): # pragma: >=3.15 cover atexit.register(_resolve_lazy_imports) def _resolve_lazy_imports() -> None: # pragma: >=3.15 cover # Interpreter shutdown clears sys.modules and sys.meta_path before it finalizes a lock still alive at exit, so a # lazy import (PEP 810) that the release path in __del__ reaches first can no longer load. Load them while it can. for name, module in list(sys.modules.items()): if name == "filelock" or name.startswith("filelock."): for attribute, value in list(vars(module).items()): if type(value) is types.LazyImportType: getattr(module, attribute) def _register_fork_hooks() -> None: if _REGISTER_AT_FORK is None: return # pragma: lacks fork if _SETTRACE_SURVIVES_AUDIT_HOOKS: # pragma: needs fork # pragma: needs settrace-safe-audit-hooks sys.addaudithook(_audit_fork_safety) _REGISTER_AT_FORK( # pragma: needs fork before=_pin_fork_objects, after_in_parent=_resume_parent_after_fork, after_in_child=_reset_child_after_fork, ) @contextmanager def _fork_transition(descriptor_owner: _ForkDescriptorOwner | None = None) -> Generator[None]: # No fork follows exit, and a daemon thread stopped inside this bookkeeping holds its lock forever. if not _HAS_REGISTER_AT_FORK or sys.is_finalizing(): yield # pragma: lacks fork return # pragma: lacks fork _ensure_current_process() # pragma: needs fork creator_pid = os.getpid() # pragma: needs fork # A generator holding this transition can be finalized on another thread, which must close the entering thread's. thread_id: Final = get_ident() # pragma: needs fork token: Final = _enter_fork_transition(descriptor_owner, thread_id) # pragma: needs fork try: # pragma: needs fork yield finally: if os.getpid() == creator_pid: # pragma: needs fork _leave_fork_transition(token, thread_id) def _register_fork_object(instance: _ForkResettable) -> None: if not _HAS_REGISTER_AT_FORK: return # pragma: lacks fork with _fork_transition(), _FORK_STATE.registry_lock: # pragma: needs fork _FORK_OBJECTS[id(instance)] = instance _refresh_owner_pins() def _register_fork_class(cls: _ForkResettableClass) -> None: if not _HAS_REGISTER_AT_FORK: return # pragma: lacks fork with _fork_transition(), _FORK_STATE.registry_lock: # pragma: needs fork _FORK_CLASSES[id(cls)] = cls _refresh_owner_pins() def _register_owned_descriptor(fd: int, identity: tuple[int, int] | None = None) -> int | None: if not _HAS_REGISTER_AT_FORK: return None # pragma: lacks fork with _fork_transition(): # pragma: needs fork if identity is None: stat_result = os.fstat(fd) identity = stat_result.st_dev, stat_result.st_ino return _record_owned_descriptor(fd, identity) def _register_unverified_owned_descriptor(fd: int) -> int | None: if not _HAS_REGISTER_AT_FORK: return None # pragma: lacks fork with _fork_transition(): # pragma: needs fork return _record_owned_descriptor(fd, None) def _record_owned_descriptor(fd: int, identity: tuple[int, int] | None) -> int: # pragma: needs fork with _FORK_STATE.registry_lock: token = next(_DESCRIPTOR_TOKENS) _OWNED_DESCRIPTORS[token] = _OwnedDescriptor( fd=fd, creator_pid=os.getpid(), device=None if identity is None else identity[0], inode=None if identity is None else identity[1], ) return token def _unregister_owned_descriptor(token: int) -> None: # pragma: needs fork # No fork follows exit, and a daemon thread stopped inside this bookkeeping holds its lock forever. if sys.is_finalizing(): # pragma: no cover # only exit reaches it, after coverage stops measuring return with _fork_transition(), _FORK_STATE.registry_lock: _OWNED_DESCRIPTORS.pop(token, None) def _pin_fork_objects() -> None: # pragma: needs fork _ensure_current_process() thread_id = get_ident() provisional_descriptor_tokens: list[int] = [] # A signal handler that raises during the wait does not stop the fork. The after hooks pop one snapshot per # admission closed here, so the finally records the snapshot on failure too. try: with _FORK_STATE.gate: _FORK_STATE.fork_owner_depths[thread_id] = _FORK_STATE.fork_owner_depths.get(thread_id, 0) + 1 _FORK_STATE.admission_closed = True while _FORK_STATE.active_transitions > len(_FORK_STATE.transitions.get(thread_id, ())): _FORK_STATE.gate.wait() transition_owners = tuple(_FORK_STATE.transitions.get(thread_id, {}).values()) _verify_unverified_descriptors() owners = {id(owner): owner for owner in transition_owners if owner is not None} provisional_descriptor_tokens.extend( starmap( _snapshot_descriptor_for_fork, (descriptor for owner in owners.values() for descriptor in owner._descriptors_for_fork()), # ruff:ignore[private-member-access] # snapshots each owner's own fork descriptors ) ) finally: with _FORK_STATE.registry_lock: _FORK_STATE.provisional_descriptor_tokens.setdefault(thread_id, []).append( tuple(provisional_descriptor_tokens) ) _FORK_STATE.pinned_objects.setdefault(thread_id, []).append(tuple(_FORK_OBJECTS.values())) _FORK_STATE.pinned_classes.setdefault(thread_id, []).append(tuple(_FORK_CLASSES.values())) def _resume_parent_after_fork() -> None: # pragma: needs fork thread_id = get_ident() try: with _FORK_STATE.registry_lock: for token in _FORK_STATE.provisional_descriptor_tokens[thread_id].pop(): _OWNED_DESCRIPTORS.pop(token, None) _FORK_STATE.pinned_objects[thread_id].pop() _FORK_STATE.pinned_classes[thread_id].pop() if not _FORK_STATE.provisional_descriptor_tokens[thread_id]: # pragma: needs fork del _FORK_STATE.provisional_descriptor_tokens[thread_id] del _FORK_STATE.pinned_objects[thread_id] del _FORK_STATE.pinned_classes[thread_id] finally: # No other code reopens admission. A failure above would leave the other threads waiting for good. with _FORK_STATE.gate: # A handler that raised before the depth was stored left no entry to decrement. if (depth := _FORK_STATE.fork_owner_depths.get(thread_id, 1)) <= 1: _FORK_STATE.fork_owner_depths.pop(thread_id, None) else: # pragma: no cover - earlier at-fork callbacks may deadlock first _FORK_STATE.fork_owner_depths[thread_id] = depth - 1 _FORK_STATE.admission_closed = bool(_FORK_STATE.fork_owner_depths) _FORK_STATE.gate.notify_all() def _ensure_current_process() -> None: _reset_child_after_fork() def _reset_child_after_fork() -> None: # pragma: forked child if (pid := os.getpid()) == _FORK_STATE.pid: return thread_id = get_ident() pinned_objects = _FORK_STATE.pinned_objects.get(thread_id, [()])[-1] pinned_classes = _FORK_STATE.pinned_classes.get(thread_id, [()])[-1] descriptors: list[_OwnedDescriptor] = [] for token, descriptor in tuple(_OWNED_DESCRIPTORS.items()): if descriptor.creator_pid != pid: descriptors.append(_OWNED_DESCRIPTORS.pop(token)) _FORK_STATE.reset_synchronization() _FORK_STATE.pid = pid _INIT_PARAMETER_MODELS.clear() _detach_child_state(descriptors, pinned_objects, pinned_classes) def _enter_fork_transition(descriptor_owner: _ForkDescriptorOwner | None, thread_id: int) -> int: # pragma: needs fork with _FORK_STATE.gate: while ( _FORK_STATE.admission_closed and thread_id not in _FORK_STATE.fork_owner_depths and thread_id not in _FORK_STATE.transitions ): _FORK_STATE.gate.wait() # pragma: no cover - exercised in isolated interpreter token = next(_TRANSITION_TOKENS) _FORK_STATE.transitions.setdefault(thread_id, {})[token] = descriptor_owner _FORK_STATE.active_transitions += 1 return token def _leave_fork_transition(token: int, thread_id: int) -> None: # pragma: needs fork with _FORK_STATE.gate: owned: Final = _FORK_STATE.transitions[thread_id] del owned[token] if not owned: del _FORK_STATE.transitions[thread_id] _FORK_STATE.active_transitions -= 1 _FORK_STATE.gate.notify_all() def _verify_unverified_descriptors() -> None: # pragma: needs fork with _FORK_STATE.registry_lock: for token, descriptor in tuple(_OWNED_DESCRIPTORS.items()): if descriptor.creator_pid != os.getpid() or descriptor.device is not None: continue try: stat_result = os.fstat(descriptor.fd) except OSError: continue _OWNED_DESCRIPTORS[token] = _OwnedDescriptor( fd=descriptor.fd, creator_pid=descriptor.creator_pid, device=stat_result.st_dev, inode=stat_result.st_ino, ) def _snapshot_descriptor_for_fork(fd: int, identity: tuple[int, int] | None) -> int: # pragma: needs fork if identity is None: # pragma: needs fork try: stat_result = os.fstat(fd) except OSError: pass else: identity = stat_result.st_dev, stat_result.st_ino return _record_owned_descriptor(fd, identity) def _detach_child_state( # pragma: forked child descriptors: list[_OwnedDescriptor], pinned_objects: tuple[_ForkResettable, ...], pinned_classes: tuple[_ForkResettableClass, ...], ) -> None: for descriptor in descriptors: if descriptor.device is None: continue try: stat_result = os.fstat(descriptor.fd) except OSError: continue if (stat_result.st_dev, stat_result.st_ino) != (descriptor.device, descriptor.inode): continue with contextlib.suppress(OSError): os.close(descriptor.fd) for instance in pinned_objects: instance._reset_after_fork_in_child() # ruff:ignore[private-member-access] # resets each pinned instance in the fork child for cls in pinned_classes: cls._reset_class_after_fork() _registry.held.clear() def _refresh_owner_pins() -> None: # pragma: needs fork with _FORK_STATE.gate: fork_owner_thread_ids = tuple(_FORK_STATE.fork_owner_depths) if get_ident() not in fork_owner_thread_ids: # pragma: no cover - at-fork callbacks disable tracing return objects = tuple(_FORK_OBJECTS.values()) classes = tuple(_FORK_CLASSES.values()) for thread_id in fork_owner_thread_ids: if object_snapshots := _FORK_STATE.pinned_objects.get(thread_id): # pragma: needs fork object_snapshots[:] = [objects] * len(object_snapshots) _FORK_STATE.pinned_classes[thread_id][:] = [classes] * len(object_snapshots) # The defaults capture the module globals: the hook outlives them at interpreter shutdown, where CPython wipes the # module dict to None before the final audit events fire. def _audit_fork_safety( # pragma: no cover - CPython disables tracing while Python audit hooks run event: str, _args: Unused, *, _fork_events: frozenset[str] = _FORK_AUDIT_EVENTS, _state: _ForkState = _FORK_STATE, _get_ident: Callable[[], int] = get_ident, ) -> None: if event in _fork_events: if _get_ident() in _state.transitions or _state.fork_owner_depths: msg = f"{event} is unsafe while filelock is changing descriptor ownership" raise RuntimeError(msg) elif event == "_posixsubprocess.fork_exec" and _get_ident() in _state.transitions: msg = "fork_exec is unsafe while filelock is changing descriptor ownership" raise RuntimeError(msg) _register_fork_hooks() __all__ = [ "_UNSET_FILE_MODE", "AcquireReturnProxy", "BaseFileLock", "CloseErrorPolicy", "ContextErrorPolicy", "FileLockContext", "FileLockMeta", "LockOptions", "ThreadLocalFileContext", "_acquire_thread_lock", "_append_exception_context", "_canonical", "_check_timeout_max", "_delegate_to_current", "_ensure_current_process", "_fork_transition", "_grouped_errors", "_raise_body_and_release", "_raise_chained_errors", "_raise_cleanup_errors", "_raise_grouped_errors", "_register_fork_class", "_register_fork_object", "_register_owned_descriptor", "_resolve_poll_interval", "_resolve_read_write_timeout", "_resolve_timeout", "_seconds", "_timeout_behavior", "_unregister_owned_descriptor", ] filelock-4.2.8/src/filelock/_async.py0000644000000000000000000005000715272561277014470 0ustar00"""Separate caller cancellation from backend task and executor-future results.""" from __future__ import annotations __lazy_modules__ = ["asyncio", "collections", "concurrent.futures", "filelock._error"] import asyncio import contextlib import time from collections import deque from concurrent.futures import Future as ConcurrentFuture from dataclasses import dataclass from threading import Lock from typing import TYPE_CHECKING, Final, Generic, Literal, NoReturn, TypeVar, cast from weakref import WeakKeyDictionary from filelock._api import ( _append_exception_context, _fork_transition, _raise_chained_errors, _resolve_read_write_timeout, ) from filelock._error import Timeout if TYPE_CHECKING: from collections.abc import AsyncGenerator, Awaitable, Callable from concurrent.futures import Executor from filelock._read_write import ReadWriteLock from filelock._soft_rw import SoftReadWriteLock _T = TypeVar("_T") #: The holder an async wrapper records its backend level under. No thread has this identifier, so sync code on the #: executor thread that took the level cannot re-enter or release it. _TASKS_THREAD_ID: Final[int] = 0 class _AsyncTransitionUnavailableError(Exception): pass @dataclass(frozen=True) class _BackendOutcome(Generic[_T]): value: _T | None = None error: BaseException | None = None class _AsyncTransitionGate: """ First-in, first-out admission for the calls that transition one lock context. Each call queues a ticket, and the head of the queue holds the gate. A call that gives up removes its own ticket, so abandoned calls do not pile up behind a long holder. """ def __init__(self) -> None: self._queue_lock: Final[Lock] = Lock() self._queue: Final[deque[ConcurrentFuture[None]]] = deque() @contextlib.asynccontextmanager async def hold(self) -> AsyncGenerator[None]: ticket: ConcurrentFuture[None] = ConcurrentFuture() try: self._join(ticket) if not ticket.done(): await self._wait_for_turn(ticket, blocking=True, cancel_check=None, deadline=None, poll_interval=0.05) yield finally: self._leave(ticket) @contextlib.asynccontextmanager async def hold_for_acquire( self, *, blocking: bool, cancel_check: Callable[[], bool] | None, deadline: float | None, poll_interval: float, ) -> AsyncGenerator[None]: ticket: ConcurrentFuture[None] = ConcurrentFuture() try: self._join(ticket) if not ticket.done(): await self._wait_for_turn( ticket, blocking=blocking, cancel_check=cancel_check, deadline=deadline, poll_interval=poll_interval, ) yield finally: self._leave(ticket) async def _wait_for_turn( self, ticket: ConcurrentFuture[None], *, blocking: bool, cancel_check: Callable[[], bool] | None, deadline: float | None, poll_interval: float, ) -> None: if not blocking: raise _AsyncTransitionUnavailableError waiter = asyncio.wrap_future(ticket) while not ticket.done(): if cancel_check is not None and cancel_check(): raise _AsyncTransitionUnavailableError wait_interval = poll_interval if deadline is not None: if (remaining := deadline - time.perf_counter()) <= 0: raise _AsyncTransitionUnavailableError wait_interval = min(wait_interval, remaining) await asyncio.wait((waiter,), timeout=wait_interval) # A hand-off that a signal handler's exception cut short leaves the head undone, so the head settles itself. self._settle_head() def _join(self, ticket: ConcurrentFuture[None]) -> None: with self._queue_lock: self._queue.append(ticket) if len(self._queue) == 1: ticket.set_result(None) def _leave(self, ticket: ConcurrentFuture[None]) -> None: with self._queue_lock: try: self._queue.remove(ticket) except ValueError: # The join never completed, or this ticket already left. return self._settle_head_locked() def _settle_head(self) -> None: with self._queue_lock: self._settle_head_locked() def _settle_head_locked(self) -> None: if self._queue and not (head := self._queue[0]).done(): head.set_result(None) @dataclass class _TaskHold: """ One task's hold, as the nesting count per wrapper it entered through. A task's hold ends when its last wrapper level is released or closed. Closing one wrapper ends only the levels taken through it. """ depths: dict[object, int] class _TaskOwners: """ Per-task ownership in front of a sync reader/writer lock that tracks threads. An async wrapper runs each call on an executor thread, and that thread does not identify the calling task. Waiting writers block new readers so a stream of readers cannot starve them. A thread lock guards the table because tasks on different event loops share it. The table stands for one backend level, taken once for all tasks. Sync code can end that level directly, with ``close()`` or a forced release on a shared singleton. The next admission then forgets the holds instead of letting more tasks join them. """ def __init__(self) -> None: self._lock: Final[Lock] = Lock() self._mode: Literal["read", "write"] | None = None self._holds: dict[asyncio.Task[object], _TaskHold] = {} self._transitioning = False self._waiting_writers = 0 #: One ticket per waiting call, settled on the next state change. A call that stops waiting takes its own out, #: so one that times out or is canceled leaves nothing behind. self._waiters: dict[ConcurrentFuture[None], None] = {} async def acquire( # ruff:ignore[too-many-arguments] # one keyword per piece of wrapper state the table consults self, mode: Literal["read", "write"], *, timeout: float, blocking: bool, lock_file: str, via: object, enter: Callable[[float], Awaitable[None]], joinable: Callable[[], bool], ) -> None: task = _current_task() timeout = _resolve_read_write_timeout(timeout, blocking=blocking) deadline = None if not blocking or timeout < 0 else time.perf_counter() + timeout waiting_writer = False try: while isinstance( admission := self._admit( task, mode, lock_file, via=via, joinable=joinable, waiting_writer=waiting_writer ), ConcurrentFuture, ): try: if not blocking or (deadline is not None and time.perf_counter() >= deadline): raise Timeout(lock_file) if mode == "write" and not waiting_writer: waiting_writer = True with self._lock: self._waiting_writers += 1 wait = None if deadline is None else deadline - time.perf_counter() await asyncio.wait((asyncio.wrap_future(admission),), timeout=wait) finally: self._forget(admission) finally: if waiting_writer: with self._lock: self._waiting_writers -= 1 self._notify() if admission == "held": return try: await enter(-1 if deadline is None else max(0.0, deadline - time.perf_counter())) except BaseException: self._finish_transition(mode=None) raise self._finish_transition(mode=mode, holds={task: _TaskHold(depths={via: 1})}) def _admit( # ruff:ignore[too-many-arguments] # the acquire's state, passed through under the table lock self, task: asyncio.Task[object], mode: Literal["read", "write"], lock_file: str, *, via: object, joinable: Callable[[], bool], waiting_writer: bool, ) -> Literal["held", "enter"] | ConcurrentFuture[None]: with self._lock: # A "held" answer trusts the backend level. Once the backend lost it, the holds are gone and the next task # has to take the lock again. if self._mode is not None and not self._transitioning and not joinable(): self._holds.clear() self._mode = None if (hold := self._holds.get(task)) is not None: if self._mode != mode: msg = ( f"Cannot acquire {mode} lock on {lock_file}: already holding a " f"{'write' if mode == 'read' else 'read'} lock " f"({'downgrade' if mode == 'read' else 'upgrade'} not allowed)" ) raise RuntimeError(msg) hold.depths[via] = hold.depths.get(via, 0) + 1 return "held" writers_ahead = self._waiting_writers - waiting_writer if not self._transitioning: if mode == "read" and self._mode == "read" and not writers_ahead: self._holds[task] = _TaskHold(depths={via: 1}) return "held" if not self._holds and (mode == "write" or not writers_ahead): self._transitioning, self._mode = True, mode return "enter" return self._ticket() async def release( self, *, force: bool, lock_file: str, via: object, leave: Callable[[], asyncio.Future[_BackendOutcome[None]]], still_held: Callable[[], bool], ) -> None: task = _current_task() with self._lock: if (hold := self._holds.get(task)) is None or via not in hold.depths: if force: return msg = f"Cannot release a lock on {lock_file} that is not held by this task through this wrapper" raise RuntimeError(msg) if not force and hold.depths[via] > 1: hold.depths[via] -= 1 return removed = hold.depths.pop(via) if hold.depths: return del self._holds[task] if self._holds: return self._transitioning = True mode = self._mode if not still_held(): self._finish_transition(mode=None) msg = f"Cannot release a lock on {lock_file} that is not held by this task" raise RuntimeError(msg) try: cancellation = await _drain_cancellation(leave()) except BaseException: hold.depths[via] = removed # Keep the hold for a retried release only while the sync lock still has the level. Restored after the sync # lock let go, it would stand for a lock nobody holds. self._finish_transition(mode=mode if still_held() else None, holds={task: hold}) raise self._finish_transition(mode=None) if cancellation is not None: # The release succeeded, so a restored hold would be a phantom no backend call can clear. raise cancellation async def retire( self, via: object | None, *, leave: Callable[[], asyncio.Future[_BackendOutcome[None]]], still_held: Callable[[], bool], ) -> None: """ End the holds taken through *via*, or every hold when it is ``None``, as one transition. Leaves the backend through *leave* when that ends the last hold, and always when *via* is ``None``. Arrivals wait while it runs, so none joins a hold the backend is letting go of. A transition already under way is waited out first: a task entering through *via* must not get a hold after the wrapper is gone. A cancellation is raised once the holds are settled. """ cancellation: asyncio.CancelledError | None = None while isinstance(settled := self._drop_holds_via(via), ConcurrentFuture): try: await _wait_until_done(asyncio.wrap_future(settled)) except asyncio.CancelledError as error: cancellation = error finally: self._forget(settled) mode, dropped = settled if via is None or (mode is not None and still_held()): try: cancellation = await _drain_cancellation(leave()) or cancellation except BaseException: # A close that failed with the backend still holding stays retryable, as a failed release does. self._finish_transition(mode=mode if still_held() else None, holds=dropped) raise self._finish_transition(mode=None) elif mode is not None: # Sync code already ended the level taken for tasks, so there is nothing to give back. self._finish_transition(mode=None) if cancellation is not None: raise cancellation def _drop_holds_via( self, via: object | None ) -> tuple[Literal["read", "write"] | None, dict[asyncio.Task[object], _TaskHold]] | ConcurrentFuture[None]: # Returns the future a running transition settles, or else the mode to leave (None when other holds keep the # backend level) and the dropped holds. The table stays transitioning while that mode is left. with self._lock: if self._transitioning: return self._ticket() if via is None: dropped = dict(self._holds) self._holds.clear() else: dropped = {} for task, hold in list(self._holds.items()): if (count := hold.depths.pop(via, None)) is not None: dropped[task] = _TaskHold(depths={via: count}) if not hold.depths: del self._holds[task] if self._holds and via is not None: return None, dropped mode = self._mode self._transitioning = mode is not None or via is None return mode, dropped def _finish_transition( self, *, mode: Literal["read", "write"] | None, holds: dict[asyncio.Task[object], _TaskHold] | None = None ) -> None: with self._lock: self._transitioning, self._mode = False, mode if mode is not None and holds: for task, hold in holds.items(): self._holds.setdefault(task, _TaskHold(depths={})).depths.update(hold.depths) self._notify() def _ticket(self) -> ConcurrentFuture[None]: # Called under the table lock, in the section that decided to wait, so no state change slips in between. ticket: ConcurrentFuture[None] = ConcurrentFuture() self._waiters[ticket] = None return ticket def _forget(self, ticket: ConcurrentFuture[None]) -> None: with self._lock: self._waiters.pop(ticket, None) def _notify(self) -> None: waiters, self._waiters = self._waiters, {} for ticket in waiters: ticket.set_result(None) _TASK_OWNERS: Final[WeakKeyDictionary[ReadWriteLock | SoftReadWriteLock, _TaskOwners]] = WeakKeyDictionary() _TASK_OWNERS_LOCK: Final[Lock] = Lock() def _task_owners_for(sync_lock: ReadWriteLock | SoftReadWriteLock) -> _TaskOwners: """Singleton wrappers share one sync lock, so they must share its task holds too.""" with _fork_transition(), _TASK_OWNERS_LOCK: if (owners := _TASK_OWNERS.get(sync_lock)) is None: owners = _TASK_OWNERS[sync_lock] = _TaskOwners() return owners def _current_task() -> asyncio.Task[object]: if (task := asyncio.current_task()) is None: msg = "an async reader/writer lock must be used from inside an asyncio task" raise RuntimeError(msg) return task async def _drain_cancellation(future: asyncio.Future[_BackendOutcome[None]]) -> asyncio.CancelledError | None: # Returned, not raised, so the caller can tell a call that completed under cancellation from one that failed. try: await _wait_until_done(future) except asyncio.CancelledError as cancellation: try: await _drain_future(future) except BaseException as error: # ruff:ignore[blind-except] # reported with the cancellation below _raise_cancelled_error(cancellation, error) return cancellation _future_result(future) return None async def _drain_future(future: asyncio.Future[_BackendOutcome[_T]]) -> _T: while not future.done(): with contextlib.suppress(asyncio.CancelledError): await _wait_until_done(future) return _future_result(future) async def _wait_until_done(future: asyncio.Future[_T]) -> None: if not future.done(): await asyncio.wait((future,)) def _future_result(future: asyncio.Future[_BackendOutcome[_T]]) -> _T: outcome = future.result() if (error := outcome.error) is None: return cast("_T", outcome.value) context = error.__context__ try: raise error # ruff:ignore[raise-within-try] # the handler restores context changed across the async boundary except BaseException: error.__context__ = context raise def _run_in_executor( loop: asyncio.AbstractEventLoop, executor: Executor | None, func: Callable[[], _T] ) -> asyncio.Future[_BackendOutcome[_T]]: outcome: asyncio.Future[_BackendOutcome[_T]] = loop.create_future() def settle(call: asyncio.Future[_BackendOutcome[_T]]) -> None: try: result = call.result() except asyncio.CancelledError: # shutdown(cancel_futures=True) dropped the call unrun: a CancelledError would cancel a task nobody canceled # and hide that the lock state never changed. msg = "the executor shut down before running the lock backend call" result = _BackendOutcome(error=RuntimeError(msg)) outcome.set_result(result) loop.run_in_executor(executor, _capture_call, func).add_done_callback(settle) return outcome def _capture_call(func: Callable[[], _T]) -> _BackendOutcome[_T]: try: return _BackendOutcome(value=func()) except BaseException as error: # ruff:ignore[blind-except] # backend control-flow exceptions are operation results return _BackendOutcome(error=error) def _raise_cancelled_error(cancellation: asyncio.CancelledError, error: BaseException) -> NoReturn: # A reconciliation step failed while unwinding a cancellation, so keep both exception chains. Splice the error's # existing context onto the cancellation, then make the cancellation the error's context, so both the failure and # the cancellation that triggered it survive. Shared by the async wrappers so cancellations report the same way. if (context := error.__context__) is not None and context is not cancellation: if (cancellation_context := cancellation.__context__) is not None: _append_exception_context(context, cancellation_context) cancellation.__context__ = context error.__context__ = cancellation _raise_chained_errors(error) async def _capture_awaitable(awaitable: Awaitable[_T]) -> _BackendOutcome[_T]: try: return _BackendOutcome(value=await awaitable) except BaseException as error: # ruff:ignore[blind-except] # backend cancellation must remain distinct from caller cancellation return _BackendOutcome(error=error) __all__ = [ "_TASKS_THREAD_ID", "_AsyncTransitionGate", "_AsyncTransitionUnavailableError", "_BackendOutcome", "_capture_awaitable", "_capture_call", "_drain_future", "_future_result", "_raise_cancelled_error", "_run_in_executor", "_task_owners_for", "_wait_until_done", ] filelock-4.2.8/src/filelock/_async_read_write.py0000644000000000000000000004303315272561277016676 0ustar00"""Async wrapper around :class:`ReadWriteLock` for use with ``asyncio``.""" from __future__ import annotations __lazy_modules__ = ["asyncio", "concurrent.futures", "filelock._async", "filelock._error", "functools", "threading"] import asyncio import functools import os import sqlite3 import threading from concurrent.futures import ThreadPoolExecutor from concurrent.futures import thread as executor_threads from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Final, Literal, ParamSpec, TypeVar, cast from filelock._api import ( _append_exception_context, _ensure_current_process, _fork_transition, _register_fork_object, ) from filelock._async import ( _TASKS_THREAD_ID, _BackendOutcome, _capture_call, _drain_future, _future_result, _raise_cancelled_error, _task_owners_for, _wait_until_done, ) from filelock._error import Timeout from filelock._read_write import ReadWriteLock if TYPE_CHECKING: from collections.abc import AsyncGenerator, Callable, MutableMapping from concurrent import futures from types import TracebackType from filelock._api import AcquireReturnProxy _P = ParamSpec("_P") _R = TypeVar("_R") class AsyncReadWriteLock: """ Async wrapper around :class:`ReadWriteLock` for use in ``asyncio`` applications. This wrapper dispatches every blocking SQLite operation to a thread pool via ``loop.run_in_executor()`` because Python's :mod:`sqlite3` module has no async API. Each ``asyncio`` task owns its own hold. Tasks share a read lock, and a task asking for the write lock waits until the other holders release, with waiting writers ahead of new readers. As with :class:`ReadWriteLock`, upgrading or downgrading a held lock raises :class:`RuntimeError`. Singleton wrappers over one path share their task holds, and closing one ends only the holds taken through it. :param lock_file: path to the SQLite database file used as the lock :param timeout: maximum wait time in seconds; ``-1`` waits up to SQLite's busy-timeout cap of about 23 days :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately when the lock is unavailable :param is_singleton: if ``True``, reuse existing :class:`ReadWriteLock` instances for the same resolved path :param loop: event loop for ``run_in_executor``; ``None`` uses the running loop :param executor: executor for ``run_in_executor``. When ``None`` this lock creates and owns a dedicated single-thread executor so every operation runs on the same thread (SQLite affinity requires this) and shuts it down in :meth:`close`. This lock uses a caller-supplied executor as-is and never shuts it down, so after passing no executor call :meth:`close` to release the owned one. .. versionadded:: 3.21.0 """ def __init__( # ruff:ignore[too-many-arguments] # public constructor: one parameter per documented lock option self, lock_file: str | os.PathLike[str], timeout: float = -1, *, blocking: bool = True, is_singleton: bool = True, loop: asyncio.AbstractEventLoop | None = None, executor: futures.Executor | None = None, ) -> None: creator_pid = os.getpid() self._creator_pid = creator_pid self._fork_invalidated = False self._closed = False self._owns_backend = not is_singleton #: Marks the task holds taken through this wrapper, which its close() ends. self._via: Final = object() _register_fork_object(self) with _fork_transition(): self._lock = ReadWriteLock(lock_file, timeout, blocking=blocking, is_singleton=is_singleton) self._owners = _task_owners_for(self._lock) self._loop = loop self._owns_executor = executor is None self._executor = executor or ThreadPoolExecutor(max_workers=1) if os.getpid() != creator_pid: # pragma: forked child msg = "AsyncReadWriteLock construction cannot continue after fork" raise RuntimeError(msg) @property def lock_file(self) -> str: """The path to the lock file.""" return self._lock.lock_file @property def timeout(self) -> float: """The default timeout.""" return self._lock.timeout @property def blocking(self) -> bool: """Whether blocking is enabled by default.""" return self._lock.blocking @property def loop(self) -> asyncio.AbstractEventLoop | None: """The event loop (or ``None`` for the running loop).""" return self._loop @property def executor(self) -> futures.Executor: """The executor used for ``run_in_executor`` (a dedicated single-thread one if none was supplied).""" return self._executor @asynccontextmanager async def read_lock(self, timeout: float | None = None, *, blocking: bool | None = None) -> AsyncGenerator[None]: """ Async context manager that acquires and releases a shared read lock. Falls back to instance defaults for *timeout* and *blocking* when ``None``. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default """ await self.acquire_read(timeout, blocking=blocking) body_error: BaseException | None = None try: yield except BaseException as error: body_error = error raise finally: await self._release_in_context(body_error) @asynccontextmanager async def write_lock(self, timeout: float | None = None, *, blocking: bool | None = None) -> AsyncGenerator[None]: """ Async context manager that acquires and releases an exclusive write lock. Falls back to instance defaults for *timeout* and *blocking* when ``None``. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default """ await self.acquire_write(timeout, blocking=blocking) body_error: BaseException | None = None try: yield except BaseException as error: body_error = error raise finally: await self._release_in_context(body_error) async def _release_in_context(self, body_error: BaseException | None) -> None: try: await self.release() except BaseException as release_error: if body_error is not None: _append_exception_context(release_error, body_error) raise async def acquire_read( self, timeout: float | None = None, *, blocking: bool | None = None ) -> AsyncAcquireReadWriteReturnProxy: """ Acquire a shared read lock. See :meth:`ReadWriteLock.acquire_read` for full semantics. :param timeout: seconds to wait; ``None`` uses the instance setting; ``-1`` waits up to SQLite's busy-timeout cap of about 23 days :param blocking: if ``False``, raise :class:`~filelock.Timeout` on contention; ``None`` uses the instance setting :returns: a proxy that can be used as an async context manager to release the lock :raises RuntimeError: if the calling task already holds the write lock :raises Timeout: if the lock cannot be acquired within *timeout* seconds :raises TypeError: if *timeout* is a :class:`bool` or not a real number :raises ValueError: if a blocking call gets a ``nan`` *timeout* or a negative one other than ``-1`` """ self._raise_if_unusable() await self._acquire("read", timeout, blocking=blocking) return AsyncAcquireReadWriteReturnProxy(lock=self) async def acquire_write( self, timeout: float | None = None, *, blocking: bool | None = None ) -> AsyncAcquireReadWriteReturnProxy: """ Acquire an exclusive write lock. See :meth:`ReadWriteLock.acquire_write` for full semantics. :param timeout: seconds to wait; ``None`` uses the instance setting; ``-1`` waits up to SQLite's busy-timeout cap of about 23 days :param blocking: if ``False``, raise :class:`~filelock.Timeout` on contention; ``None`` uses the instance setting :returns: a proxy that can be used as an async context manager to release the lock :raises RuntimeError: if the calling task already holds the read lock :raises Timeout: if the lock cannot be acquired within *timeout* seconds :raises TypeError: if *timeout* is a :class:`bool` or not a real number :raises ValueError: if a blocking call gets a ``nan`` *timeout* or a negative one other than ``-1`` """ self._raise_if_unusable() await self._acquire("write", timeout, blocking=blocking) return AsyncAcquireReadWriteReturnProxy(lock=self) async def _acquire(self, mode: Literal["read", "write"], timeout: float | None, *, blocking: bool | None) -> None: timeout = self.timeout if timeout is None else timeout blocking = self.blocking if blocking is None else blocking async def enter(remaining: float) -> None: # A close() may have landed while this task waited for the table. self._raise_if_unusable() await self._run_acquire( functools.partial( self._lock._acquire_level_of, # ruff:ignore[private-member-access] # the wrapper owns this backend hold _TASKS_THREAD_ID, mode, remaining, blocking=blocking, ) ) def joinable() -> bool: self._raise_if_unusable() return self._backend_holds() await self._owners.acquire( mode, timeout=timeout, blocking=blocking, lock_file=self.lock_file, via=self._via, enter=enter, joinable=joinable, ) async def release(self, *, force: bool = False) -> None: """ Release one level of the calling task's hold. The wrapper releases the database lock once no task holds it. :param force: if ``True``, end every level the calling task took through this wrapper. Levels taken through other wrappers on the same path stay. A wrapper that holds no level for the task releases nothing. :raises RuntimeError: if the calling task holds no level through this wrapper and *force* is ``False`` """ _ensure_current_process() if self._inherited: # pragma: needs fork return await self._owners.release( force=force, lock_file=self.lock_file, via=self._via, leave=functools.partial(self._submit, self._leave_backend), still_held=self._backend_holds, ) async def close(self) -> None: """ End the holds of the tasks that acquired through this instance and release the lock once no task holds it. After calling this method, the lock instance is no longer usable. With ``is_singleton=False`` it also closes the underlying SQLite connection. A singleton wrapper leaves the shared :class:`ReadWriteLock` open, so tasks that acquired through another wrapper and synchronous users of the same path keep their holds. The next acquire notices a synchronous ``close()`` or forced release of that shared lock. One that lands in the few bytecodes between that check and the admission still admits the task into the ended hold. """ _ensure_current_process() if self._inherited: # pragma: needs fork return if self._closed: return # Refuse acquires from here on, so none is admitted into a hold the backend is letting go of. self._closed = True try: await self._owners.retire( None if self._owns_backend else self._via, leave=functools.partial(self._submit, self._lock.close if self._owns_backend else self._leave_backend), still_held=self._backend_holds, ) except BaseException as error: # Only a failed backend call can leave the level held, and then a retried close() has to find it. if not isinstance(error, asyncio.CancelledError) and self._backend_holds(): self._closed = False else: self._shutdown_owned_executor() raise # Wait for the worker to exit rather than letting it drain in the background: a caller that forks right # after closing deserves a single-threaded process, and os.fork warns about any surviving thread. if self._owns_executor: await asyncio.to_thread(functools.partial(self._executor.shutdown, wait=True)) async def _run_acquire(self, acquire: Callable[..., AcquireReturnProxy]) -> None: abandoned = threading.Event() acquire_future = self._submit(acquire, cancel_check=abandoned.is_set) try: await _wait_until_done(acquire_future) except asyncio.CancelledError as cancellation: # Stops a backend wait on another process's hold, which would otherwise outlast the caller by as long. abandoned.set() try: await _drain_future(acquire_future) except Timeout: # Nothing was taken, so the caller's cancellation is the whole outcome. raise cancellation from None except BaseException as error: # ruff:ignore[blind-except] # reported with the cancellation below _raise_cancelled_error(cancellation, error) try: await _drain_future(self._submit(self._leave_backend)) except BaseException as error: # ruff:ignore[blind-except] # reported with the cancellation below _raise_cancelled_error(cancellation, error) raise _future_result(acquire_future) def _backend_holds(self) -> bool: # A sync close() or forced release on a shared singleton ends the tasks' level without the table taking part. return self._lock._holds_level_of(_TASKS_THREAD_ID) # ruff:ignore[private-member-access] # the wrapper owns this backend hold def _leave_backend(self) -> None: # Only the tasks' level: a sync user of the same singleton keeps its own. self._lock._release_level_of(_TASKS_THREAD_ID) # ruff:ignore[private-member-access] # the wrapper owns this backend hold def _submit( self, func: Callable[_P, _R], *args: _P.args, **kwargs: _P.kwargs ) -> asyncio.Future[_BackendOutcome[_R]]: return (self._loop or asyncio.get_running_loop()).run_in_executor( self._executor, _capture_call, functools.partial(func, *args, **kwargs), ) def _shutdown_owned_executor(self) -> None: if self._owns_executor: self._executor.shutdown(wait=False) @property def _inherited(self) -> bool: return self._fork_invalidated or os.getpid() != self._creator_pid def _raise_if_unusable(self) -> None: _ensure_current_process() if self._inherited: # pragma: needs fork msg = f"AsyncReadWriteLock on {self.lock_file} was invalidated by fork(); construct a new instance" raise RuntimeError(msg) if self._closed: msg = "Cannot operate on a closed database." raise sqlite3.ProgrammingError(msg) def _reset_after_fork_in_child(self) -> None: # pragma: forked child self._fork_invalidated = True if self._owns_executor: # The worker did not survive the fork, yet before 3.12 the child's exit still wakes it through its queue. On # PyPy that queue is pure Python, and its lock may have been held by the worker at fork time, so exit hangs. # CPython 3.12+ forgets every inherited worker in the child; do the same for the one this lock owns. # typeshed declares the registry read-only; it is a WeakKeyDictionary the exit hook iterates. registry = cast( "MutableMapping[threading.Thread, object]", executor_threads._threads_queues, # ruff:ignore[private-member-access] # the exit hook's registry ) for worker in cast("ThreadPoolExecutor", self._executor)._threads: # ruff:ignore[private-member-access] # only the executor lists its workers registry.pop(worker, None) def __del__(self) -> None: # Safety net when close() was never called: shut down the executor we own so its worker thread does not # outlive the lock. shutdown(wait=False) never blocks. if os.getpid() == getattr(self, "_creator_pid", None) and getattr(self, "_owns_executor", False): self._executor.shutdown(wait=False) class AsyncAcquireReadWriteReturnProxy: """Context-aware object that releases the async read/write lock on exit.""" def __init__(self, lock: AsyncReadWriteLock) -> None: self.lock = lock async def __aenter__(self) -> AsyncReadWriteLock: return self.lock async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: await self.lock.release() __all__ = [ "AsyncAcquireReadWriteReturnProxy", "AsyncReadWriteLock", ] filelock-4.2.8/src/filelock/_descriptor.py0000644000000000000000000000702015272561277015526 0ustar00"""A minimal native lock over a caller-owned file descriptor, contending with :class:`FileLock` on the same file.""" from __future__ import annotations __lazy_modules__ = ["filelock._unix", "threading", "typing"] import sys import time from threading import TIMEOUT_MAX from typing import Final from filelock._api import _seconds if sys.platform == "win32": # pragma: win32 cover from filelock._windows import _lock_fd_nonblocking, _unlock_fd else: # pragma: win32 no cover from filelock._unix import _lock_fd_nonblocking, _unlock_fd def lock_descriptor(fd: int, *, blocking: bool = True, poll_interval: float = 0.05) -> bool: """ Take the native OS lock on *fd*, a file descriptor the caller opened and owns. This is the same exclusive lock the native :class:`FileLock` backend uses: a whole-file :func:`fcntl.flock` lock on Unix, or a ``LockFileEx`` lock of one byte at offset 0 on Windows. A descriptor lock and a native path lock on the same file contend with each other. There are no offset or length parameters; seeking *fd* does not change the locked range. Interoperating with another tool requires agreement on the locking primitive, range, and protocol. Unlike :class:`FileLock` it adds no path handling: it never opens, truncates, closes, unlinks, chmods, canonicalizes, or falls back. The caller owns *fd* before, during, and after the call, and must close it. On Windows *fd* must be a synchronous descriptor (its handle not opened with ``FILE_FLAG_OVERLAPPED``). For timeout, reentrancy, singleton, lifetime, or stale-break behavior, use :class:`FileLock`. There is no async wrapper. Run this in an executor, or drive ``blocking=False`` from your own polling loop. :param fd: an open file descriptor the caller owns. :param blocking: when ``True`` (default), retry the nonblocking attempt every *poll_interval* seconds until it succeeds; when ``False``, make one attempt. :param poll_interval: positive seconds between attempts while blocking, at most :data:`threading.TIMEOUT_MAX`; ignored when *blocking* is ``False``. :returns: ``True`` once the lock is held, or ``False`` on contention when ``blocking`` is ``False``. :raises OSError: for a permanent native failure, such as an invalid descriptor, or with ``errno.ENOSYS`` when the Python build lacks the native locking primitive. The descriptor is left open. :raises ValueError: if a blocking call receives a *poll_interval* outside that range, including ``nan``. .. versionadded:: 3.30.0 """ if not blocking: return _lock_fd_nonblocking(fd) # time.sleep raises OverflowError past TIMEOUT_MAX; nan fails both comparisons. if not 0 < (poll_interval := _seconds("poll_interval", poll_interval)) <= TIMEOUT_MAX: msg: Final[str] = f"poll_interval must be greater than 0 and at most {TIMEOUT_MAX}, got {poll_interval}" raise ValueError(msg) while not _lock_fd_nonblocking(fd): time.sleep(poll_interval) return True def unlock_descriptor(fd: int) -> None: """ Release the native OS lock on *fd* without touching the descriptor. :param fd: the descriptor a prior :func:`lock_descriptor` locked; the caller still owns and must close it. :raises OSError: if the native unlock fails, including ``errno.ENOSYS`` when the Python build lacks the native locking primitive; the caller may retry on the same descriptor. .. versionadded:: 3.30.0 """ _unlock_fd(fd) __all__ = [ "lock_descriptor", "unlock_descriptor", ] filelock-4.2.8/src/filelock/_error.py0000644000000000000000000000624315272561277014507 0ustar00from __future__ import annotations __lazy_modules__ = ["typing"] from typing import Final class Timeout(TimeoutError): # ruff:ignore[error-suffix-on-exception-name] # public exception name; renaming breaks the API """Raised when the lock could not be acquired in *timeout* seconds.""" def __init__(self, lock_file: str, holder: str | None = None) -> None: super().__init__() self._lock_file = lock_file self._holder = holder def __reduce__(self) -> tuple[type[Timeout], tuple[str], dict[str, object]]: # holder rides in the state so a subclass taking only lock_file, and a 4.0.12 reader, can still unpickle it. return self.__class__, (self._lock_file,), self.__dict__ def __str__(self) -> str: # pragma: needs hard-link message: Final = f"The file lock '{self._lock_file}' could not be acquired." if self._holder is None: return message return f"{message} It is held by {self._holder}, which this host cannot probe." def __repr__(self) -> str: return f"{self.__class__.__name__}({self.lock_file!r})" @property def lock_file(self) -> str: """The path of the file lock.""" return self._lock_file class SoftFileLockLifetimeWarning(FutureWarning): """ The configured soft-lock lifetime permits overlapping live holders after expiry. A :class:`FutureWarning` shows by default wherever the lock is configured; a :class:`DeprecationWarning` shows only in ``__main__``. """ class LeaseSettingsMismatch(ValueError): # ruff:ignore[error-suffix-on-exception-name] # public exception name; renaming breaks the API """A lease contender disagrees with the published claim about how long the lease lasts.""" class SoftFileLockProtocolError(OSError): """Raised when strict soft-lock state cannot be interpreted without risking overlap.""" def __init__(self, lock_file: str, claim_name: str | None, reason: str) -> None: self._lock_file = lock_file self._claim_name = claim_name self._reason = reason super().__init__(self.__str__()) def __reduce__( self, ) -> tuple[ type[SoftFileLockProtocolError], tuple[str, str | None, str], dict[str, object] ]: # pragma: needs hard-link return self.__class__, (self._lock_file, self._claim_name, self._reason), self.__dict__ def __str__(self) -> str: location = self._lock_file if self._claim_name is None else f"{self._lock_file}: claim {self._claim_name!r}" return f"Invalid strict soft-lock state at {location}: {self._reason}" @property def lock_file(self) -> str: # pragma: needs hard-link """The requested lock path.""" return self._lock_file @property def claim_name(self) -> str | None: # pragma: needs hard-link """The claim that caused the error, if scanning identified one.""" return self._claim_name @property def reason(self) -> str: # pragma: needs hard-link """The protocol validation failure.""" return self._reason __all__ = [ "LeaseSettingsMismatch", "SoftFileLockLifetimeWarning", "SoftFileLockProtocolError", "Timeout", ] filelock-4.2.8/src/filelock/_identity.py0000644000000000000000000004226215272561277015210 0ustar00from __future__ import annotations __lazy_modules__ = ["hashlib", "pathlib", "socket", "typing"] import hashlib import os import socket import sys from errno import EPERM, ESRCH from pathlib import Path from typing import Final #: Every marker format in this package caps the hostname at the RFC 1123 limit and reads a longer one as malformed. _HOST_NAME_LIMIT: Final[int] = 253 #: The bytes those formats carry verbatim: printable non-space ASCII, less the ``?`` reserved for the escape. _HOST_NAME_VERBATIM: Final[frozenset[int]] = frozenset(range(0x21, 0x7F)) - {ord("?")} #: The characters at the end of an over-long name that carry a digest of the part cut off. _HOST_NAME_DIGEST_LEN: Final[int] = 8 #: The kernel ABI fixes the initial PID namespace's inode (``PID_NS_INIT_INO`` in ``linux/nsfs.h``); every namespace #: created later draws one from ``0xF0000000`` up, so this value names the host's own namespace. _INITIAL_PID_NAMESPACE: Final[int] = 0xEFFFFFFC _NAMESPACE_MARK: Final[str] = "?pidns-" def host_name() -> str: """ The hostname recorded alongside an owner, so a marker written on another machine is never probed here. Marker formats here hold the field to printable non-space ASCII, while ``socket.gethostname()`` reports whatever the kernel stores: a space, a newline that forges an extra marker line, or a byte no codec encodes, which Python hands back as a surrogate. A holder that publishes one raw writes a marker it cannot read back, and loses the lock to the first peer that ages it out as malformed. ``?`` is illegal in a hostname, so escaping it along with every out-of-grammar byte as ``?`` leaves a real name untouched and still keeps two hosts apart, which :func:`owner_is_stale` relies on to refuse to probe a foreign PID. Linux appends the PID namespace, as Mercurial's lock prefix does. Two containers in one pod share the hostname and the boot id but not the PID namespace, so a PID recorded in one names another process, or none, in the other. The suffix starts with a ``?`` no escape can produce, so ``a`` in a namespace never reads as a host literally named ``a?pidns-...``. The initial namespace adds nothing, which keeps a host's name what older releases recorded. An over-long name ends in a digest of the part cut to fit the limit. A bare cut gives every name that shares the kept prefix one identity, and :func:`owner_is_stale` would then probe a local PID for another host's holder. """ name = ( "".join( chr(byte) if byte in _HOST_NAME_VERBATIM else f"?{byte:02x}" for byte in socket.gethostname().encode("utf-8", "surrogateescape") ) + _pid_namespace_suffix() ) if len(name) > _HOST_NAME_LIMIT: keep = _HOST_NAME_LIMIT - _HOST_NAME_DIGEST_LEN - 1 name = f"{name[:keep]}-{hashlib.sha256(name[keep:].encode()).hexdigest()[:_HOST_NAME_DIGEST_LEN]}" # An escape is three characters and a kept byte is never '?', so a bare '?' can only mean an empty hostname. return name or "?" def _pid_namespace_suffix() -> str: try: namespace: Final = Path("/proc/self/ns/pid").stat().st_ino except OSError: # only Linux 3.8+ has the file, and a sandbox may hide it; nothing else tells namespaces apart return "" return "" if namespace == _INITIAL_PID_NAMESPACE else f"{_NAMESPACE_MARK}{namespace:x}" def owner_is_stale(pid: int, hostname: str, start_token: int | None) -> bool: """ Whether the recorded owner is provably gone, so reclaiming its marker cannot detach a live holder. Fail closed: return ``True`` only when this process can prove the exact recorded owner is dead. A marker from another host cannot be probed; a live PID whose start token still matches, or whose token cannot be read, is the holder or is indistinguishable from it; a live PID whose start token differs is a recycled PID, so the process that wrote the marker is gone. PostgreSQL, Qt ``QLockFile`` and Mercurial all break a stale lock only on proof of death and treat an unreadable or foreign owner as still holding. """ if hostname != host_name(): return False if process_alive(pid) and ( start_token is None or (current := process_start_token(pid)) is None or _same_start(current, start_token) ): return False # Only a bare hostname can be an older release's, written from a container that shares this host's name. return _NAMESPACE_MARK in hostname or not _owner_runs_elsewhere(pid, start_token) def owner_is_current_process(pid: int, hostname: str, start_token: int | None) -> bool: """ Whether the recorded owner is this process, so a marker a recycled PID left behind never reads as ours. Fail closed: a recorded start token must match this process's. One that differs was written by an earlier process that reused this PID, and when this process cannot read its own token nothing proves the marker is ours. A marker without a token falls back to the PID and hostname. """ if pid != os.getpid() or hostname != host_name(): return False return start_token is None or start_token == process_start_token(pid) def _same_start(current: int, recorded: int) -> bool: # A Linux token carries the boot id in its high 64 bits, or 0 there when the process could not read the boot id. # systemd's ProcSubset=pid hides /proc/sys from one unit, so one process may lack the boot id while its peer has # it. A missing boot id then proves nothing, and only starttime is compared. Other platforms' tokens fit in the # low bits. return current == recorded or (0 in {current >> 64, recorded >> 64} and current % 2**64 == recorded % 2**64) if sys.platform == "win32": # pragma: win32 cover import ctypes from ctypes import wintypes _KERNEL32: Final[ctypes.WinDLL] = ctypes.WinDLL("kernel32", use_last_error=True) _KERNEL32.CloseHandle.argtypes = [wintypes.HANDLE] _KERNEL32.CloseHandle.restype = wintypes.BOOL _KERNEL32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] _KERNEL32.OpenProcess.restype = wintypes.HANDLE _KERNEL32.GetProcessTimes.argtypes = [ wintypes.HANDLE, ctypes.POINTER(wintypes.FILETIME), ctypes.POINTER(wintypes.FILETIME), ctypes.POINTER(wintypes.FILETIME), ctypes.POINTER(wintypes.FILETIME), ] _KERNEL32.GetProcessTimes.restype = wintypes.BOOL _KERNEL32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] _KERNEL32.WaitForSingleObject.restype = wintypes.DWORD _WIN_SYNCHRONIZE: Final[int] = 0x100000 _WIN_PROCESS_QUERY_LIMITED_INFORMATION: Final[int] = 0x1000 _WIN_ERROR_INVALID_PARAMETER: Final[int] = 87 _WIN_INHERIT_HANDLE: Final[bool] = False _WIN_WAIT_OBJECT_0: Final[int] = 0 def process_alive(pid: int) -> bool: """Whether a process with this PID still runs, treating an access denial as proof it does.""" handle = _KERNEL32.OpenProcess(_WIN_SYNCHRONIZE, _WIN_INHERIT_HANDLE, pid) if handle: try: # Any open handle keeps an exited process openable, but its process object is signaled once it exits. return _KERNEL32.WaitForSingleObject(handle, 0) != _WIN_WAIT_OBJECT_0 finally: _KERNEL32.CloseHandle(handle) return ctypes.get_last_error() != _WIN_ERROR_INVALID_PARAMETER def process_start_token(pid: int) -> int | None: """The process creation FILETIME as a 100ns tick count, or ``None`` when it cannot be read.""" handle = _KERNEL32.OpenProcess(_WIN_PROCESS_QUERY_LIMITED_INFORMATION, _WIN_INHERIT_HANDLE, pid) if not handle: return None creation, exit_time, kernel_time, user_time = (wintypes.FILETIME() for _ in range(4)) try: if not _KERNEL32.GetProcessTimes( handle, ctypes.byref(creation), ctypes.byref(exit_time), ctypes.byref(kernel_time), ctypes.byref(user_time), ): return None # pragma: no cover # win32 GetProcessTimes failure path; not reproducible on a live handle finally: _KERNEL32.CloseHandle(handle) return (creation.dwHighDateTime << 32) | creation.dwLowDateTime else: # pragma: win32 no cover def process_alive(pid: int) -> bool: """Whether a process with this PID exists, treating an access denial (``EPERM``) as proof it does.""" try: os.kill(pid, 0) except OSError as error: if error.errno == ESRCH: return False if error.errno == EPERM: return True raise return True if sys.platform in {"linux", "android"}: # pragma: linux cover # Termux/Android reports sys.platform == "android" but runs the Linux kernel, so /proc//stat and the boot # id are the same reliable start-time source; treat it exactly like Linux rather than the tokenless fallback. # comm (field 2) is wrapped in parentheses and may itself contain spaces or a ')', so the fixed fields start # after the final ')'. starttime is field 22 overall, the twentieth of those trailing fields (index 19). _STARTTIME_INDEX: Final[int] = 19 def _read_boot_id() -> int: # starttime is measured in clock ticks since boot, so on its own it repeats across a reboot. Folding the # boot id into the high bits makes the Linux token reboot-safe like the absolute clocks macOS and Windows # expose, while staying a single integer so a 3.29 reader still parses the third marker line. 0 when this # process cannot read the boot id leaves the bare starttime, which _same_start compares alone. try: boot_id = Path("/proc/sys/kernel/random/boot_id").read_text(encoding="ascii") return int(boot_id.strip().replace("-", ""), 16) except (OSError, ValueError): return 0 _BOOT_ID: Final[int] = _read_boot_id() #: ``TIME_NS_INIT_INO`` in ``linux/proc_ns.h``, fixed by the kernel ABI like the initial PID namespace's inode. _INITIAL_TIME_NAMESPACE: Final[int] = 0xEFFFFFFA def process_start_token(pid: int) -> int | None: """ The ``/proc//stat`` ``starttime`` folded with the boot id. ``None`` when the process is gone, or when this process runs in a time namespace with a boottime offset. Since Linux 5.6 the kernel shifts ``starttime`` by the reader's offset, so a value read there differs from what a peer outside reads. """ return _read_start_token(pid) if _starttime_unshifted() else None def _starttime_unshifted() -> bool: # setns can move this process into another time namespace, so ask on every read. try: namespace: Final = Path("/proc/self/ns/time").stat().st_ino except FileNotFoundError: # no entry means no time namespaces on this kernel return True except OSError: # any other failure leaves the namespace unknown, so starttime is not comparable return False if namespace == _INITIAL_TIME_NAMESPACE: return True # A container runtime may give each container a time namespace without offsets. timens_offsets describes # the namespace this process's children get, its own unless it unshared one since. try: if Path("/proc/self/ns/time_for_children").stat().st_ino != namespace: return False offsets: Final = Path("/proc/self/timens_offsets").read_text(encoding="ascii") except OSError: return False # The clock goes by name since Linux 5.8 and by clock id before. return any(line.split() in (["boottime", "0", "0"], ["7", "0", "0"]) for line in offsets.splitlines()) def _read_start_token(pid: int) -> int | None: try: data = Path(f"/proc/{pid}/stat").read_bytes() except OSError: return None # psutil identifies a process by the same (pid, starttime) pair; the boot id extends that across reboots. fields = data[data.rfind(b")") + 1 :].split() if len(fields) <= _STARTTIME_INDEX: # pragma: no cover # a truncated /proc read, never seen in practice return None try: starttime = int(fields[_STARTTIME_INDEX]) except ValueError: # pragma: no cover # /proc always renders starttime as an integer return None return (_BOOT_ID << 64) | starttime def _owner_runs_elsewhere(pid: int, start_token: int | None) -> bool: """ Whether the owner runs in another PID namespace as *pid*. An older release in a container sharing this hostname recorded its in-container PID, which means nothing here. Readers without a boottime offset read the same starttime and boot id. A task with the recorded start token whose innermost PID is *pid* is therefore the owner; one that started in the same tick is not. A reader with an offset cannot compare start tokens, so the innermost PID alone keeps the marker. """ if start_token is None or (_BOOT_ID and start_token >> 64 not in {0, _BOOT_ID}): return False unshifted: Final = _starttime_unshifted() try: with os.scandir("/proc") as entries: return any( entry.name.isdigit() and (task := int(entry.name)) != pid and ( not unshifted or ((token := _read_start_token(task)) is not None and _same_start(token, start_token)) ) and _innermost_pid(task) == pid for entry in entries ) except OSError: # A /proc this process cannot list proves nothing either way, which leaves 4.0.12's verdict. return False def _innermost_pid(task: int) -> int | None: try: status: Final = Path(f"/proc/{task}/status").read_bytes() except OSError: # pragma: no cover # the task exited between the stat read and this one return None # The kernel ends a line only with "\n". A task names itself, and its Name line keeps "\r", "\v" and the # other separators that a text-mode read and str.splitlines() split at. A name could then forge a line. return next( (int(line.split()[-1]) for line in status.split(b"\n") if line.startswith(b"NSpid:")), None, # kernels before 4.1 record no namespace PIDs ) elif sys.platform == "darwin": # pragma: darwin cover import ctypes import struct _LIBC: Final[ctypes.CDLL] = ctypes.CDLL(None, use_errno=True) _CTL_KERN: Final[int] = 1 _KERN_PROC: Final[int] = 14 _KERN_PROC_PID: Final[int] = 1 # kinfo_proc opens with kp_proc.p_starttime (a struct timeval) at offset 0: int64 seconds, int32 microseconds. # The offset is fixed by the struct chain kinfo_proc -> extern_proc -> p_un, so a read at 0 is not a guess. _TIMEVAL_AT_ZERO: Final[str] = " int | None: """The process start time in microseconds from ``sysctl(KERN_PROC_PID)``, or ``None`` when it is gone.""" mib = (ctypes.c_int * 4)(_CTL_KERN, _KERN_PROC, _KERN_PROC_PID, pid) length = ctypes.c_size_t(0) # The size probe reports the kinfo_proc size for any PID, so the read below, not the probe, tells a live # process from a gone one: a gone PID leaves the fetch a zero-length success, so re-check the length after. if _LIBC.sysctl(mib, 4, None, ctypes.byref(length), None, 0) != 0: return None buffer = (ctypes.c_char * length.value)() if _LIBC.sysctl(mib, 4, buffer, ctypes.byref(length), None, 0) != 0 or length.value < _TIMEVAL_SIZE: return None seconds, microseconds = struct.unpack_from(_TIMEVAL_AT_ZERO, buffer.raw, 0) return seconds * 1_000_000 + microseconds else: # pragma: no cover # a POSIX platform without a proven start-time source falls back to fail-closed liveness def process_start_token(pid: int) -> int | None: """No proven start-time source, so the owner carries no token and liveness rests on the PID alone.""" del pid return None if sys.platform not in {"linux", "android"}: # pragma: linux no cover # pragma: android no cover def _owner_runs_elsewhere(pid: int, start_token: int | None) -> bool: """Without Linux PID namespaces, a PID that is gone or recycled leaves its owner nowhere else to run.""" del pid, start_token return False __all__ = [ "host_name", "owner_is_current_process", "owner_is_stale", "process_alive", "process_start_token", ] filelock-4.2.8/src/filelock/_lease.py0000644000000000000000000004562515272561277014456 0ustar00from __future__ import annotations __lazy_modules__ = [ "contextlib", "filelock._error", "filelock._identity", "filelock._soft", "filelock._util", "math", "secrets", "threading", "weakref", ] import os import secrets import sys import time from contextlib import suppress from dataclasses import dataclass from math import isfinite from threading import Event, Thread, current_thread from typing import TYPE_CHECKING, Final, Literal, cast from weakref import WeakMethod from filelock._api import _check_timeout_max, _seconds from filelock._error import LeaseSettingsMismatch from filelock._identity import owner_is_stale from filelock._marker import MarkerSoftFileLock, OwnerMode, OwnerRecord, parse_marker from filelock._soft import _read_lock_file from filelock._util import pin_inode, touch if TYPE_CHECKING: from collections.abc import Callable, Mapping from typing import Unpack from filelock._api import LockOptions, _ExtraValue #: ``evicted`` is a :class:`~filelock.SoftReadWriteLock` loss: a peer waited out the stale threshold and committed a #: generation without this holder. CompromiseReason = Literal["marker-missing", "owner-changed", "refresh-failed", "evicted"] _RefreshOutcome = Literal["ok", "transient", "marker-missing", "owner-changed"] _DEFAULT_LEASE_DURATION: Final[float] = 30.0 @dataclass(frozen=True) class LeaseCompromise: """Why a held lease stopped being this process's to hold, and the error that ended its refresh, if any.""" lock_file: str token: str reason: CompromiseReason error: Exception | None = None @dataclass(frozen=True) class _Heartbeat: """A running heartbeat and the event that stops it, which only ever exist together.""" thread: Thread stop: Event @dataclass class _LeaseClaim: """The state of one claim: its token, its heartbeat, and how that claim was lost.""" token: str | None = None compromise: LeaseCompromise | None = None heartbeat: _Heartbeat | None = None class SoftFileLease(MarkerSoftFileLock): """ Existence lock whose claim expires, so a peer may take it while the previous holder still runs. A lease trades mutual exclusion for progress. The holder publishes a claim and refreshes it every ``heartbeat_interval`` seconds; a contender takes the marker once it has seen no refresh for ``lease_duration`` seconds of its own monotonic clock, so clock skew between hosts cannot expire a live claim. Nothing stops the expired holder: it keeps running, and it keeps using whatever the lock protects. Treat the lease as a hint about who *should* be working, not as a guarantee that only one worker is. To make a protected resource reject a superseded holder, that resource must be linearizable and must fence on a monotonic generation it controls. :attr:`token` names a claim; it does not fence one. Where overlap is unacceptable, use :class:`StrictSoftFileLock ` instead. Every contender for a path must agree on ``lease_duration``. A contender that finds a claim published under a different duration raises :class:`LeaseSettingsMismatch ` rather than apply its own expiry to a peer that never agreed to it. Expiry reclaims less on Windows, which refuses to rename or delete a file another process holds open. A peer there takes an expired claim only once the previous holder's process exits and its handle closes; a holder that lives on but stops refreshing keeps the marker. Unix reclaims the marker either way. ``on_compromise`` fires from the heartbeat thread when a refresh fails, or when the marker vanishes or names another owner. The holder should stop touching the protected resource when it runs. Because it runs on that thread, a ``release()`` inside it only takes effect when the lease was built with ``thread_local=False``; the default thread-local context hides the claim from every thread but the one that acquired it, so the release does nothing. Signal the acquiring thread instead when the context stays thread-local. .. versionadded:: 3.30.0 """ _owner_mode: OwnerMode = "lease" #: lease_duration replaces the legacy age-based lifetime, so accepting both would give one lock two expiry clocks. _lifetime_supported: bool = False _lifetime_unsupported_reason: str = "lease_duration sets when a lease expires" def __init__( self, lock_file: str | os.PathLike[str], *, lease_duration: float = _DEFAULT_LEASE_DURATION, heartbeat_interval: float | None = None, on_compromise: Callable[[LeaseCompromise], None] | None = None, **kwargs: Unpack[LockOptions], ) -> None: """ Create a lease. :param lease_duration: seconds a contender must see the marker go unrefreshed before it may take the claim. Every contender for the path must pass the same value. :param heartbeat_interval: seconds between refreshes. Defaults to a third of ``lease_duration``, leaving room for two missed refreshes before a peer may take the claim. Must be shorter than ``lease_duration`` and at most :data:`threading.TIMEOUT_MAX`. :param on_compromise: called from the heartbeat thread with a :class:`LeaseCompromise` when the claim is lost. :param kwargs: every other :class:`BaseFileLock ` option, ``timeout`` and ``mode`` among them. The metaclass passes them all by keyword, and taking them here lets :class:`AsyncSoftFileLease ` add the async plumbing a fixed signature would hide. """ # A plain float: the marker records its repr, which NumPy's float64 spells in a form no parser reads. if not isfinite(lease_duration := _seconds("lease_duration", lease_duration)) or lease_duration <= 0: msg = f"lease_duration must be positive and finite, got {lease_duration!r}" raise ValueError(msg) heartbeat_interval = ( lease_duration / 3 if heartbeat_interval is None else _seconds("heartbeat_interval", heartbeat_interval) ) if not 0 < heartbeat_interval < lease_duration: msg = f"heartbeat_interval must be positive and below lease_duration, got {heartbeat_interval!r}" raise ValueError(msg) _check_timeout_max("heartbeat_interval", heartbeat_interval) super().__init__(lock_file, **kwargs) self._lease_duration = lease_duration self._heartbeat_interval = heartbeat_interval self._on_compromise = on_compromise # Two threads lazily creating a shared claim could each get one, and release() would miss the heartbeat's. if not self.is_thread_local(): self._context.lease_claim = _LeaseClaim() def _singleton_extra_mismatches(self, kwargs: Mapping[str, _ExtraValue], /) -> dict[str, tuple[str, str]]: mismatches = super()._singleton_extra_mismatches(kwargs) lease_duration: Final = _seconds( "lease_duration", cast("float", kwargs.get("lease_duration", _DEFAULT_LEASE_DURATION)) ) if lease_duration != self._lease_duration: mismatches["lease_duration"] = (str(lease_duration), str(self._lease_duration)) # An omitted heartbeat_interval resolves as in __init__; a differing duration is already reported above. heartbeat_interval: Final = ( self._lease_duration / 3 if (passed := kwargs.get("heartbeat_interval")) is None else _seconds("heartbeat_interval", cast("float", passed)) ) if heartbeat_interval != self._heartbeat_interval: mismatches["heartbeat_interval"] = (str(passed), str(self._heartbeat_interval)) # A callback compares by identity, as on_acquired does: two equal callables can close over different state. if (on_compromise := kwargs.get("on_compromise")) is not self._on_compromise: mismatches["on_compromise"] = (str(on_compromise), str(self._on_compromise)) return mismatches @property def _claim(self) -> _LeaseClaim: # Per hold, so another thread's failed acquire cannot stop the holder's heartbeat. state: Final = self._hold_state() if (claim := state.lease_claim) is None: claim = state.lease_claim = _LeaseClaim() return claim @property def lease_duration(self) -> float: """The staleness in seconds after which a contender may take this claim.""" return self._lease_duration @property def token(self) -> str | None: """ The token naming the claim this process published. :returns: the token while the lease is held, ``None`` otherwise. It identifies a claim; it does not fence one. """ return self._claim.token @property def compromise(self) -> LeaseCompromise | None: """ The loss of claim the heartbeat observed. The value outlives ``release()``, so a holder can check after leaving the ``with`` block whether its work may have overlapped a successor; the next acquisition resets it, as :attr:`SoftReadWriteLock.compromise ` does. :returns: the :class:`LeaseCompromise` of the latest claim, or ``None`` while that claim holds or held to its release """ return self._claim.compromise def _acquire(self) -> None: claim = self._claim self._stop_heartbeat() # no earlier claim's heartbeat outlives the acquisition of the next one # The published record reads the token, so it exists before the marker is written and goes back on failure. claim.token = token = secrets.token_hex(16) claim.compromise = None try: super()._acquire() except BaseException: claim.token = None raise # The context is thread-local by default, so the heartbeat thread cannot read the descriptor this one just # published, nor the claim this one owns. Hand it the fd, the inode it verified and the claim instead. if (fd := self._context.lock_file_fd) is not None and ( identity := self._context.lock_file_fd_identity ) is not None: self._start_heartbeat(claim, fd, identity, token) else: claim.token = None def _release(self) -> None: self._stop_heartbeat() self._claim.token = None super()._release() def _reset_after_fork_in_child(self) -> None: # pragma: forked child super()._reset_after_fork_in_child() # As in __init__: two child threads lazily creating a shared claim could each get one. if not self.is_thread_local(): self._context.lease_claim = _LeaseClaim() def _published_record(self) -> OwnerRecord: return super()._published_record()._replace(token=self._claim.token, lease_duration=self._lease_duration) def _try_break_stale_lock(self) -> None: # A break can fail for reasons a contender must ride out rather than raise on: a peer broke the marker first, # or Windows refuses to rename a file whose holder still has it open. Poll again instead. with suppress(OSError), pin_inode(self._lock_path) as pinned: self._try_break_pinned_lock(pinned) def _try_break_pinned_lock(self, pinned: int | None) -> None: if (peer := self._read_peer()) is None: # Not a readable protocol 2 lease record: a partial write, a foreign or legacy protocol 1 marker, or the # strict sentinel. The base self-heal evicts a genuinely malformed marker once it ages past the grace # window and leaves a legitimate legacy or strict holder in place, so a corrupt marker no longer wedges # every lease contender until its own timeout. super()._try_break_stale_lock() return owner, marker = peer if pinned not in {None, marker.st_ino}: # pragma: win32 no cover # Windows pins nothing return # replaced before the read, so nothing keeps the number read from reuse # Duration governs age-based expiry; a dead owner has no lease left to disagree with. if owner.mode != "unknown" and owner_is_stale(owner.pid, owner.hostname, owner.start): self._take_over(marker, owner.pid) return # Only a peer that published a lease agreed to be superseded by age; an exclusive owner is reclaimed only once # it is provably dead, and a contract this version does not know never is. Raise the mismatch outside the read # so the suppression cannot swallow it. if owner.mode == "lease" and owner.lease_duration != self._lease_duration: msg = ( f"{self.lock_file} holds a lease of {owner.lease_duration!r}s but this contender configured " f"{self._lease_duration!r}s; every contender for a path must agree on lease_duration" ) raise LeaseSettingsMismatch(msg) if owner.mode == "lease" and self._marker_unchanged_for(marker) >= self._lease_duration: self._take_over(marker, None) def _read_peer(self) -> tuple[OwnerRecord, os.stat_result] | None: with suppress(OSError, ValueError): content, marker = _read_lock_file(self._lock_path) if (owner := parse_marker(content)) is not None: return owner, marker return None def _start_heartbeat(self, claim: _LeaseClaim, fd: int, identity: tuple[int, int], token: str) -> None: # The thread watches the event it was handed rather than whatever the claim names later: a heartbeat that # outlives its join timeout would otherwise adopt the next acquisition's event and never stop. stop: Final = Event() loop: Final = _RefreshLoop( # A strong reference from a running thread would keep a dropped lease alive, so __del__ would never release # it and its marker would outlive the process. The callback stops a heartbeat that __del__ could not reach. report=WeakMethod(self._report_compromise, lambda _: stop.set()), lock_file=self._lock_path, claim=claim, fd=fd, identity=identity, token=token, stop=stop, interval=self._heartbeat_interval, duration=self._lease_duration, ) thread: Final = Thread(target=loop.run, name=f"filelock-lease-{os.getpid()}", daemon=True) # Record the heartbeat before starting the thread so a release racing this acquire on a shared, # non-thread-local claim always sees it and sets the stop event; the thread then exits at its first wait # instead of outliving the release. A start that raises leaves the unstarted thread for _stop_heartbeat. claim.heartbeat = _Heartbeat(thread, stop) thread.start() def _stop_heartbeat(self) -> None: claim = self._claim if (heartbeat := claim.heartbeat) is None: return heartbeat.stop.set() claim.heartbeat = None # An unstarted thread has nothing to join, on_compromise may release from the heartbeat thread itself, and # during finalization a daemon thread is frozen, so joining it raises PythonFinalizationError (3.14+). if heartbeat.thread.ident is not None and heartbeat.thread is not current_thread() and not sys.is_finalizing(): heartbeat.thread.join(timeout=self._heartbeat_interval) def _report_compromise( self, claim: _LeaseClaim, reason: CompromiseReason, error: OSError | None, token: str, ) -> None: # Record it on the claim this heartbeat serves, not on self._claim: a thread-local claim read from the # heartbeat thread is a different, empty one, so the holder would never see the loss it is being told about. # The token is the one this thread published, not claim.token, which a release may already have cleared. claim.compromise = LeaseCompromise(lock_file=self.lock_file, token=token, reason=reason, error=error) if self._on_compromise is not None: self._on_compromise(claim.compromise) @dataclass(frozen=True) class _RefreshLoop: """What a heartbeat thread needs to refresh one claim, holding the lease itself only weakly.""" report: WeakMethod[Callable[[_LeaseClaim, CompromiseReason, OSError | None, str], None]] lock_file: str claim: _LeaseClaim fd: int identity: tuple[int, int] token: str stop: Event interval: float duration: float def run(self) -> None: # A transient error (ESTALE / EIO on the NFS-style filesystems a lease targets) is not a loss. Like restic, call # the claim unrefreshable once failures run long enough that a contender could take it before the next success. last_success, missing = time.monotonic(), False while not self.stop.wait(self.interval): outcome, error = self._refresh() if outcome == "ok": last_success, missing = time.monotonic(), False continue # A peer's stale break exchanges a live marker aside for the length of its check, so only a second miss or # change in a row is a loss. if outcome in {"marker-missing", "owner-changed"} and not missing: missing = True continue if outcome == "transient" and time.monotonic() - last_success < self.duration - self.interval: continue if (report := self.report()) is not None: report(self.claim, "refresh-failed" if outcome == "transient" else outcome, error, self.token) return def _refresh(self) -> tuple[_RefreshOutcome, OSError | None]: try: st = os.lstat(self.lock_file) except FileNotFoundError as error: return self._refresh_unnamed(error) except OSError as error: return "transient", error # A peer that took the expired claim replaced the marker, so the pathname now names its inode, not ours. if (st.st_dev, st.st_ino) != self.identity: return "owner-changed", None return self._touch() def _refresh_unnamed(self, name_error: FileNotFoundError) -> tuple[_RefreshOutcome, OSError | None]: # A renamed directory takes the marker with it; the name is gone but the inode behind the descriptor keeps its # link. try: linked = os.fstat(self.fd).st_nlink > 0 except OSError as error: return "transient", error if not linked: return "marker-missing", name_error return self._touch() def _touch(self) -> tuple[_RefreshOutcome, OSError | None]: try: touch(self.lock_file, fd=self.fd) except OSError as error: return "transient", error return "ok", None __all__ = [ "CompromiseReason", "LeaseCompromise", "SoftFileLease", "_LeaseClaim", ] filelock-4.2.8/src/filelock/_marker.py0000644000000000000000000001733015272561277014636 0ustar00from __future__ import annotations __lazy_modules__ = ["contextlib", "filelock._identity", "filelock._util", "math"] import math import os import re from contextlib import suppress from typing import Final, Literal, NamedTuple from filelock._identity import host_name, owner_is_current_process, owner_is_stale, process_start_token from filelock._soft import SoftFileLock, _read_lock_file, parse_decimal from filelock._util import pin_inode, write_all #: Protocol 1 is the legacy ``\n\n[\n]`` marker that :class:`SoftFileLock` still writes. #: Protocol 2 carries the owner mode and the lease claim. A protocol 1 reader treats a protocol 2 marker as malformed #: and evicts it after its grace period, so the two never guarantee mutual exclusion against each other. _PROTOCOL: Final[str] = "filelock/2" _MAX_PID: Final[int] = 2**31 - 1 #: The forms ``repr`` gives a non-negative finite float or int; ``float()`` would also accept whitespace, underscores #: and non-ASCII digits. _DURATION: Final[re.Pattern[str]] = re.compile(r"[0-9]+(?:\.[0-9]+)?(?:e[+-][0-9]+)?", re.ASCII) #: Preserve unknown contracts so contenders cannot mistake them for malformed, reclaimable markers. OwnerMode = Literal["lease", "exclusive", "unknown"] class MarkerSoftFileLock(SoftFileLock): """An existence lock whose marker carries a protocol 2 owner record.""" #: Age-based expiry requires a lease contract; an exclusive holder grants none. _owner_mode: OwnerMode = "exclusive" @property def owner(self) -> OwnerRecord | None: """ The owner named by the marker on disk. :returns: the published record, or ``None`` when no marker exists or its record is malformed or protocol 1 """ return self._read_owner() @property def pid(self) -> int | None: """ The PID of the process holding this lock, read from the marker. :returns: the PID, or ``None`` when no marker exists or its record is unreadable """ return None if (owner := self._read_owner()) is None else owner.pid @property def is_lock_held_by_us(self) -> bool: """ Whether the marker on disk names this process. :returns: ``True`` when the marker names this process's PID, hostname and, when recorded, start token """ owner = self._read_owner() return owner is not None and owner_is_current_process(owner.pid, owner.hostname, owner.start) def force_break(self) -> None: """ Remove the marker whoever holds it, so a later contender can acquire. Forced breaking voids mutual exclusion: the previous holder keeps running and keeps using whatever the lock protects. Reserve it for an operator clearing a marker whose holder is known to be gone. """ self.break_lock() def _try_break_stale_lock(self) -> None: with suppress(OSError, ValueError), pin_inode(self._lock_path) as pinned: content, marker = _read_lock_file(self._lock_path) owner: Final[OwnerRecord | None] if pinned not in {None, marker.st_ino}: # pragma: win32 no cover # Windows pins nothing return # replaced before the read, so nothing keeps the number read from reuse if (owner := parse_marker(content)) is None: super()._try_break_stale_lock() elif owner.mode != "unknown" and owner_is_stale(owner.pid, owner.hostname, owner.start): self._take_over(marker, owner.pid) @classmethod def _recorded_holder(cls, content: str | None) -> tuple[int, str] | None: if (owner := parse_marker(content)) is not None: return owner.pid, owner.hostname return super()._recorded_holder(content) @classmethod def _marker_is_live(cls, content: str | None, breaker_pid: int) -> bool: # pragma: win32 no cover if (owner := parse_marker(content)) is None: return super()._marker_is_live(content, breaker_pid) return owner.mode == "unknown" or ( (owner.pid != breaker_pid or owner.hostname != host_name()) and not owner_is_stale(owner.pid, owner.hostname, owner.start) ) def _read_owner(self) -> OwnerRecord | None: with suppress(OSError, ValueError): return parse_marker(_read_lock_file(self._lock_path)[0]) return None def _write_lock_info(self, fd: int) -> None: write_all(fd, encode_marker(self._published_record())) def _published_record(self) -> OwnerRecord: return OwnerRecord( pid=os.getpid(), hostname=host_name(), mode=self._owner_mode, start=process_start_token(os.getpid()), ) class OwnerRecord(NamedTuple): """Keep optional metadata when reading unknown lock modes.""" pid: int hostname: str mode: OwnerMode token: str | None = None lease_duration: float | None = None start: int | None = None def encode_marker(record: OwnerRecord) -> bytes: """Render an owner record as the bytes a protocol 2 marker holds.""" lines = [_PROTOCOL, f"pid={record.pid}", f"host={record.hostname}", f"mode={record.mode}"] if record.token is not None: lines.append(f"token={record.token}") if record.lease_duration is not None: lines.append(f"duration={record.lease_duration!r}") if record.start is not None: lines.append(f"start={record.start}") return "".join(f"{line}\n" for line in lines).encode() def parse_marker(content: str | None) -> OwnerRecord | None: """Return the owner a protocol 2 marker names, or ``None`` when the record is malformed or protocol 1.""" # A torn write may end in a prefix like "mode=exclu", an unknown contract nobody reclaims; malformed, it ages out. if not content or content[-1] != "\n" or not (lines := content.strip().splitlines()) or lines[0] != _PROTOCOL: return None fields: dict[str, str] = {} for line in lines[1:]: key, separator, value = line.partition("=") if not separator: return None fields[key] = value return _build_record(fields) def _build_record(fields: dict[str, str]) -> OwnerRecord | None: # An unknown key is a field a newer filelock published, so ignore it rather than read the record as malformed. An # unrecognized mode is the same story one level up: a contract this version does not implement. Reading it as # malformed would age the marker out of a live owner's hands, so keep it and let the caller refuse to reclaim it. # A record naming no mode at all states no contract and stays malformed. if (published := fields.get("mode")) is None: return None mode: Final[OwnerMode] = published if published in {"lease", "exclusive"} else "unknown" hostname = fields.get("host") if not hostname or "pid" not in fields or ("duration" in fields and not _DURATION.fullmatch(fields["duration"])): return None try: pid = parse_decimal(fields["pid"]) duration = float(fields["duration"]) if "duration" in fields else None start = parse_decimal(fields["start"]) if "start" in fields else None except ValueError: return None if not 1 <= pid <= _MAX_PID: return None token = fields.get("token") # "1e400" passes the grammar but overflows to inf, which mismatches every configured duration and so wedges reclaim, # where a malformed marker ages out. if mode == "lease" and (not token or duration is None or not (math.isfinite(duration) and duration > 0)): return None return OwnerRecord(pid=pid, hostname=hostname, mode=mode, token=token, lease_duration=duration, start=start) __all__ = [ "MarkerSoftFileLock", "OwnerMode", "OwnerRecord", "encode_marker", "parse_marker", ] filelock-4.2.8/src/filelock/_read_write.py0000644000000000000000000020652115272561277015504 0ustar00from __future__ import annotations __lazy_modules__ = [ "collections", "filelock._error", "filelock._identity", "hashlib", "itertools", "pathlib", "secrets", "tempfile", ] import hashlib import logging import os import pathlib import secrets import sqlite3 import stat import sys import tempfile import threading import time from collections import deque from contextlib import contextmanager, suppress from dataclasses import dataclass, field from itertools import count from typing import TYPE_CHECKING, ClassVar, Final, Literal, NamedTuple, TypeAlias, cast from weakref import WeakValueDictionary from filelock._api import ( AcquireReturnProxy, _acquire_thread_lock, _canonical, _ensure_current_process, _fork_transition, _raise_chained_errors, _register_fork_class, _register_fork_object, _register_owned_descriptor, _resolve_lazy_imports_before_shutdown, _resolve_read_write_timeout, _seconds, _timeout_behavior, _unregister_owned_descriptor, ) from filelock._error import Timeout from filelock._identity import host_name, owner_is_stale, process_start_token if sys.platform == "win32": # pragma: win32 cover from filelock._windows import ( _directory_is_private, _identity_at, _identity_of_fd, _make_private_directory, _open_non_reparse_fd, _pinned, ) if TYPE_CHECKING: from collections.abc import Callable, Generator from typing import Self from _typeshed import Unused _LOGGER: Final[logging.Logger] = logging.getLogger("filelock") _GETPID: Final[Callable[[], int]] = os.getpid _IS_PYPY: Final[bool] = sys.implementation.name == "pypy" _NEEDS_CONNECTION_ESCROW: Final[bool] = ( hasattr(os, "register_at_fork") and sys.implementation.name == "cpython" and sys.version_info < (3, 12) ) _ConnectionParameter: TypeAlias = ( str | bytes | os.PathLike[str] | os.PathLike[bytes] | float | int | type[sqlite3.Connection] | None ) _DatabaseIdentity: TypeAlias = tuple[int, int] # sqlite3_busy_timeout() accepts a C int, max 2_147_483_647 on 32-bit. Use a lower value to be safe (~23 days). _MAX_SQLITE_TIMEOUT_MS: Final[int] = 2_000_000_000 - 1 # How long a wait that its caller may abandon runs before it checks whether it should stop. _CANCEL_POLL_INTERVAL: Final[float] = 0.1 _UNSAFE_FORK_EXIT_STATUS: Final[int] = 70 #: The longest a contended acquisition waits for the database inside one fork transition, in seconds. _MAX_TRANSITION_WAIT: Final[float] = 0.05 # O_NONBLOCK keeps an open from blocking on a FIFO planted at the path; the regular-file check then rejects it. _DB_OPEN_FLAGS: Final[int] = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) _DIRECTORY_FLAGS: Final[int] = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) # Header bytes 18 and 19 hold the file format versions, 2 in WAL mode: https://www.sqlite.org/fileformat.html _SQLITE_MAGIC: Final[bytes] = b"SQLite format 3\x00" _FORMAT_VERSIONS: Final[slice] = slice(18, 20) _HEADER_SIZE: Final[int] = 20 _WAL_FORMAT_VERSION: Final[int] = 2 class _SQLiteTransitionContext(threading.local): depth: int = 0 _SQLITE_TRANSITION_CONTEXT: Final = _SQLiteTransitionContext() class _ConnectionEscrow: def __init__(self) -> None: self._lock = threading.RLock() self._functions: tuple[Callable[[sqlite3.Connection], None], Callable[[sqlite3.Connection], None]] | None = None def functions( self, ) -> tuple[Callable[[sqlite3.Connection], None], Callable[[sqlite3.Connection], None]] | None: if not _NEEDS_CONNECTION_ESCROW: return None # pragma: >=3.12 cover with self._lock: # pragma: <3.12 cover # pragma: needs fork if self._functions is None: import ctypes # ruff:ignore[import-outside-top-level] # keep optional ctypes and its audited dlsym out of ordinary imports function_type = ctypes.PYFUNCTYPE(None, ctypes.py_object) increment_address = ctypes.cast(ctypes.pythonapi.Py_IncRef, ctypes.c_void_p).value decrement_address = ctypes.cast(ctypes.pythonapi.Py_DecRef, ctypes.c_void_p).value if increment_address is None or decrement_address is None: # pragma: no cover - resolved CPython API msg = "CPython reference functions have no address" raise RuntimeError(msg) self._functions = ( cast("Callable[[sqlite3.Connection], None]", function_type(increment_address)), cast("Callable[[sqlite3.Connection], None]", function_type(decrement_address)), ) return self._functions def _reset_after_fork_in_child(self) -> None: # pragma: forked child self._lock = threading.RLock() _CONNECTION_ESCROW: Final = _ConnectionEscrow() class _ForkedDatabaseRegistry: def __init__(self) -> None: self._lock = threading.RLock() self._paths: set[pathlib.Path] = set() self._identities: set[_DatabaseIdentity] = set() self._sqlite_used = False self._all_paths_poisoned = False def raise_if_poisoned(self, path: pathlib.Path) -> None: identity = self.identity(path) with self._lock: all_paths_poisoned = self._all_paths_poisoned poisoned = ( all_paths_poisoned or path in self._paths or (identity is not None and identity in self._identities) ) if poisoned: # pragma: needs fork msg = ( "ReadWriteLock is unavailable in a PyPy fork child; exec or exit before using it" if all_paths_poisoned else f"SQLite database {path!s} was active across fork(); exec or exit before using it in the child" ) raise RuntimeError(msg) def poison_after_fork(self, path: pathlib.Path, identity: _DatabaseIdentity | None) -> None: self._paths.add(path) if identity is not None: self._identities.add(identity) def note_sqlite_use(self) -> None: if _IS_PYPY: with self._lock: self._sqlite_used = True def _reset_after_fork_in_child(self) -> None: # pragma: forked child self._lock = threading.RLock() self._all_paths_poisoned = self._all_paths_poisoned or (_IS_PYPY and self._sqlite_used) self._sqlite_used = False @staticmethod def identity(path: pathlib.Path) -> _DatabaseIdentity | None: try: stat_result = path.stat() except OSError: return None return stat_result.st_dev, stat_result.st_ino _FORKED_DATABASES: Final = _ForkedDatabaseRegistry() _LINK_PREFIX: Final[str] = ".filelock-" _LINK_NAME: Final[str] = "lock.db" _SWEPT_DATABASES: Final[set[_DatabaseIdentity]] = set() # POSIX locks belong to the process and the inode, so closing any descriptor on the inode drops the locks every other # connection of this process holds there. SQLite defers its own closes for this reason (section 2.2 of # https://www.sqlite.org/howtocorrupt.html); the probes stay open until the last connection on their inode closes. class _ProbeDescriptors: def __init__(self) -> None: self._lock = threading.Lock() self._inodes: dict[_DatabaseIdentity, _InodeProbes] = {} self._releases: deque[_Probe] = deque() self._leases = count() def adopt(self, database: str, claimed: list[_Probe]) -> _Probe | None: # pragma: win32 no cover # Reuse a probe no live connection holds: each connect would otherwise keep another descriptor open until the # inode's last close. try: stat_result: Final = os.lstat(database) except OSError: return None identity: Final = stat_result.st_dev, stat_result.st_ino with self._lock: if not stat.S_ISREG(stat_result.st_mode) or not (probes := self._inodes.get(identity)) or not probes.idle: return None claimed.append(adopted := _Probe(identity, probes.idle.pop(), next(self._leases))) probes.leases.add(adopted.lease) return adopted def retain(self, fd: int, claimed: list[_Probe]) -> _Probe: # pragma: win32 no cover stat_result: Final = os.fstat(fd) identity: Final = stat_result.st_dev, stat_result.st_ino token: Final = _register_owned_descriptor(fd, identity) with self._lock: (probes := self._inodes.setdefault(identity, _InodeProbes())).descriptors.append((fd, token)) claimed.append(retained := _Probe(identity, fd, next(self._leases))) probes.leases.add(retained.lease) return retained def release(self, probe: _Probe) -> None: # pragma: win32 no cover # A finalizer may run this on a thread already inside the lock, so it never waits; the lock holder drains. self._releases.append(probe) self.drain() def drain(self) -> None: # pragma: win32 no cover while self._releases and self._lock.acquire(blocking=False): try: # Dequeued only once applied, so an exception leaves the rest of the release for the next drain. while self._releases: self._release_locked(self._releases[0]) self._releases.popleft() finally: self._lock.release() def _release_locked(self, probe: _Probe) -> None: # pragma: win32 no cover if (probes := self._inodes.get(probe.identity)) is None: return if probe.lease in probes.leases: probes.leases.remove(probe.lease) if probes.leases: probes.idle.append(probe.fd) if probes.leases: return # No connect may adopt a probe about to close, even one an interrupt leaves listed. probes.idle.clear() # Closing under the lock keeps a concurrent connect from taking a lock on this inode before the close. while probes.descriptors: fd, token = probes.descriptors[-1] if token is not None: # pragma: needs fork _unregister_owned_descriptor(token) # Forgotten before the close: after an interrupt, a leaked descriptor beats closing a reused number. probes.descriptors.pop() # The descriptor is gone even when close reports an error, and no lock of this process depends on it. with suppress(OSError): os.close(fd) del self._inodes[probe.identity] def _reset_after_fork_in_child(self) -> None: # pragma: forked child # The child holds no POSIX locks, and the owned-descriptor registry closes the inherited probes. self._lock = threading.Lock() self._inodes = {} self._releases = deque() class _Operation(NamedTuple): pid: int start_time: float #: Asked between bounded waits, so an abandoned acquire stops waiting; ``None`` waits in one call. cancel_check: Callable[[], bool] | None class _Probe(NamedTuple): identity: _DatabaseIdentity fd: int # One per connection, so a release applied again after an interrupt kept it queued counts once. lease: int @dataclass(slots=True) class _InodeProbes: leases: set[int] = field(default_factory=set) descriptors: list[tuple[int, int | None]] = field(default_factory=list) idle: list[int] = field(default_factory=list) _PROBE_DESCRIPTORS: Final = _ProbeDescriptors() class _ForkSafeConnection(sqlite3.Connection): _creator_pid: int _decrement_escrow: Callable[[sqlite3.Connection], None] | None _database_directory: _LinkDirectory | None database_identity: _DatabaseIdentity | None = None _probe: _Probe | None = None def __new__( cls, *_args: _ConnectionParameter, **_kwargs: _ConnectionParameter, ) -> Self: connection = super().__new__(cls) connection._creator_pid = _GETPID() connection._decrement_escrow = None connection._database_directory = None return connection def close(self) -> None: with _sqlite_transition(): if _GETPID() != self._creator_pid: # pragma: needs fork return with _fork_transition(): sqlite3.Connection.close(self) try: if (directory := self._database_directory) is not None: # pragma: needs posix-hard-link self._database_directory = None _remove_link_directory(directory) finally: if (probe := self._probe) is not None: # pragma: win32 no cover self._probe = self.database_identity = None _PROBE_DESCRIPTORS.release(probe) if (decrement := self._decrement_escrow) is not None: # pragma: <3.12 cover # pragma: needs fork self._decrement_escrow = None decrement(self) def retain_link(self, directory: _LinkDirectory | None) -> None: # pragma: win32 cover """Keep the private link directory, if there is one, until SQLite no longer uses it.""" self._database_directory = directory def retain_until_close(self, directory: _LinkDirectory | None, probe: _Probe) -> None: # pragma: win32 no cover """Keep the private link and this connection's probe descriptor until SQLite no longer uses them.""" self._database_directory = directory self._probe = probe self.database_identity = probe.identity def acquire_escrow( # pragma: <3.12 cover # pragma: needs fork self, functions: tuple[Callable[[sqlite3.Connection], None], Callable[[sqlite3.Connection], None]] | None, ) -> None: # The caller only reaches here holding the escrow functions; it skips the call entirely without them. if functions is not None: # pragma: no branch increment, decrement = functions increment(self) self._decrement_escrow = decrement def __del__(self) -> None: with suppress(sqlite3.Error, RuntimeError, OSError): self.close() @dataclass(slots=True) class _OwnedConnection: """ The connection of one lock, closed when the lock is collected. CPython runs ``__del__`` before it clears weak references. A finalizer on the lock itself would close the connection while the singleton cache still hands the lock out, and a thread given the lock would nest into a hold with no database lock behind it. The lock alone refers to this holder, so the holder is collected once the cache no longer finds the lock. """ connection: _ForkSafeConnection | None = None def __del__(self) -> None: if (connection := self.connection) is not None: with suppress(sqlite3.Error, RuntimeError): connection.close() class _ReadWriteLockMeta(type): """ Resolve singleton instances for ``is_singleton=True`` construction. This logic lives here rather than in ReadWriteLock.get_lock so ``ReadWriteLock(path)`` returns cached instances without a 2-arg ``super()`` call that type checkers cannot verify. """ _instances: WeakValueDictionary[pathlib.Path, ReadWriteLock] _instances_lock: threading.RLock _instances_pid: int _instances_under_construction: set[pathlib.Path] def __call__( cls, lock_file: str | os.PathLike[str], timeout: float = -1, *, blocking: bool = True, is_singleton: bool = True, ) -> ReadWriteLock: _ensure_current_process() _resolve_lazy_imports_before_shutdown() timeout = _seconds("timeout", timeout) if cls._instances_pid != _GETPID(): cls._reset_class_after_fork() construction_pid = _GETPID() if not is_singleton: instance = super().__call__(lock_file, timeout, blocking=blocking, is_singleton=is_singleton) if _GETPID() != construction_pid: # pragma: forked child msg = "ReadWriteLock construction cannot continue after fork" raise RuntimeError(msg) return instance # Resolve only the parent, so a symlink at the lock path stays its own key instead of aliasing its target. normalized = pathlib.Path(_canonical(lock_file)) with cls._instances_lock: # One lookup, since another thread can drop the last reference between a membership check and a read. if (instance := cls._instances.get(normalized)) is None: if normalized in cls._instances_under_construction: # pragma: no cover - exercised in an audit callback msg = f"Singleton lock construction is already active for {lock_file!s}" raise RuntimeError(msg) construction_registry = cls._instances_under_construction construction_registry.add(normalized) try: instance = super().__call__(lock_file, timeout, blocking=blocking, is_singleton=is_singleton) finally: if _GETPID() == construction_pid: construction_registry.discard(normalized) if _GETPID() != construction_pid: msg = "ReadWriteLock construction cannot continue after fork" raise RuntimeError(msg) cls._instances[normalized] = instance if ( _timeout_behavior(instance.timeout, any_negative=False) != _timeout_behavior(timeout, any_negative=False) or instance.blocking != blocking ): msg = ( f"Singleton lock created with timeout={instance.timeout}, blocking={instance.blocking}," f" cannot be changed to timeout={timeout}, blocking={blocking}" ) raise ValueError(msg) return instance def _reset_class_after_fork(cls) -> None: # pragma: forked child cls._instances = WeakValueDictionary() cls._instances_lock = threading.RLock() cls._instances_pid = _GETPID() cls._instances_under_construction = set() class ReadWriteLock(metaclass=_ReadWriteLockMeta): """ Cross-process read-write lock backed by SQLite. Allows concurrent shared readers or a single exclusive writer. The lock is reentrant within the same mode (multiple ``acquire_read`` calls nest, as do multiple ``acquire_write`` calls from the same thread), but upgrading from read to write or downgrading from write to read raises :class:`RuntimeError`. Write locks are pinned to the thread that acquired them, and each thread sharing a read lock can release only the levels it acquired. An instance coordinates processes, not threads: a thread asking for the opposite mode of another thread's hold raises :class:`RuntimeError` instead of waiting. Threads that must wait for each other each need their own instance (``is_singleton=False``). By default, ``is_singleton=True``: calling ``ReadWriteLock(path)`` with the same resolved path returns the same instance. A ``.db`` extension is a convention rather than a requirement; the filesystem must be one the active SQLite VFS supports. Give the lock a file of its own: a WAL-mode database, or one with a schema larger than a lock needs, raises :class:`ValueError`. A database directory that other accounts can write must also be writable by this one and support hard links; the protection against other accounts ends once one of them can replace the database directory or a directory above it. :param lock_file: path to the SQLite database file used as the lock :param timeout: maximum wait time in seconds; ``-1`` waits up to SQLite's busy-timeout cap of about 23 days :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately when the lock is unavailable :param is_singleton: if ``True``, reuse existing instances for the same resolved path .. versionadded:: 3.21.0 """ _instances: ClassVar[WeakValueDictionary[pathlib.Path, ReadWriteLock]] = WeakValueDictionary() _instances_lock = threading.RLock() _instances_pid = _GETPID() _instances_under_construction: ClassVar[set[pathlib.Path]] = set() def __init_subclass__(cls) -> None: super().__init_subclass__() cls._instances = WeakValueDictionary() cls._instances_lock = threading.RLock() cls._instances_pid = _GETPID() cls._instances_under_construction = set() _register_fork_class(cls) @classmethod def get_lock( cls, lock_file: str | os.PathLike[str], timeout: float = -1, *, blocking: bool = True ) -> ReadWriteLock: """ Return the singleton :class:`ReadWriteLock` for *lock_file*. :param lock_file: path to the SQLite database file used as the lock :param timeout: maximum wait time in seconds; ``-1`` waits up to SQLite's busy-timeout cap of about 23 days :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately when the lock is unavailable :returns: the singleton lock instance :raises ValueError: if an instance already exists for this path with different *timeout* or *blocking* values """ return cls(lock_file, timeout, blocking=blocking) def __init__( self, lock_file: str | os.PathLike[str], timeout: float = -1, *, blocking: bool = True, is_singleton: bool = True, # ruff:ignore[unused-method-argument] # consumed by _ReadWriteLockMeta.__call__ ) -> None: self.lock_file = os.fspath(lock_file) self._canonical_path = pathlib.Path(_canonical(lock_file)) _FORKED_DATABASES.raise_if_poisoned(self._canonical_path) self.timeout = timeout self.blocking = blocking self._transaction_lock = threading.Lock() # serializes the (possibly blocking) SQLite transaction work self._internal_lock = threading.Lock() # protects _lock_level / _current_mode updates and rollback self._lock_level = 0 self._current_mode: Literal["read", "write"] | None = None self._write_thread_id: int | None = None self._read_depths: dict[int, int] = {} self._acquisition_thread_ids: set[int] = set() self._owned_connection = _OwnedConnection() self._connection_transaction_released = True self._connection_identity: _DatabaseIdentity | None = None self._closed = False self._creator_pid = _GETPID() self._fork_invalidated = False _register_fork_object(self) with _fork_transition(), _sqlite_transition(): validation_connection = self._open_connection(sqlite_timeout=5.0) validation_connection.close() def acquire_read(self, timeout: float | None = None, *, blocking: bool | None = None) -> AcquireReturnProxy: """ Acquire a shared read lock. If this instance already holds a read lock, the lock level is incremented (reentrant). Attempting to acquire a read lock while holding a write lock raises :class:`RuntimeError` (downgrade not allowed). :param timeout: seconds to wait; ``None`` uses the instance setting; ``-1`` waits up to SQLite's busy-timeout cap of about 23 days :param blocking: if ``False``, raise :class:`~filelock.Timeout` on contention; ``None`` uses the instance setting :returns: a proxy that can be used as a context manager to release the lock :raises RuntimeError: if a write lock is already held on this instance :raises Timeout: if the lock cannot be acquired within *timeout* seconds :raises TypeError: if *timeout* is a :class:`bool` or not a real number :raises ValueError: if a blocking call gets a ``nan`` *timeout* or a negative one other than ``-1`` """ return self._acquire( "read", self.timeout if timeout is None else timeout, blocking=self.blocking if blocking is None else blocking, ) def acquire_write(self, timeout: float | None = None, *, blocking: bool | None = None) -> AcquireReturnProxy: """ Acquire an exclusive write lock. If this instance already holds a write lock from the same thread, the lock level is incremented (reentrant). Attempting to acquire a write lock while holding a read lock raises :class:`RuntimeError` (upgrade not allowed). Write locks are pinned to the acquiring thread: a different thread trying to re-enter also raises :class:`RuntimeError`. :param timeout: seconds to wait; ``None`` uses the instance setting; ``-1`` waits up to SQLite's busy-timeout cap of about 23 days :param blocking: if ``False``, raise :class:`~filelock.Timeout` on contention; ``None`` uses the instance setting :returns: a proxy that can be used as a context manager to release the lock :raises RuntimeError: if a read lock is already held, or a write lock is held by a different thread :raises Timeout: if the lock cannot be acquired within *timeout* seconds :raises TypeError: if *timeout* is a :class:`bool` or not a real number :raises ValueError: if a blocking call gets a ``nan`` *timeout* or a negative one other than ``-1`` """ return self._acquire( "write", self.timeout if timeout is None else timeout, blocking=self.blocking if blocking is None else blocking, ) @contextmanager def read_lock(self, timeout: float | None = None, *, blocking: bool | None = None) -> Generator[None]: """ Context manager that acquires and releases a shared read lock. Falls back to instance defaults for *timeout* and *blocking* when ``None``. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default """ self.acquire_read(timeout, blocking=blocking) try: yield finally: self.release() @contextmanager def write_lock(self, timeout: float | None = None, *, blocking: bool | None = None) -> Generator[None]: """ Context manager that acquires and releases an exclusive write lock. Falls back to instance defaults for *timeout* and *blocking* when ``None``. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default """ self.acquire_write(timeout, blocking=blocking) try: yield finally: self.release() def release(self, *, force: bool = False) -> None: """ Release one level of the current lock. When the lock level reaches zero the underlying SQLite transaction is rolled back, releasing the database lock. :param force: if ``True``, release the lock completely regardless of the current lock level :raises RuntimeError: if no lock is currently held and *force* is ``False``, or if the calling thread holds no level of the current lock and *force* is ``False`` """ with _fork_transition(): _ensure_current_process() if self._inherited: # pragma: needs fork return self._raise_if_acquiring("release") self._release(force=force, close=False) def close(self) -> None: """ Release the lock (if held) and close the underlying SQLite connection. After calling this method, the lock instance is no longer usable. Unlike :meth:`release`, it ends every thread's hold on this instance, whichever thread calls it. """ with _fork_transition(): _ensure_current_process() if self._inherited: # pragma: needs fork return self._raise_if_acquiring("close") self._release(force=True, close=True) def _acquire_level_of( self, holder: int, mode: Literal["read", "write"], timeout: float, *, blocking: bool, cancel_check: Callable[[], bool], ) -> AcquireReturnProxy: # Records the level under *holder* instead of the calling thread: an async wrapper's executor thread does not # identify the tasks it holds for. Gives up with Timeout once *cancel_check* is true. return self._acquire(mode, timeout, blocking=blocking, holder=holder, cancel_check=cancel_check) def _release_level_of(self, thread_id: int) -> None: with _fork_transition(): _ensure_current_process() self._raise_if_acquiring("release") self._release(force=False, close=False, thread_id=thread_id) def _holds_level_of(self, thread_id: int) -> bool: # Read without the internal lock. The async wrapper asks from its event loop, which must not wait out a release # on another thread. A stale answer is what a caller asking a moment earlier would have had. A level whose # transaction ended is one an interrupted release left behind; it counts as gone, as _holds_level() treats it. if thread_id != self._write_thread_id and thread_id not in self._read_depths: return False try: return not self._transaction_ended() except sqlite3.ProgrammingError: # The connection closed between the read of _con and the in_transaction call, so the transaction is over. return False def _release(self, *, force: bool, close: bool, thread_id: int | None = None) -> None: thread_id = threading.get_ident() if thread_id is None else thread_id with self._transaction_lock, self._internal_lock: if not force: self._raise_if_not_holder(thread_id) if self._lock_level == 0: if force and self._con is None: if close: self._closed = True return if not force: msg = f"Cannot release a lock on {self.lock_file} (lock id: {id(self)}) that is not held" raise RuntimeError(msg) if not force and self._lock_level > 1: self._leave_one_level(thread_id) return try: self._finish_connection() except BaseException: # Once the rollback ran, the level is gone whatever stopped the cleanup, a signal handler's exception # included. Kept, it would let the next acquire nest into a transaction that no longer exists. if self._transaction_ended(): self._clear_lock_state() raise self._clear_lock_state() if close: self._closed = True def _transaction_ended(self) -> bool: if self._connection_transaction_released or (connection := self._con) is None: return True # An interrupt can land between the rollback and the flag that records it. return not connection.in_transaction def _raise_if_not_holder(self, thread_id: int) -> None: # The connection runs with check_same_thread=False, so a release from another thread would end the owner's # transaction under it and let a second writer in. if self._current_mode == "write" and thread_id != self._write_thread_id: msg = ( f"Cannot release write lock on {self.lock_file} (lock id: {id(self)}) " f"from thread {thread_id} while it is held by thread {self._write_thread_id}" ) raise RuntimeError(msg) # Readers share that one transaction too, so a thread that holds no read level would end another's hold. if self._current_mode == "read" and thread_id not in self._read_depths: msg = ( f"Cannot release read lock on {self.lock_file} (lock id: {id(self)}) " f"from thread {thread_id}, which does not hold it" ) raise RuntimeError(msg) def _leave_one_level(self, thread_id: int) -> None: self._lock_level -= 1 if self._current_mode == "read" and (depth := self._read_depths.pop(thread_id) - 1): self._read_depths[thread_id] = depth def _clear_lock_state(self) -> None: self._lock_level = 0 self._current_mode = None self._write_thread_id = None self._read_depths = {} @property def _con(self) -> _ForkSafeConnection | None: return self._owned_connection.connection @_con.setter def _con(self, connection: _ForkSafeConnection | None) -> None: self._owned_connection.connection = connection def _reset_after_fork_in_child(self) -> None: # pragma: forked child if self._con is not None: _FORKED_DATABASES.poison_after_fork(self._canonical_path, self._connection_identity) self._con = None self._connection_transaction_released = True self._connection_identity = None self._transaction_lock = threading.Lock() self._internal_lock = threading.Lock() self._clear_lock_state() self._acquisition_thread_ids = set() self._fork_invalidated = True @property def _inherited(self) -> bool: return self._fork_invalidated or _GETPID() != self._creator_pid def _raise_if_unusable(self) -> None: _ensure_current_process() if self._inherited: # pragma: needs fork msg = f"ReadWriteLock on {self.lock_file} was invalidated by fork(); construct a new instance" raise RuntimeError(msg) if self._closed: msg = "Cannot operate on a closed database." raise sqlite3.ProgrammingError(msg) def _acquire( self, mode: Literal["read", "write"], timeout: float, *, blocking: bool, holder: int | None = None, cancel_check: Callable[[], bool] | None = None, ) -> AcquireReturnProxy: timeout = _resolve_read_write_timeout(timeout, blocking=blocking) start_time: Final = time.monotonic() remaining = timeout # -1 waits up to SQLite's busy-timeout cap while True: # A fork waits for the open transitions and admits no new one meanwhile. A wait for the database inside one # stalls the forking thread and, behind it, each lock call in the process. With no other thread to fork, # the wait runs in one piece, which keeps SQLite's writer preference. threading does not count a # thread started through _thread, so a fork from one of those still waits. final = ( not blocking or 0 <= remaining <= _MAX_TRANSITION_WAIT or not hasattr(os, "register_at_fork") or threading.active_count() == 1 ) try: return self._attempt_acquire( mode, remaining if final else _MAX_TRANSITION_WAIT, blocking=blocking, holder=holder, cancel_check=cancel_check, ) except Timeout: if final or (cancel_check is not None and cancel_check()): raise # Only a runtime with fork handlers waits in more than one attempt. if timeout != -1: # pragma: needs register-at-fork remaining = max(timeout - (time.monotonic() - start_time), 0) def _attempt_acquire( self, mode: Literal["read", "write"], timeout: float, *, blocking: bool, holder: int | None = None, cancel_check: Callable[[], bool] | None = None, ) -> AcquireReturnProxy: with _fork_transition(): self._raise_if_unusable() operation_pid = _GETPID() thread_id = threading.get_ident() holder = thread_id if holder is None else holder with self._internal_lock: if self._holds_level(): return self._validate_reentrant(mode, holder) if thread_id in self._acquisition_thread_ids: # pragma: no cover - exercised in an audit callback msg = f"Cannot acquire ReadWriteLock on {self.lock_file} while acquisition is active in this thread" raise RuntimeError(msg) self._acquisition_thread_ids.add(thread_id) try: start_time = time.perf_counter() self._acquire_transaction_lock(blocking=blocking, timeout=timeout, cancel_check=cancel_check) try: self._raise_if_unusable() return self._do_acquire_inner( mode, timeout, holder, blocking=blocking, operation=_Operation(operation_pid, start_time, cancel_check), ) finally: self._transaction_lock.release() finally: with self._internal_lock: self._acquisition_thread_ids.discard(thread_id) def _do_acquire_inner( self, mode: Literal["read", "write"], timeout: float, holder: int, *, blocking: bool, operation: _Operation, ) -> AcquireReturnProxy: operation_pid, start_time, _ = operation # Double-check: another thread may have acquired the lock while we waited on _transaction_lock. with self._internal_lock: if self._holds_level(): return self._validate_reentrant(mode, holder) if self._con is not None: self._finish_connection() try: self._open_for_acquisition( timeout, blocking=blocking, operation_pid=operation_pid, start_time=start_time, ) self._configure_and_begin(mode, timeout, blocking=blocking, operation=operation) self._raise_if_process_changed(operation_pid) except BaseException as error: acquisition_error: BaseException if isinstance(error, sqlite3.OperationalError) and "database is locked" in str(error): acquisition_error = Timeout(self.lock_file) else: acquisition_error = error try: self._finish_connection() except sqlite3.Error as cleanup_error: _raise_chained_errors(acquisition_error, cleanup_error) if acquisition_error is not error: raise acquisition_error from None raise with self._internal_lock: self._raise_if_process_changed(operation_pid) self._current_mode = mode self._lock_level = 1 if mode == "write": self._write_thread_id = holder else: self._read_depths = {holder: 1} return AcquireReturnProxy(lock=self) def _open_for_acquisition(self, timeout: float, *, blocking: bool, operation_pid: int, start_time: float) -> None: with _sqlite_transition(): sqlite_timeout = ( timeout_for_sqlite( timeout, blocking=blocking, already_waited=time.perf_counter() - start_time, ) / 1000 ) connection = self._open_connection(sqlite_timeout=sqlite_timeout) self._con, self._connection_transaction_released, self._connection_identity = ( connection, False, _FORKED_DATABASES.identity(self._canonical_path), ) self._raise_if_process_changed(operation_pid) def _configure_and_begin( self, mode: Literal["read", "write"], timeout: float, *, blocking: bool, operation: _Operation, ) -> None: with _sqlite_transition(): connection = cast("_ForkSafeConnection", self._con) # SQLite opened the file the name held at connect time, and its first statement would replay a journal # beside that file, so the name is checked before any statement runs. self._raise_if_database_replaced(connection) with _bounded_schema(connection, self.lock_file): self._raise_if_process_changed(operation.pid) # Use legacy journal mode (not WAL) because WAL does not block readers while a concurrent EXCLUSIVE # write transaction is active, which makes read-write locking impossible without modifying table data. # MEMORY is safe here since no writes happen, so a crash cannot corrupt the DB. # See https://sqlite.org/lang_transaction.html#deferred_immediate_and_exclusive_transactions self._execute_waiting( connection, "PRAGMA journal_mode=MEMORY;", timeout, blocking=blocking, operation=operation ) self._raise_if_process_changed(operation.pid) statements = "BEGIN EXCLUSIVE TRANSACTION;" if mode == "write" else "BEGIN TRANSACTION;" if mode == "read": # SQLite takes the SHARED lock only when a statement reads; BEGIN alone stays deferred. # https://www.sqlite.org/lockingv3.html#transaction_control # sqlite_schema is an alias added in SQLite 3.33.0; sqlite_master works on every version. statements += " SELECT name FROM sqlite_master LIMIT 1;" self._execute_waiting(connection, statements, timeout, blocking=blocking, operation=operation) self._raise_if_database_replaced(connection) @staticmethod def _execute_waiting( connection: _ForkSafeConnection, statements: str, timeout: float, *, blocking: bool, operation: _Operation ) -> None: # SQLite's busy wait cannot be interrupted. An acquire its caller may abandon waits in bounded slices and asks # between them whether to stop. slice_ms = int(_CANCEL_POLL_INTERVAL * 1000) while True: busy_ms = timeout_for_sqlite( timeout, blocking=blocking, already_waited=time.perf_counter() - operation.start_time ) cancel_check = operation.cancel_check if busy_ms > slice_ms else None try: connection.executescript( f"PRAGMA busy_timeout={busy_ms if cancel_check is None else slice_ms}; {statements}" ).close() except sqlite3.OperationalError as error: if cancel_check is None or "database is locked" not in str(error) or cancel_check(): raise # A read's BEGIN succeeds before its SELECT waits, so the retry has to start a fresh transaction. if connection.in_transaction: connection.rollback() else: return def _raise_if_database_replaced(self, connection: _ForkSafeConnection) -> None: # Windows pins the name: SQLite opens the database without delete sharing, so it cannot be unlinked or renamed. if (identity := connection.database_identity) is None: # pragma: win32 cover return try: # pragma: win32 no cover current = self._canonical_path.stat(follow_symlinks=False) except FileNotFoundError: # pragma: win32 no cover current = None # The lock lives on the inode, so one taken after the path moved on excludes no later opener. if current is None or (current.st_dev, current.st_ino) != identity: # pragma: win32 no cover msg = f"lock database {self.lock_file!r} was unlinked or replaced while acquiring; leave it in place" raise OSError(msg) def _open_connection(self, *, sqlite_timeout: float) -> _ForkSafeConnection: with _sqlite_transition(): creator_pid = _GETPID() functions = _CONNECTION_ESCROW.functions() if _GETPID() != creator_pid: # pragma: forked child msg = "SQLite connection construction cannot continue after fork" raise RuntimeError(msg) try: connection = _connect( os.fspath(self._canonical_path), factory=_ForkSafeConnection, timeout=sqlite_timeout, ) except BlockingIOError: raise Timeout(self.lock_file) from None if functions is not None: # pragma: <3.12 cover # pragma: needs fork connection.acquire_escrow(functions) if _GETPID() != creator_pid: # pragma: forked child _FORKED_DATABASES.poison_after_fork( self._canonical_path, _FORKED_DATABASES.identity(self._canonical_path), ) msg = "SQLite connection construction cannot continue after fork" raise RuntimeError(msg) return connection def _finish_connection(self) -> None: with _sqlite_transition(): if (connection := self._con) is None: return rollback_error: sqlite3.Error | None = None if not self._connection_transaction_released: if connection.in_transaction: try: connection.rollback() except sqlite3.Error as error: if connection.in_transaction: raise self._connection_transaction_released = True rollback_error = error else: self._connection_transaction_released = True else: self._connection_transaction_released = True try: connection.close() except sqlite3.Error as close_error: if rollback_error is not None: _raise_chained_errors(rollback_error, close_error) raise self._con = None self._connection_transaction_released = True self._connection_identity = None if rollback_error is not None: raise rollback_error def _holds_level(self) -> bool: # A release interrupted after its rollback, but before it recorded that, leaves a level with no transaction. if self._lock_level > 0 and self._transaction_ended(): self._clear_lock_state() return self._lock_level > 0 def _validate_reentrant(self, mode: Literal["read", "write"], cur: int) -> AcquireReturnProxy: if self._current_mode != mode: opposite = "write" if mode == "read" else "read" if cur == self._write_thread_id or cur in self._read_depths: direction = "downgrade" if mode == "read" else "upgrade" reason = f"already holding a {opposite} lock ({direction} not allowed)" else: reason = ( f"another thread holds a {opposite} lock on this instance, which coordinates processes rather " "than threads; give each thread its own instance with is_singleton=False" ) msg = f"Cannot acquire {mode} lock on {self.lock_file} (lock id: {id(self)}): {reason}" raise RuntimeError(msg) if mode == "write" and cur != self._write_thread_id: msg = ( f"Cannot acquire write lock on {self.lock_file} (lock id: {id(self)}) " f"from thread {cur} while it is held by thread {self._write_thread_id}" ) raise RuntimeError(msg) if mode == "read": self._read_depths[cur] = self._read_depths.get(cur, 0) + 1 self._lock_level += 1 return AcquireReturnProxy(lock=self) def _acquire_transaction_lock( self, *, blocking: bool, timeout: float, cancel_check: Callable[[], bool] | None ) -> None: if not _acquire_thread_lock( self._transaction_lock, blocking=blocking, timeout=timeout, poll_interval=_CANCEL_POLL_INTERVAL, cancel_check=cancel_check, ): raise Timeout(self.lock_file) from None def _raise_if_acquiring(self, operation: Literal["acquire", "close", "release"]) -> None: with self._internal_lock: active_in_current_thread = threading.get_ident() in self._acquisition_thread_ids if active_in_current_thread: # pragma: no cover - exercised in an audit callback msg = f"Cannot {operation} ReadWriteLock on {self.lock_file} while acquisition is active in this thread" raise RuntimeError(msg) def _raise_if_process_changed(self, operation_pid: int) -> None: if _GETPID() != operation_pid or self._inherited: # pragma: forked child msg = f"ReadWriteLock on {self.lock_file} was invalidated by fork(); construct a new instance" raise RuntimeError(msg) def _connect(database: str, *, factory: type[_ForkSafeConnection], timeout: float) -> _ForkSafeConnection: _FORKED_DATABASES.note_sqlite_use() # A symlink at the path would make SQLite open, lock, or create its target, so connect through a descriptor that # refuses one. if sys.platform == "win32": # pragma: win32 cover fd: Final = _open_lock_database(database, oplock_wait=timeout) link: _LinkDirectory | None = None # A Windows lock belongs to the handle that took it, so closing this one leaves SQLite's locks in place. try: _raise_if_wal(fd, database) target = database if not _directory_is_private(os.fspath(pathlib.Path(database).parent)): link = _link_beside_on_windows(database, _identity_of_fd(fd)) target = os.fspath(link.database) try: connection = sqlite3.connect( target, check_same_thread=False, factory=factory, cached_statements=0, timeout=timeout ) except BaseException: if link is not None: _remove_link_directory(link) raise connection.retain_link(link) return connection finally: os.close(fd) return _connect_through_descriptor( # pragma: win32 no cover database, factory=factory, timeout=timeout, owner=os.geteuid() ) def _connect_through_descriptor( # pragma: win32 no cover database: str, *, factory: type[_ForkSafeConnection], timeout: float, owner: int ) -> _ForkSafeConnection: # Listed before its lease is taken and cleared once a connection owns it, so any exception between, an interrupt # included, releases the lease instead of pinning the inode's probes. claimed: Final[list[_Probe]] = [] link: _LinkDirectory | None = None try: if (probe := _PROBE_DESCRIPTORS.adopt(database, claimed)) is None: probe = _PROBE_DESCRIPTORS.retain(_open_lock_database(database), claimed) identity, fd, _ = probe # Releases a finalizer queued while the table was locked. _PROBE_DESCRIPTORS.drain() _raise_if_wal(fd, database) target = database # SQLite opens the name it is given, following symlinks, and uses the -journal, -wal and -shm names beside it, # so the database name is safe only where no process of another account can create names. A descriptor path # such as /proc/self/fd/ is no better: SQLite reads it back into the name the kernel reports. if not _names_are_private(pathlib.Path(database).parent, owner): # pragma: needs posix-hard-link link = _link_beside(database, identity, owner) target = os.fspath(link.database) connection = sqlite3.connect( target, check_same_thread=False, factory=factory, cached_statements=0, timeout=timeout ) connection.retain_until_close(link, probe) claimed.clear() return connection finally: for unowned in claimed: _PROBE_DESCRIPTORS.release(unowned) if claimed and link is not None: # pragma: needs posix-hard-link # A connection that already took the link removes it too when it closes. _remove_link_directory(link) def _names_are_private(directory: pathlib.Path, owner: int) -> bool: # pragma: win32 no cover status: Final = os.lstat(directory) # The sticky bit still lets others create names, so a directory anyone else may write to never qualifies. return status.st_uid in {owner, 0} and not status.st_mode & (stat.S_IWGRP | stat.S_IWOTH) class _LinkDirectory(NamedTuple): # pragma: needs posix-hard-link path: pathlib.Path identity: _DatabaseIdentity # Made through a descriptor of path, which nobody else can list, so a directory another process swaps in under # path cannot hold this name and SQLite fails to open instead of following the swap. name: str @property def database(self) -> pathlib.Path: return self.path / self.name / _LINK_NAME def _link_beside( # pragma: needs posix-hard-link database: str, identity: _DatabaseIdentity, owner: int ) -> _LinkDirectory: # Beside the database because a link cannot cross filesystems, and TMPDIR is often on another one. parent: Final = pathlib.Path(database).parent _sweep_links_of_the_dead(parent, identity, owner) path: Final = pathlib.Path(tempfile.mkdtemp(prefix=_link_prefix(), dir=parent)) fd: Final = os.open(path, _DIRECTORY_FLAGS) try: made: Final = os.fstat(fd) # Whoever can rename in the parent can put another directory under the new name before it opens. loose: Final = made.st_mode & 0o077 if made.st_uid != owner or (loose and os.listdir(fd)): # ruff:ignore[os-listdir] # pathlib cannot list a descriptor msg = f"link directory {os.fspath(path)!r} was replaced while opening" raise OSError(msg) # An inherited ACL, such as an NFSv4 one on ZFS, overrides the mode mkdtemp asks for. Only an empty directory # is tightened, so one swapped in with contents is still refused above. if loose: msg = ( f"link directory {os.fspath(path)!r} stays open to other accounts through an inherited ACL" f" that chmod 0700 cannot close; make {os.fspath(parent)!r} writable by its owner only" ) try: os.fchmod(fd, 0o700) except PermissionError as exc: # ZFS with aclmode=restricted refuses a chmod on a non-trivial ACL raise OSError(msg) from exc if os.fstat(fd).st_mode & 0o077: raise OSError(msg) link: Final = _LinkDirectory(path, (made.st_dev, made.st_ino), secrets.token_hex(8)) try: os.mkdir(link.name, 0o700, dir_fd=fd) os.link(database, link.database, follow_symlinks=False) linked: Final = link.database.stat(follow_symlinks=False) except BaseException: _remove_link_directory(link) raise finally: os.close(fd) if (linked.st_dev, linked.st_ino) != identity: _remove_link_directory(link) msg = f"lock database changed while opening: {database!r}" raise OSError(msg) return link def _sweep_links_of_the_dead( parent: pathlib.Path, identity: _DatabaseIdentity, owner: int ) -> None: # pragma: needs posix-hard-link # A process killed while it held a link leaves the directory, and an extra link to the database, behind for good. # Two threads sweeping at once only both try the same removals, which a lock would cost a fork-safety reset for. if identity in _SWEPT_DATABASES: return _SWEPT_DATABASES.add(identity) try: with os.scandir(parent) as entries: names: Final = [ entry.name for entry in entries if entry.name.startswith(_LINK_PREFIX) and entry.is_dir(follow_symlinks=False) ] except OSError: return for name in names: # A peer sweeping alongside, or a directory this user cannot remove, must not fail the connect. with suppress(OSError): _remove_dead_links(parent / name, identity, owner) def _remove_dead_links( path: pathlib.Path, identity: _DatabaseIdentity, owner: int ) -> None: # pragma: needs posix-hard-link if sys.platform == "win32": # pragma: win32 cover _remove_dead_links_on_windows(path, identity) return # Any directory can carry a dead holder's name after a rename in the parent, so only hard links to this database # go, and the directory only once that leaves it empty. fd: Final = os.open(path, _DIRECTORY_FLAGS) try: # Only a directory this account made carries a holder of its own; a name another account chose is never # parsed, so no PID is probed on its behalf. if os.fstat(fd).st_uid != owner or not _owner_is_gone(path.name): return with os.scandir(fd) as entries: names: Final = [entry.name for entry in entries] for entry in names: # Releases up to 4.2.0 linked the database right inside the directory. if entry == _LINK_NAME: _unlink_link_to(identity, fd) continue try: nested = os.open(entry, _DIRECTORY_FLAGS, dir_fd=fd) except OSError: continue try: _unlink_link_to(identity, nested) finally: os.close(nested) with suppress(OSError): os.rmdir(entry, dir_fd=fd) finally: os.close(fd) with suppress(OSError): path.rmdir() def _unlink_link_to(identity: _DatabaseIdentity, dir_fd: int) -> None: # pragma: needs posix-hard-link try: linked: Final = os.stat(_LINK_NAME, dir_fd=dir_fd, follow_symlinks=False) except OSError: return if (linked.st_dev, linked.st_ino) == identity: os.unlink(_LINK_NAME, dir_fd=dir_fd) def _owner_is_gone(name: str) -> bool: # pragma: needs posix-hard-link match name.removeprefix(_LINK_PREFIX).split("-"): case [pid, start, digest, _] if ( pid.isascii() and pid.isdigit() and (not start or (start.isascii() and start.isdigit())) ): if not 1 <= (owner_pid := int(pid)) <= 2**31 - 1: return False host: Final = host_name() return digest == _host_digest(host) and owner_is_stale(owner_pid, host, int(start) if start else None) case _: return False def _link_prefix() -> str: # pragma: needs posix-hard-link # The owner rides in the name, which mkdtemp creates in one step, so no directory ever exists without it. start: Final = process_start_token(pid := os.getpid()) return f"{_LINK_PREFIX}{pid}-{'' if start is None else start}-{_host_digest(host_name())}-" def _host_digest(host: str) -> str: # pragma: needs posix-hard-link # SHA-256 as host_name does: some runtimes, GraalPy's among them, have no BLAKE2 with a custom digest size. return hashlib.sha256(host.encode()).hexdigest()[:16] def _remove_link_directory(link: _LinkDirectory) -> None: # pragma: needs posix-hard-link if sys.platform == "win32": # pragma: win32 cover _remove_link_directory_on_windows(link) return # A rename in the parent may have put any directory under this name, so entries go through a descriptor checked # to be the directory made, and the name goes only through rmdir, which refuses a non-empty directory. try: fd: Final = os.open(link.path, _DIRECTORY_FLAGS) except OSError: return try: opened: Final = os.fstat(fd) if (opened.st_dev, opened.st_ino) != link.identity: return for suffix in ("", "-journal", "-wal", "-shm"): with suppress(FileNotFoundError): os.unlink(f"{link.name}/{_LINK_NAME}{suffix}", dir_fd=fd) with suppress(FileNotFoundError): os.rmdir(link.name, dir_fd=fd) finally: os.close(fd) # The link is gone, so a failure here leaves one stray empty directory and is not worth failing a release for. with suppress(OSError): link.path.rmdir() if sys.platform == "win32": # pragma: win32 cover def _link_beside_on_windows(database: str, identity: _DatabaseIdentity) -> _LinkDirectory: parent: Final = pathlib.Path(database).parent # No uid exists here: a directory is a dead holder's only if its ACL admits nobody but the trusted accounts. _sweep_links_of_the_dead(parent, identity, 0) path: Final = parent / f"{_link_prefix()}{secrets.token_hex(8)}" _make_private_directory(os.fspath(path)) link: _LinkDirectory | None = None try: # The handle keeps a rename in the parent from swapping the directory while the link is made inside it. with _pinned(os.fspath(path)) as directory_identity: link = _LinkDirectory(path, directory_identity, secrets.token_hex(8)) _fill_link_directory(link, database, identity) except BaseException: if link is not None: # pragma: no branch # a pinned directory always gets its link _remove_link_directory(link) raise return link def _fill_link_directory(link: _LinkDirectory, database: str, identity: _DatabaseIdentity) -> None: if not _directory_is_private(os.fspath(link.path)): msg = f"link directory {os.fspath(link.path)!r} was replaced while opening" raise OSError(msg) _make_private_directory(os.fspath(link.path / link.name)) os.link(database, link.database) # The open database handle pins its path against a rename, so only a file index that is not unique can differ. if _identity_at(os.fspath(link.database)) != identity: # pragma: no cover msg = f"lock database changed while opening: {database!r}" raise OSError(msg) def _remove_dead_links_on_windows(path: pathlib.Path, identity: _DatabaseIdentity) -> None: with _pinned(os.fspath(path)): if not _directory_is_private(os.fspath(path)) or not _owner_is_gone(path.name): return with os.scandir(path) as entries: nested: Final = [pathlib.Path(entry.path) for entry in entries] for directory in nested: _unlink_link_to_on_windows(identity, directory / _LINK_NAME) with suppress(OSError): directory.rmdir() with suppress(OSError): path.rmdir() def _unlink_link_to_on_windows(identity: _DatabaseIdentity, path: pathlib.Path) -> None: with suppress(OSError): if _identity_at(os.fspath(path)) == identity: path.unlink() def _remove_link_directory_on_windows(link: _LinkDirectory) -> None: # A removal never fails a release: SQLite or a scanner may still hold a name, and the next sweep retries. with suppress(OSError): with _pinned(os.fspath(link.path)) as directory_identity: if directory_identity != link.identity: return for suffix in ("", "-journal", "-wal", "-shm"): with suppress(FileNotFoundError): pathlib.Path(f"{link.database}{suffix}").unlink() with suppress(FileNotFoundError): pathlib.Path(link.database.parent).rmdir() link.path.rmdir() def _open_lock_database(database: str, *, oplock_wait: float = 0) -> int: if sys.platform == "win32": # pragma: win32 cover # The handle refuses a reparse point and omits delete sharing, so the name cannot be swapped while it is open. if (fd := _open_non_reparse_fd(database, 0o600, oplock_wait=oplock_wait)) is None: msg = f"lock database {database!r} is held without sharing or pending deletion" raise PermissionError(msg) return fd fd = os.open(database, _DB_OPEN_FLAGS, 0o600) # pragma: win32 no cover if not stat.S_ISREG(os.fstat(fd).st_mode): # pragma: win32 no cover os.close(fd) msg = f"refusing a non-regular lock database: {database!r}" raise OSError(msg) return fd # pragma: win32 no cover def _raise_if_wal(fd: int, database: str) -> None: # SQLite derives the -wal name from the path it connects through, a descriptor name on POSIX, and WAL never blocks # readers behind a writer, so the lock cannot work on such a database. # pread: an adopted probe is shared, so its file offset is not this connect's to move. header: Final = os.pread(fd, _HEADER_SIZE, 0) if hasattr(os, "pread") else os.read(fd, _HEADER_SIZE) if header.startswith(_SQLITE_MAGIC) and _WAL_FORMAT_VERSION in header[_FORMAT_VERSIONS]: msg = f"refusing a WAL-mode lock database: {database!r}; give ReadWriteLock a file of its own" raise ValueError(msg) @contextmanager def _bounded_schema(connection: sqlite3.Connection, database: str) -> Generator[None]: # The first statement loads the file's complete schema, which no timeout bounds, and a lock database has none. # Waiting for a lock runs no virtual machine steps, so a step budget bounds only that load. calls: Final = count() max_calls: Final = 10 connection.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, 4096) connection.set_progress_handler(lambda: next(calls) >= max_calls, 1000) try: yield except sqlite3.DatabaseError as error: # An interrupted schema load reports a malformed schema rather than the interrupt. if isinstance(error, sqlite3.DataError) or next(calls) > max_calls: msg = ( f"refusing a lock database whose schema is too large to load: {database!r}; " "give ReadWriteLock a file of its own" ) raise ValueError(msg) from error raise finally: connection.set_progress_handler(None, 0) @contextmanager def _sqlite_transition() -> Generator[None]: _SQLITE_TRANSITION_CONTEXT.depth += 1 try: yield finally: _SQLITE_TRANSITION_CONTEXT.depth -= 1 def _abort_forked_sqlite_transition() -> None: # pragma: forked child if _SQLITE_TRANSITION_CONTEXT.depth: os._exit(_UNSAFE_FORK_EXIT_STATUS) # inherited SQLite handles cannot be used or closed safely def _track_sqlite_use(event: str, _args: Unused) -> None: if event == "sqlite3.connect": _FORKED_DATABASES.note_sqlite_use() def timeout_for_sqlite(timeout: float, *, blocking: bool, already_waited: float) -> int: if blocking is False: return 0 if timeout == -1: return _MAX_SQLITE_TIMEOUT_MS if timeout < 0: msg = "timeout must be a non-negative number or -1" raise ValueError(msg) timeout_ms = int((max(timeout - already_waited, 0) if timeout > 0 else timeout) * 1000) if timeout_ms > _MAX_SQLITE_TIMEOUT_MS or timeout_ms < 0: _LOGGER.warning("timeout %s is too large for SQLite, using %s ms instead", timeout, _MAX_SQLITE_TIMEOUT_MS) return _MAX_SQLITE_TIMEOUT_MS return timeout_ms _register_fork_object(_CONNECTION_ESCROW) _register_fork_object(_FORKED_DATABASES) _register_fork_object(_PROBE_DESCRIPTORS) _register_fork_class(ReadWriteLock) if _IS_PYPY: sys.addaudithook(_track_sqlite_use) # pragma: pypy cover if hasattr(os, "register_at_fork"): # pragma: needs fork os.register_at_fork(after_in_child=_abort_forked_sqlite_transition) __all__ = [ "ReadWriteLock", "timeout_for_sqlite", ] filelock-4.2.8/src/filelock/_soft.py0000644000000000000000000010321615272561277014327 0ustar00from __future__ import annotations __lazy_modules__ = [ "contextlib", "filelock._identity", "filelock._soft_protocol", "filelock._util", "itertools", "pathlib", "re", "secrets", "typing", ] import os import re import secrets import stat import sys import threading import time from contextlib import suppress from errno import EACCES, EEXIST, EPERM from itertools import islice from pathlib import Path from typing import Final from filelock._api import BaseFileLock, _raise_grouped_errors from filelock._identity import host_name, owner_is_current_process, owner_is_stale, process_start_token from filelock._soft_protocol import STRICT_SOFT_SENTINEL_RECORD from filelock._util import ( break_lock_file, exchange_back, exchange_names, exchange_supported, pin_inode, raise_on_not_writable_file, remove_own_file, write_all, ) if sys.platform == "win32": # pragma: win32 cover from filelock._windows import open_read_without_oplock_wait from filelock._windows import unlink_without_oplock_wait as unlink_path else: # pragma: win32 no cover from filelock._util import unlink_path MALFORMED_LOCK_AGE_THRESHOLD: Final[float] = 2.0 _MAX_LOCK_FILE_SIZE: Final[int] = 1024 _UNLINK_MAX_RETRIES: Final[int] = 10 _MARKER_WITH_START_TOKEN_LINE_COUNT: Final[int] = 3 #: Contenders act on the record in the marker, so no other account may write it, whatever the mode or umask. _OTHERS_WRITE: Final[int] = stat.S_IWGRP | stat.S_IWOTH #: All a SoftFileLock before 3.22 writes, and what a current one shows between creating its marker and writing it. _RECORDLESS_MARKER: Final[str] = "" _CLAIMS_IN_PROGRESS: Final[set[str]] = set() _CLAIMS_LOCK: Final[threading.Lock] = threading.Lock() class SoftFileLock(BaseFileLock): """ Cooperative file lock based on a shared existence marker. Unlike :class:`UnixFileLock ` and :class:`WindowsFileLock `, this lock does not use OS-level locking primitives. Instead, it creates the lock file with ``O_CREAT | O_EXCL`` and treats its existence as the lock indicator. The filesystem must provide coherent exclusive creation and directory updates to each participating process. A crash can leave the marker behind. The marker contains the holder's PID and hostname. A contender may remove it when it can no longer find a same-host process with that PID. A configured :attr:`~filelock.BaseFileLock.lifetime` also permits removal based on marker age, including while the holder remains alive. Age-based expiry can overlap protected operations and does not provide strict mutual exclusion. """ #: Existence locks reclaim by unlinking a pathname, so an age-based lease may break one; a native inode lock cannot. _lifetime_supported: bool = True #: Age-based expiry preserves historical behavior but does not provide strict mutual exclusion. _lifetime_replacements: tuple[str, str] | None = ("StrictSoftFileLock", "SoftFileLease") #: An existence lock unlinks its marker to release, so it cannot promise to keep the pathname. _preserve_lock_file_supported: bool = False #: An existence lock keeps protocol state in its marker, so it cannot lend the descriptor to an on_acquired hook. _on_acquired_supported: bool = False #: The marker's (mtime, inode, ctime) when this instance last saw it change, and when, on its monotonic clock. _marker_seen: tuple[tuple[float, int, int], float] | None = None #: The identity of a marker a Windows release relinquished but has not unlinked yet. _marker_cleanup: tuple[int, int] | None = None #: The descriptor of that marker while its close has not run yet, as a list so one C call can take it. _marker_fds: list[int] def _acquire(self) -> None: self._finish_marker_cleanup() raise_on_not_writable_file(self._lock_path) # O_CREAT | O_EXCL makes the create fail with EEXIST when the file already exists, so a successful open # means this process now holds the lock. flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_TRUNC if (o_nofollow := getattr(os, "O_NOFOLLOW", None)) is not None: # pragma: needs o-nofollow flags |= o_nofollow try: fd = os.open(self._lock_path, flags, self._open_mode() & ~_OTHERS_WRITE) except OSError as exception: if not ( exception.errno == EEXIST or (exception.errno == EACCES and sys.platform == "win32") ): # pragma: win32 no cover raise self._try_break_stale_lock() return # Publish held state only once the record is on disk. On failure, cancellation included, close the descriptor # and unlink the path only while it still names the file we opened: after an expired lease a successor's # marker may sit at the same path. The guard starts at the open, so a signal handler that raises before # _mark_descriptor_owned leaves no marker behind. identity: tuple[int, int] | None = None try: identity = _file_identity(os.fstat(fd)) self._mark_descriptor_pending(fd) self._apply_explicit_mode(fd) self._write_lock_info(fd) self._mark_descriptor_owned(fd, identity) except BaseException: self._mark_descriptor_released() try: if identity is None: # The first read failed, as a network mount reporting EIO or an interrupt landing mid-call does, and # this attempt still owns the descriptor it created the marker through, so a retry usually answers. # Without the identity the Windows unlink below cannot recognize the marker, and the empty marker it # leaves blocks the path for good, since no contender breaks one. with suppress(OSError): identity = _file_identity(os.fstat(fd)) if sys.platform != "win32": # pragma: win32 no cover # Before the close: our open descriptor keeps the inode allocated, so a successor's marker cannot # carry its number. with suppress(OSError): remove_own_file(self._lock_path, fd) finally: try: os.close(fd) finally: if sys.platform == "win32": # pragma: win32 cover self._unlink_held_marker(identity) raise def _apply_explicit_mode(self, fd: int) -> None: # os.open filters the mode through the umask, so an explicit mode lands as given only through fchmod, the way # UnixFileLock applies it. Windows maps the mode to a read-only bit that os.open already set. if self.has_explicit_mode and sys.platform != "win32": # pragma: win32 no cover with suppress(PermissionError): os.fchmod(fd, self._context.mode & ~_OTHERS_WRITE) def _try_break_stale_lock(self) -> None: with suppress(OSError, ValueError), pin_inode(self._lock_path) as pinned: content, marker = _read_lock_file(self._lock_path) if pinned not in {None, marker.st_ino}: # pragma: win32 no cover # Windows pins nothing return # another node replaced the pinned one before the read, and nothing keeps its number from reuse if content == STRICT_SOFT_SENTINEL_RECORD: # pragma: needs hard-link return # Such a marker is held for as long as its owner runs, so being unchanged proves nobody gone. if content == _RECORDLESS_MARKER: return holder = _parse_lock_holder(content) if holder is None: # Unparsable: wrong line count, a non-integer PID or start token, empty, oversized or not UTF-8. # Self-heal only once the file is clearly not a half-written fresh lock (a peer between O_EXCL and # _write_lock_info), so the brief create-then-write window is never mistaken for a stale lock. if self._marker_unchanged_for(marker) >= MALFORMED_LOCK_AGE_THRESHOLD: self._take_over(marker, _placeholder_breaker(content)) return if owner_is_stale(*holder): self._take_over(marker, holder[0]) def _try_break_expired_lock(self) -> None: if (lifetime := self._context.lifetime) is None: return with suppress(OSError), pin_inode(self._lock_path) as pinned: # _read_lock_file takes the lstat, not stat: a symlink swapped in at the path must report its own mtime. content, marker = _read_lock_file(self._lock_path) if pinned not in {None, marker.st_ino} or time.time() - marker.st_mtime < lifetime: return holder = self._recorded_holder(content) self._take_over(marker, None if holder is None else holder[0]) def _take_over(self, marker: os.stat_result, breaker_pid: int | None) -> None: # Swap the judged marker for a complete marker of ours in one exchange, so the lock path never stands empty. # Once the file swapped out proves to be the one judged, this lock holds the path; the caller's pin keeps that # file's inode number from naming anything else. Only a peer's exchange can move our marker after that, and # the peer puts it back because what it took is not the file it judged. Every failure before the handoff takes # our marker off the path and puts back what the exchange took. Without an exchange, the portable break decides. if exchange_supported(): # pragma: win32 no cover self._take_over_by_exchange(marker, breaker_pid) else: break_lock_file(self._lock_path, marker.st_mtime, marker.st_ino) def _take_over_by_exchange(self, marker: os.stat_result, breaker_pid: int | None) -> None: # pragma: win32 no cover if (current := os.lstat(self._lock_path)).st_mtime != marker.st_mtime or current.st_ino != marker.st_ino: return if breaker_pid is not None and self._restore_stranded_marker(breaker_pid, marker): return aside: Final = f"{self._lock_path}.break.{os.getpid()}.{secrets.token_hex(16)}" flags: Final = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) fd: Final = os.open(aside, flags, self._open_mode() & ~_OTHERS_WRITE) self._mark_descriptor_pending(fd) exchanged = held = False try: identity: Final = _file_identity(os.fstat(fd)) self._apply_explicit_mode(fd) self._write_lock_info(fd) # The scan and the write take long enough for a peer to have taken the marker since the check above, and an # exchange now would move that peer's live marker aside. if (current := os.lstat(self._lock_path)).st_mtime != marker.st_mtime or current.st_ino != marker.st_ino: return if not exchange_names(aside, self._lock_path): return # NFS, SMB and the like refuse it; renaming the marker aside would empty the path exchanged = True if (taken := os.lstat(aside)).st_mtime != marker.st_mtime or taken.st_ino != marker.st_ino: return # a peer recreated the marker, which the cleanup below swaps back Path(aside).unlink() exchanged = False self._mark_descriptor_owned(fd, identity) held = True finally: if not held: self._abandon_take_over(fd, aside, put_back=exchanged) def _restore_stranded_marker(self, breaker_pid: int, marker: os.stat_result) -> bool: # pragma: win32 no cover # A breaker killed between its exchange and the put-back leaves its own marker at the path and the live # holder's marker under its break name. A recoverer first renames that record to a name of its own. Only one # contender wins the rename, so two of them never exchange the same record and reverse each other. A record # a dead recoverer holds is claimable again. A claim of this process that no thread is working on was # abandoned by a failed put-back, and the next poll takes it up again. Where the marker cannot be put back # the lock counts as held, as it does wherever no exchange is possible. if (candidates := self._stranded_candidates(breaker_pid)) is None: return True return any(self._recover_claimed(path, breaker_pid, marker, adopt=abandoned) for path, abandoned in candidates) def _stranded_candidates(self, breaker_pid: int) -> list[tuple[str, bool]] | None: # pragma: win32 no cover directory, name = os.path.split(self._lock_path) stranded: Final = re.compile(rf"{re.escape(name)}\.break\.{breaker_pid}\.[0-9a-f]{{32}}") # [0-9], not \d: a str pattern's \d takes any Unicode digit, and int() reads fullwidth ones as another number. claimed: Final = re.compile(rf"{re.escape(name)}\.restore\.([0-9]+)\.[0-9a-f]{{32}}") candidates: list[tuple[str, bool]] = [] with os.scandir(directory or os.curdir) as entries: for entry in entries: if not entry.is_file(follow_symlinks=False): continue path = str(Path(directory) / entry.name) if stranded.fullmatch(entry.name): candidates.append((path, False)) elif (found := claimed.fullmatch(entry.name)) and 1 <= (recoverer := int(found[1])) <= 2**31 - 1: # No filelock writes a claim outside the pid range; os.kill would raise OverflowError on one. ours = recoverer == os.getpid() if not (ours or owner_is_stale(recoverer, host_name(), None)): return None candidates.append((path, ours)) return sorted(candidates) def _recover_claimed( self, path: str, breaker_pid: int, marker: os.stat_result, *, adopt: bool ) -> bool: # pragma: win32 no cover # One try/finally owns the registration from the first step to the last, so no interruption can leave it set. # Registered before the rename publishes the name, so a thread that scans meanwhile sees a claim in progress. own: Final = path if adopt else f"{self._lock_path}.restore.{os.getpid()}.{secrets.token_hex(16)}" if not _begin_claim(own): return True try: if not adopt: try: Path(path).rename(own) except OSError: # a peer that won the rename now holds the record, so the lock counts as held return True return self._restore_claimed(own, breaker_pid, marker) finally: _end_claim(own) def _restore_claimed(self, own: str, breaker_pid: int, marker: os.stat_result) -> bool: # pragma: win32 no cover try: content, _ = _read_lock_file(own) except OSError: self._return_claim(own, breaker_pid) return True if not self._marker_is_live(content, breaker_pid): with suppress(OSError): Path(own).unlink() return False # The inode of the marker judged dead must still be at the path, or a peer has acted since. with suppress(OSError): current = os.lstat(self._lock_path) if (current.st_ino, current.st_mtime) == (marker.st_ino, marker.st_mtime) and exchange_names( own, self._lock_path ): if os.lstat(own).st_ino == marker.st_ino: Path(own).unlink() return True # A peer replaced the path after the check, so the exchange took its marker: swap it straight back. exchange_names(own, self._lock_path) self._return_claim(own, breaker_pid) return True def _return_claim(self, own: str, breaker_pid: int) -> None: # pragma: win32 no cover # A refused rename leaves the claim under this process's PID, and the next poll retries it. with suppress(OSError): Path(own).rename(f"{self._lock_path}.break.{breaker_pid}.{secrets.token_hex(16)}") @classmethod def _marker_is_live(cls, content: str | None, breaker_pid: int) -> bool: # pragma: win32 no cover if content == STRICT_SOFT_SENTINEL_RECORD: # pragma: needs hard-link return True if content == _RECORDLESS_MARKER: # An empty marker is a pre-3.22 holder for as long as it exists, as the stale check treats it. return True # The breaker's own marker names its PID on this host; the same number on another host is a holder. return ( (holder := _parse_lock_holder(content)) is not None and (holder[0] != breaker_pid or holder[1] != host_name()) and not owner_is_stale(*holder) ) def _abandon_take_over(self, fd: int, aside: str, *, put_back: bool) -> None: # pragma: win32 no cover self._mark_descriptor_released() put_back_done = not put_back try: if put_back: put_back_done = exchange_back(self._lock_path, aside, fd) finally: try: # Whichever name still holds our marker loses it: the descriptor keeps its inode number ours. own: Final = os.fstat(fd) for name in (self._lock_path, aside): if name == self._lock_path and not put_back_done: continue with suppress(FileNotFoundError): if os.path.samestat(os.lstat(name), own): Path(name).unlink() finally: os.close(fd) def _marker_unchanged_for(self, marker: os.stat_result) -> float: # The mtime is the writer's or file server's clock, so against time.time() a contender whose clock runs ahead # would expire a fresh marker. Only watch it for change, timed on this process's monotonic clock. The owner of a # file sets its mtime, and ext4 reuses a freed inode number, so a new marker can repeat both. The change time # cannot be set and moves with every create, which keeps a new marker from inheriting an old one's age. now: Final = time.monotonic() key: Final = marker.st_mtime, marker.st_ino, marker.st_ctime_ns if (seen := self._marker_seen) is None or seen[0] != key: self._marker_seen = key, now return 0.0 return now - seen[1] def _unprobeable_holder(self) -> str | None: # This host cannot prove such a holder dead, so whoever decides whether it is gone needs its name. with suppress(OSError, ValueError): if (content := _read_lock_file(self._lock_path)[0]) == _RECORDLESS_MARKER: return "an owner that recorded no PID (a filelock release before 3.22, or one still writing its record)" if (holder := self._recorded_holder(content)) and holder[1] != host_name(): return f"pid {holder[0]} on {holder[1]}" return None @classmethod def _recorded_holder(cls, content: str | None) -> tuple[int, str] | None: return None if (holder := _parse_lock_holder(content)) is None else holder[:2] @staticmethod def _write_lock_info(fd: int) -> None: # No suppression: a write failure must reach the acquisition rollback so it never publishes a half-written # marker as held state. The optional third line is this process's start token, absent when the platform # exposes no proven start time, in which case a reader falls back to PID-only liveness. info = f"{os.getpid()}\n{host_name()}\n" if (token := process_start_token(os.getpid())) is not None: info += f"{token}\n" write_all(fd, info.encode()) @property def pid(self) -> int | None: """ The PID of the process holding this lock, read from the lock file. :returns: the PID as an integer, or ``None`` if the lock file does not exist or cannot be parsed """ with suppress(OSError, ValueError): holder = _parse_lock_holder(_read_lock_file(self._lock_path)[0]) if holder is not None: return holder[0] return None @property def is_lock_held_by_us(self) -> bool: """ Whether this lock is held by the current process. :returns: ``True`` if the lock file exists and names the current process's PID, hostname and, when recorded, start token """ with suppress(OSError, ValueError): if (holder := _parse_lock_holder(_read_lock_file(self._lock_path)[0])) is not None: return owner_is_current_process(*holder) return False def break_lock(self) -> None: """Forcibly break the lock by removing the lock file, regardless of who holds it.""" with suppress(OSError): Path(self._lock_path).unlink() def _release_current(self, *, force: bool) -> None: try: super()._release_current(force=force) finally: # A release a handler cut short after the descriptor closed still owes the unlink on Windows. self._finish_marker_cleanup() def _release(self) -> None: fd = self._context.lock_file_fd assert fd is not None # ruff:ignore[assert] # _release runs only while held, so the descriptor is set # Capture the held file's identity before closing so cleanup can refuse to unlink a successor's marker. A # supported lifetime lease lets a peer break our expired marker and create its own at this path before we # release; unlinking by path alone would then delete the successor's lock. identity: tuple[int, int] | None = None with suppress(OSError): identity = _file_identity(os.fstat(fd)) if sys.platform == "win32": # pragma: win32 cover self._release_then_unlink(fd, identity) else: # pragma: win32 no cover # Our open descriptor keeps the inode allocated, so its number cannot name a successor's marker yet, and an # interrupt before the unlink lands leaves the lock held for the retried release to finish. unlinked: Final = self._unlink_open_marker(fd, identity) # A failed close may already have released and recycled the descriptor number. Relinquish it before the # one close attempt so no later release can close an unrelated descriptor that reused the same integer. self._mark_descriptor_released() try: self._close_released_fd(fd, default_suppresses=False) finally: # A share that refuses to delete an open file gets the unlink after the close; the marker still exists # until then, so its inode cannot be reused either. if not unlinked: self._unlink_held_marker(identity) def _release_then_unlink(self, fd: int, identity: tuple[int, int] | None) -> None: # pragma: win32 cover # Windows cannot unlink an open file, so the unlink outlives the hold until it lands, here or on a later acquire # or release. Only the gate's holder touches the pending record, so two threads never overwrite each other's. with self._transition_lock: self._marker_fds = [fd] self._marker_cleanup = identity self._mark_descriptor_released() try: self._close_marker_fd() # Marker cleanup must also run for control-flow exceptions, and both failures must remain observable. except BaseException as close_error: try: self._finish_marker_cleanup() except BaseException as cleanup_error: # ruff:ignore[blind-except] # keep control-flow failures _raise_grouped_errors( "lock descriptor close and marker cleanup both failed", close_error, cleanup_error, ) raise self._finish_marker_cleanup() def _marker_names(self) -> tuple[str, ...]: # After a rename of the lock's directory only the caller's spelling, resolved through the working directory, # still reaches the marker this hold opened. return tuple(dict.fromkeys((self._lock_path, self.lock_file))) def _unlink_open_marker(self, fd: int, identity: tuple[int, int] | None) -> bool: # pragma: win32 no cover if identity is None: return True replaced = False for name in self._marker_names(): try: if _file_identity(os.lstat(name)) == identity: Path(name).unlink() return True replaced = True except FileNotFoundError: continue except OSError: return False if replaced: try: _leave_released_record(fd) except OSError: return False return True def _finish_marker_cleanup(self) -> None: # Never waits: whoever holds the gate is acquiring or releasing this lock and finishes the cleanup itself. if self._marker_cleanup is not None and self._transition_lock.acquire(blocking=False): # pragma: win32 cover try: # A close error here belongs to the release an interrupt cut short, not to this caller. with suppress(OSError): self._close_marker_fd() if (identity := self._marker_cleanup) is not None: # pragma: no branch # the gate's holder clears it self._windows_unlink_if_ours(identity) finally: self._transition_lock.release() def _close_marker_fd(self) -> None: # pragma: win32 cover # Popping and closing is one chain of C calls that no signal handler can split, so a retried release closes the # descriptor at most once; a second close could hit an unrelated file that reused the number. try: list(map(os.close, islice(iter(vars(self).setdefault("_marker_fds", []).pop, None), 1))) except IndexError: return except OSError: if not self._suppresses_close_error(default_suppresses=False): raise def _unlink_held_marker(self, identity: tuple[int, int] | None) -> None: if sys.platform == "win32": # pragma: win32 cover if identity is not None: self._windows_unlink_if_ours(identity) else: # pragma: win32 no cover for name in self._marker_names(): with suppress(OSError): if _file_identity(os.lstat(name)) == identity: Path(name).unlink() return def _windows_unlink_if_ours(self, identity: tuple[int, int]) -> None: # pragma: win32 cover retry_delay = 0.001 for attempt in range(_UNLINK_MAX_RETRIES): # Windows doesn't immediately release file handles after close, causing EACCES/EPERM on unlink. Recheck # identity each attempt: a failed unlink leaves a window for a successor to replace the marker at this path. try: for name in self._marker_names(): with suppress(FileNotFoundError): if _file_identity(os.lstat(name)) == identity: # Forgotten before the unlink: Windows does not promise unique file IDs, so no retry may # reach a successor that reused ours. self._marker_cleanup = None unlink_path(name) break except OSError as exc: if exc.errno in {EACCES, EPERM}: # The file is still ours, so the next acquire or release tries again if these attempts run out. self._marker_cleanup = identity if attempt < _UNLINK_MAX_RETRIES - 1: time.sleep(retry_delay) retry_delay *= 2 continue self._marker_cleanup = None return def _file_identity(st: os.stat_result) -> tuple[int, int]: # (st_dev, st_ino) names the concrete inode behind a path, so a marker recreated at the same pathname after an # expired lease reads as a different file. CPython populates both on Windows from the volume serial and file index. return st.st_dev, st.st_ino def _begin_claim(name: str | Path) -> bool: # pragma: win32 no cover # Keyed by the file name alone: its random token identifies the claim, and the spelling of the directory varies. with _CLAIMS_LOCK: if (key := Path(name).name) in _CLAIMS_IN_PROGRESS: return False _CLAIMS_IN_PROGRESS.add(key) return True def _end_claim(name: str | Path) -> None: # pragma: win32 no cover with _CLAIMS_LOCK: _CLAIMS_IN_PROGRESS.discard(Path(name).name) def _placeholder_breaker(content: str | None) -> int | None: # pragma: win32 no cover # The placeholder a break leaves at the path while it works names the breaker that may have died there. if content is None or not content.startswith(placeholder := "filelock/break\n") or not content.endswith("\n"): return None with suppress(ValueError): return parse_decimal(content.removeprefix(placeholder)[:-1]) return None def _leave_released_record(fd: int) -> None: # A peer's exchange holds our marker under its break name and will put it back. Overwriting the record lets # contenders age it out instead of waiting on this live PID. The write comes before the truncate so the file is # never empty. if sys.platform != "win32": # pragma: win32 no cover os.pwrite(fd, released := b"filelock/released\n", 0) os.ftruncate(fd, len(released)) def _read_lock_file(path: str) -> tuple[str | None, os.stat_result]: # A legitimate lock file is always a regular file. Classify the path with lstat first, so any other node (symlink, # FIFO, socket, device) is reported as a malformed lock the caller can evict, without an os.open that would follow # a symlink, stall on a FIFO, or fail on a socket and leave acquisition wedged. The stat result still flows back # for the identity-checked stale break. lstat, not stat, so a hostile symlink is never followed onto its target. st = os.lstat(path) if not stat.S_ISREG(st.st_mode): # pragma: needs fifo return None, st # Re-check on the opened handle: O_NOFOLLOW refuses a symlink swapped in after the lstat, O_NONBLOCK stops a FIFO # swapped in from stalling the open, and the fstat catches any other non-regular replacement race before we read. # The capped read stops a huge regular file (e.g. one filled from /dev/zero) from exhausting memory. if sys.platform == "win32": # pragma: win32 cover # os.open would wait for an oplock holder to answer its break, past the caller's deadline. fd = open_read_without_oplock_wait(path) else: # pragma: win32 no cover fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)) try: st = os.fstat(fd) if not stat.S_ISREG(st.st_mode): # pragma: no cover # only a non-regular node swapped in after the lstat return None, st data = os.read(fd, _MAX_LOCK_FILE_SIZE + 1) finally: os.close(fd) if len(data) <= _MAX_LOCK_FILE_SIZE: with suppress(UnicodeDecodeError): return data.decode("utf-8"), st return None, st def _parse_lock_holder(content: str | None) -> tuple[int, str, int | None] | None: # A well-formed lock file is "\n\n" with an optional "\n" third line naming the # holder's process start instant (a filelock 3.29 marker wrote this only on Windows; every platform writes it now). # Anything else (wrong line count, a non-integer PID or start token, empty or unreadable content) is unparsable; # returning None lets the caller treat it as a malformed lock to self-heal rather than a holder. # Every writer since 3.22 ends each line with "\n", so a record without one was cut by a crash or read mid-write. As # a holder it would name a foreign host no one reclaims, or a start token that lets a contender break a live lock. if not content or not content.endswith("\n") or len(lines := content.strip().splitlines()) not in {2, 3}: return None try: pid = parse_decimal(lines[0]) start_token = parse_decimal(lines[2]) if len(lines) == _MARKER_WITH_START_TOKEN_LINE_COUNT else None except ValueError: return None # A pid outside the valid range is a malformed lock, not a holder. Without this, a non-positive pid # reaches os.kill() where 0 / -1 mean "the caller's own process group / every process" so a dead # holder reads as alive and the lock is never reclaimed, while an oversized pid raises OverflowError # (not OSError/ValueError) out of the self-heal path. _parse_marker_bytes already enforces this range. if not 1 <= pid <= 2**31 - 1: return None return pid, lines[1], start_token def parse_decimal(text: str) -> int: """ Read a PID or start token as a writer formats it: ASCII digits only. :raises ValueError: for anything else ``int()`` would accept, such as ``1_000``, surrounding whitespace, a sign or non-ASCII digits; no filelock writes those """ if not (text.isascii() and text.isdigit()): msg = f"not a decimal integer: {text!r}" raise ValueError(msg) return int(text) __all__ = [ "MALFORMED_LOCK_AGE_THRESHOLD", "SoftFileLock", "parse_decimal", ] filelock-4.2.8/src/filelock/_soft_protocol.py0000644000000000000000000000033215272561277016243 0ustar00from __future__ import annotations __lazy_modules__ = ["typing"] from typing import Final STRICT_SOFT_SENTINEL_RECORD: Final[str] = "1\nfilelock-strict-v1\x00\n0\n" __all__ = [ "STRICT_SOFT_SENTINEL_RECORD", ] filelock-4.2.8/src/filelock/_strict.py0000644000000000000000000016622615272561277014676 0ustar00from __future__ import annotations __lazy_modules__ = [ "contextlib", "filelock._error", "filelock._identity", "filelock._marker", "filelock._soft", "filelock._util", "functools", "pathlib", "secrets", "tempfile", ] import contextlib import errno import functools import os import secrets import stat import sys import tempfile import time from dataclasses import dataclass from errno import EACCES, EEXIST, ENOENT, ENOSYS, EPERM, ESTALE, EXDEV from pathlib import Path from typing import TYPE_CHECKING, Final, Literal, cast from filelock._api import BaseFileLock, ThreadLocalFileContext, _canonical, _raise_cleanup_errors from filelock._error import SoftFileLockProtocolError from filelock._identity import host_name, owner_is_stale, process_start_token from filelock._marker import parse_marker from filelock._soft import MALFORMED_LOCK_AGE_THRESHOLD, _parse_lock_holder from filelock._soft_protocol import STRICT_SOFT_SENTINEL_RECORD from filelock._util import foreign_directory_owner, write_all if sys.platform == "win32": # pragma: win32 cover from filelock._windows import _is_unlinked, _open_deletable_for_reading, _pin_directory, _unpin_directory if TYPE_CHECKING: from collections.abc import Callable, Iterator StrictSoftFileClaimState = Literal["intent", "held"] _OccupantState = Literal["sentinel", "held", "exited", "damaged", "unparsable"] _CLAIM_STATES: Final[frozenset[str]] = frozenset({"intent", "held"}) _COORDINATION_SUFFIX: Final[str] = ".filelock" _CLAIM_DIRECTORY_NAME: Final[str] = "claims" _CLAIM_MAGIC: Final[str] = "filelock-strict-v1" _CLAIM_RECORD_LIMIT: Final[int] = 1024 _CLAIM_NAME_PART_COUNT: Final[int] = 3 _TOKEN_HEX_LENGTH: Final[int] = 32 _PRIVATE_RECORD_MARKER: Final[str] = ".private-v1-" _PRIVATE_RECORD_SUFFIX: Final[str] = ".tmp" _PRIVATE_RECORD_RANDOM_HEX_LENGTH: Final[int] = 32 _CLAIM_PROBE_PREFIX: Final[str] = ".probe-v1-" _PRIVATE_RECORD_GRACE: Final[float] = 2.0 _UNLINK_MAX_RETRIES: Final[int] = 10 #: How long a scan waits out a claim held in Windows' delete-pending state before treating it as unreadable. _CLAIM_READ_GRACE: Final[float] = 0.5 _CLAIM_READ_RETRY: Final[float] = 0.002 #: Rides out a peer replacing the lock path mid-inspection; a foreign node or unreadable record outlasting it is damage. _SENTINEL_READ_GRACE: Final[float] = 0.5 _NODE_DESCRIPTIONS: Final[dict[int, str]] = {stat.S_IFDIR: "a directory", stat.S_IFLNK: "a symlink"} #: Windows opens descriptors in text mode by default, which rewrites newlines and truncates a record at a control byte. #: The claim and sentinel records are exact binary, so every record descriptor must be binary; POSIX ignores the flag. _O_BINARY: Final[int] = getattr(os, "O_BINARY", 0) _LEGACY_SENTINEL: Final[bytes] = STRICT_SOFT_SENTINEL_RECORD.encode() _WINDOWS_HARD_LINK_UNSUPPORTED: Final[frozenset[int]] = frozenset({1, 17, 50}) # Termux/Android CPython ships without os.link (bionic long had only linkat), so the strict backend's whole hard-link # mechanism is absent there. Probe once and gate every os.link reference on it, so importing filelock still works and # only an actual StrictSoftFileLock acquire reports the missing capability. _HAS_LINK: Final[bool] = hasattr(os, "link") # Probe dir_fd capability once at import. A per-call ``os.unlink in os.supports_dir_fd`` check flips to False the moment # a test mocks os.unlink, silently diverting the code to a different branch than the one under test. _OPEN_SUPPORTS_DIR_FD: Final[bool] = os.open in os.supports_dir_fd _UNLINK_SUPPORTS_DIR_FD: Final[bool] = os.unlink in os.supports_dir_fd _STAT_SUPPORTS_DIR_FD: Final[bool] = os.stat in os.supports_dir_fd _LINK_SUPPORTS_DIR_FD: Final[bool] = _HAS_LINK and os.link in os.supports_dir_fd _MKDIR_SUPPORTS_DIR_FD: Final[bool] = os.mkdir in os.supports_dir_fd _SCANDIR_SUPPORTS_FD: Final[bool] = os.scandir in os.supports_fd def _probe_link_follow_symlinks() -> bool: # os.supports_follow_symlinks lists os.link on PyPy, but its linkat then rejects follow_symlinks=False with EINVAL, # and Windows raises NotImplementedError for the option outright. Link a throwaway file for real so the answer # reflects the runtime rather than its advertisement, and treat any failure as "not honored": the option only # hardens a source this process created with O_EXCL, so skipping it is safe, and a real environment fault surfaces # when the actual link runs. if not _HAS_LINK: return False try: with tempfile.TemporaryDirectory() as directory: source = Path(directory, "probe-source") source.touch() os.link(source, Path(directory, "probe-link"), follow_symlinks=False) except (OSError, NotImplementedError, ValueError): return False return True _LINK_HONORS_FOLLOW_SYMLINKS: Final[bool] = _probe_link_follow_symlinks() def _probe_hard_link_unsupported_errnos() -> frozenset[int]: # GraalPy's errno omits ENOTSUP, so importing the name outright breaks every runtime that ships without it. ENOTSUP # wins wherever it exists, leaving every runtime that names it unchanged. EOPNOTSUPP only stands in for the ones # that do not, and it approximates rather than matches: the two codes agree on Linux but differ on macOS/BSD and on # Windows. Where neither exists a runtime that cannot name "operation not supported" cannot raise it either, and # ENOSYS/EXDEV still classify the link failures it can raise. not_supported = getattr(errno, "ENOTSUP", getattr(errno, "EOPNOTSUPP", None)) return frozenset({ENOSYS, EXDEV} if not_supported is None else {ENOSYS, EXDEV, not_supported}) _HARD_LINK_UNSUPPORTED_ERRNOS: Final[frozenset[int]] = _probe_hard_link_unsupported_errnos() class StrictSoftFileLock(BaseFileLock): """Portable fail-closed lock based on immutable owner claims.""" _preserve_lock_file_supported: bool = True _on_acquired_supported: bool = False #: Age cannot clear a strict claim: expiring one on a clock is the overlap the fail-closed contract exists to rule #: out, so only force_break() removes it. _lifetime_supported: bool = False _lifetime_unsupported_reason: str = "a strict claim is never broken by age, only by force_break()" #: Contending processes each publish and rescan several files, so jitter their retries. Seconds. Only a #: poll_interval below this cap jitters; the default equals it and keeps a fixed cadence. _poll_backoff_cap: float = 0.05 _occupant_seen: tuple[tuple[float, int, int], float] | None = None #: Set by the finalizer, after which no retry of a failed cleanup can run. _finalizing: bool = False def _acquire(self) -> None: # Finish a doorway whose claim cleanup failed before publishing, so this owner never contends with leftovers. self._discard_pending_doorways(exited_only=True) lock_path = Path(self._lock_path) coordination_directory = Path(f"{lock_path}{_COORDINATION_SUFFIX}") claim_directory = coordination_directory / _CLAIM_DIRECTORY_NAME # A process that can write these directories can remove claims from them, so they take the lock's mode plus # search permission where the mode grants read. directory_mode: Final = (mode := self._open_mode()) | (mode & 0o444) >> 2 _ensure_protocol_directory(self.lock_file, coordination_directory, directory_mode) _ensure_claim_directory(self.lock_file, claim_directory, directory_mode) if ( sentinel_fd := _open_or_create_sentinel( self.lock_file, lock_path, self._open_mode(), self._occupant_unchanged_for ) ) is None: return try: sentinel_identity = _file_identity(os.fstat(sentinel_fd)) except BaseException as inspection_error: # preserve inspection and descriptor cleanup errors try: os.close(sentinel_fd) except BaseException as close_error: # ruff:ignore[blind-except] # preserve inspection and descriptor cleanup errors _raise_cleanup_errors("strict sentinel inspection cleanup failed", inspection_error, close_error) raise self._mark_descriptor_pending(sentinel_fd, sentinel_identity) try: if won := self._attempt_doorway(claim_directory): self._mark_descriptor_owned(sentinel_fd, sentinel_identity) except BaseException: # Once recorded as held, the acquire's rollback releases the claim instead. if not self.is_locked: self._discard_doorway() raise if not won: self._discard_doorway() def _discard_pending_doorways(self, *, exited_only: bool) -> None: if self._context.owner_claim_paths and not self.is_locked: self._discard_doorway() if not isinstance(context := self._context, ThreadLocalFileContext): return # Only this registry reaches an exited thread's thread-local doorway; a live thread finishes its own. own: Final = context.current with self._transition_lock: for pending, thread in self._thread_pending.copy().values(): if exited_only and thread.is_alive(): continue context.current = pending try: self._discard_doorway() finally: context.current = own def _occupant_unchanged_for(self, occupant: os.stat_result) -> float: # SoftFileLock's marker rule: the mtime is only tested for change, timed on this process's monotonic clock. key, now = (occupant.st_mtime, occupant.st_ino, occupant.st_size), time.monotonic() if (seen := self._occupant_seen) is None or seen[0] != key: self._occupant_seen = key, now return 0.0 return now - seen[1] def _attempt_doorway(self, claim_directory: Path) -> bool: if _read_existing_claims(self.lock_file, claim_directory): return False token = secrets.token_hex(_TOKEN_HEX_LENGTH // 2) intent_name = _claim_name("intent", token) intent_path = str(claim_directory / intent_name) # Record each claim before its link: once the link may have committed, any later failure must leave the name for # _discard_doorway. The token makes the name unique, so removing it when the link never happened is a no-op. self._context.owner_claim_paths = (intent_path,) try: with _pin_claim_directory(claim_directory): publication_cleanup_error = _publish_record( intent_path, _claim_record(token), self._open_mode(), _open_claim_directory ) except _PrivateRecordReclaimedError: return False except (NotImplementedError, OSError) as error: _raise_if_hard_links_unsupported(self.lock_file, error) if isinstance(error, OSError) and error.errno == EEXIST: # Another file holds the name, so it is not this owner's to remove. self._context.owner_claim_paths = () return False raise if publication_cleanup_error is not None: # pragma: needs dir-fd raise publication_cleanup_error claims = _read_existing_claims(self.lock_file, claim_directory) if ( not claims or any(claim.state == "held" for claim in claims) or min(claim.name for claim in claims) != intent_name ): return False held_name = _claim_name("held", token) held_path = str(claim_directory / held_name) self._context.owner_claim_paths = (held_path, intent_path) try: link_cleanup_error = _link_no_replace(claim_directory, intent_name, held_name) except (NotImplementedError, OSError) as error: _raise_if_hard_links_unsupported(self.lock_file, error) if isinstance(error, OSError) and error.errno == EEXIST: self._context.owner_claim_paths = (intent_path,) raise if link_cleanup_error is not None: # pragma: needs dir-fd raise link_cleanup_error claims = _read_existing_claims(self.lock_file, claim_directory) # Keep the intent claim for the whole hold rather than unlinking it now. The intent has existed, unchanged, # since this owner published it, so a contender's os.scandir is guaranteed to return it (POSIX only leaves the # visibility of entries created or removed *during* a scan unspecified). The freshly linked held claim carries # no such guarantee: a scan that races its creation can miss it. Were the intent removed here, that scan could # observe neither claim and let a larger-token contender win over this owner. The stable intent is the witness # that keeps the phase-five min-token decision computed over the true set. Release unlinks both. return {intent_name, held_name}.issubset(claim.name for claim in claims) and min( _claim_token_key(claim.name) for claim in claims ) == f"v1-{token}.claim" @property def claims(self) -> tuple[StrictSoftFileClaim, ...]: """Published claims that block acquisition.""" # An inspection writes nothing, so it trusts no record's age rather than probe the filesystem clock. return _read_existing_claims(self.lock_file, self._claim_directory, probe_clock=False, verify_server=False) @property def lock_path_occupant(self) -> str | None: """ What sits at the lock path in place of the strict sentinel, or ``None`` when the sentinel or nothing is there. A soft-lock owner's marker blocks acquisition without appearing in :attr:`~filelock.StrictSoftFileLock.claims`; this describes it, naming the PID and host when the marker records them. A directory, a symlink, a marker whose owner on this host has exited, or a file it cannot parse that stays unchanged for two seconds makes acquisition raise :class:`~filelock.SoftFileLockProtocolError` instead and needs an operator to remove it. """ try: fd, _, occupant = _inspect_lock_path(Path(_canonical(self.lock_file))) except FileNotFoundError: return None if fd is None: return occupant os.close(fd) return None def force_break(self, claim_name: str) -> None: """Remove one named claim, allowing overlap if its owner still holds the protected resource.""" _validate_force_break_name(claim_name) with _pin_claim_directory(directory := self._claim_directory): _require_exact_name(directory, claim_name) cleanup_error = _unlink_in_directory(directory, claim_name) if cleanup_error is not None: # pragma: needs dir-fd raise cleanup_error def _release_abandoned_holds(self) -> None: self._finalizing = True super()._release_abandoned_holds() # release() skips a doorway whose cleanup failed, as it is no hold, so a dropped instance would strand claims. with contextlib.suppress(Exception): # GraalPy defers finalizers to the host collector, so its tests cannot drive this cleanup. self._discard_pending_doorways(exited_only=False) # pragma: needs collected-finalization def _reconcile_failed_acquire(self, canonical: str) -> None: # The acquisition is over, so the next one resolves the working directory again rather than reuse this one's. self._context.claim_root = None super()._reconcile_failed_acquire(canonical) def _release(self) -> None: fd = cast("int", self._context.lock_file_fd) self._context.claim_root = None remaining, errors = self._unlink_owner_claims() if remaining: _raise_recorded_errors("strict claim release failed", errors) self._mark_descriptor_released() try: self._close_released_fd(fd, default_suppresses=False) except BaseException as close_error: # ruff:ignore[blind-except] # preserve claim and sentinel cleanup errors errors.append(close_error) if errors: _raise_recorded_errors("strict release cleanup failed", errors) def _discard_doorway(self) -> None: remaining, errors = self._unlink_owner_claims() # The pending descriptor holds no lock and a retry needs the claim paths alone. A dropped instance does not # retry, so close it now. if (fd := self._context.pending_lock_file_fd) is not None: self._context.pending_lock_file_fd = self._context.pending_lock_file_fd_identity = None try: self._close_released_fd(fd, default_suppresses=False) except BaseException as close_error: # ruff:ignore[blind-except] # preserve claim and sentinel cleanup errors errors.append(close_error) if remaining: # Stay pending, not owned: is_locked reports only a held claim that passed the final rescan. The next # acquire retries this cleanup. _raise_recorded_errors("strict doorway claim cleanup failed", errors) self._mark_descriptor_released() if errors: _raise_recorded_errors("strict doorway cleanup failed", errors) def _unlink_owner_claims(self) -> tuple[list[str], list[BaseException]]: remaining, errors = _unlink_owner_paths(self._context.owner_claim_paths) self._context.owner_claim_paths = tuple(remaining) if self._finalizing: # No retry runs after the finalizer and no caller sees its errors, so report success and let it close the # descriptor even when claims remain. return [], [] # pragma: needs collected-finalization return remaining, errors @property def _claim_directory(self) -> Path: if self._context.owner_claim_paths: return Path(self._context.owner_claim_paths[0]).parent return Path(f"{_canonical(self.lock_file)}{_COORDINATION_SUFFIX}") / _CLAIM_DIRECTORY_NAME @dataclass(frozen=True) class StrictSoftFileClaim: """One parsed strict soft-lock claim.""" name: str state: StrictSoftFileClaimState token: str pid: int hostname: str #: The owner's process start token, or ``None`` when the platform exposes no proven start time. A strict lock never #: reclaims a claim on its own, so this identifies the owner for tooling rather than driving any automatic break. start: int | None = None class _PrivateRecordReclaimedError(Exception): pass def _open_or_create_sentinel( lock_file: str, path: Path, mode: int, unchanged_for: Callable[[os.stat_result], float] ) -> int | None: try: return _open_sentinel(lock_file, path, unchanged_for) except FileNotFoundError: pass _reclaim_sentinel_private_records(lock_file, path) try: publication_cleanup_error = _publish_record(os.fspath(path), _LEGACY_SENTINEL, mode) except _PrivateRecordReclaimedError: return None except (NotImplementedError, OSError) as error: _raise_if_hard_links_unsupported(lock_file, error) if not isinstance(error, OSError) or error.errno != EEXIST: raise else: if publication_cleanup_error is not None: # pragma: needs dir-fd raise publication_cleanup_error try: return _open_sentinel(lock_file, path, unchanged_for) except FileNotFoundError: return None def _open_sentinel(lock_file: str, path: Path, unchanged_for: Callable[[os.stat_result], float]) -> int | None: # A live soft-lock owner blocks like any holder. Anything else raises: claims cannot show it and force_break() # cannot remove it, so waiting would wedge every strict contender with no diagnostic. A file no lock parses may be a # soft lock between its create and its write, so it raises only once unchanged as long as a soft contender waits. deadline: float | None = None while True: fd, state, occupant = _inspect_lock_path(path) if fd is not None or state == "held": return fd reason = f"expected the strict sentinel at the lock path, found {occupant}" if state == "unparsable": if unchanged_for(path.lstat()) < MALFORMED_LOCK_AGE_THRESHOLD: return None raise SoftFileLockProtocolError( lock_file, None, f"{reason}, unchanged for {MALFORMED_LOCK_AGE_THRESHOLD:g} seconds" ) if deadline is None: deadline = time.monotonic() + _SENTINEL_READ_GRACE if state == "exited" or time.monotonic() >= deadline: raise SoftFileLockProtocolError(lock_file, None, reason) time.sleep(_CLAIM_READ_RETRY) def _inspect_lock_path(path: Path) -> tuple[int | None, _OccupantState, str]: # "held" may be a soft-lock owner that still runs, "exited" is one this host proves gone, "unparsable" is content no # lock parses, and "damaged" is a node no lock writes. if not stat.S_ISREG(mode := path.lstat().st_mode): return None, "damaged", _NODE_DESCRIPTIONS.get(stat.S_IFMT(mode), "a special file") try: fd, record = _open_record((os.fspath(path.parent), None), path.name, _CLAIM_RECORD_LIMIT) except FileNotFoundError: raise except PermissionError as error: # Windows denies the read while a legacy holder keeps its marker open, and a holder running as another user # may leave it unreadable here, so a marker that cannot be read may still be held. return None, "held", f"a file it cannot read ({error.strerror or error})" except OSError as error: return None, "damaged", f"a file it cannot read ({error.strerror or error})" if record == _LEGACY_SENTINEL: return fd, "sentinel", "the strict sentinel" os.close(fd) return None, *_describe_soft_marker(record.decode("utf-8", errors="replace")) def _describe_soft_marker(content: str) -> tuple[_OccupantState, str]: # A SoftFileLock, MarkerSoftFileLock or SoftFileLease owner holds the path legitimately until it releases, so its # marker is contention. One this host proves gone is named, never removed: strict never acts on an inferred death. if (record := parse_marker(content)) is not None: pid, hostname, start = record.pid, record.hostname, record.start elif (holder := _parse_lock_holder(content)) is not None: pid, hostname, start = holder else: return "unparsable", "a file that is neither the sentinel nor a soft-lock marker it can parse" if owner_is_stale(pid, hostname, start): return "exited", f"a soft-lock marker left by pid {pid} on {hostname}, which has exited" return "held", f"a soft-lock marker held by pid {pid} on {hostname}" def _read_claims( lock_file: str, directory_ref: tuple[str, int | None], *, probe_clock: bool, verify_server: bool ) -> tuple[StrictSoftFileClaim, ...]: names: Final = _list_claim_names(lock_file, directory_ref, probe_clock=probe_clock) name_parts: dict[str, tuple[StrictSoftFileClaimState, str]] = {} for name in names: if (parts := _parse_claim_name(name)) is None: raise SoftFileLockProtocolError(lock_file, name, "unknown claim name or protocol version") name_parts[name] = parts records: Final = _read_claim_records(lock_file, directory_ref, names, probe_clock=probe_clock) return tuple( _parse_claim(lock_file, name, name_parts[name], records[name]) for name in names if name in records and (not verify_server or _claim_exists_on_server(lock_file, directory_ref, name)) ) def _claim_exists_on_server(lock_file: str, directory_ref: tuple[str, int | None], name: str) -> bool: # Reading an NFS claim can succeed from a client cache after its server-side name is gone. LINK checks the source # on the server; a private proof is reclaimed if this process exits before unlinking it. proof = f"{_CLAIM_PROBE_PREFIX}{secrets.token_hex(_TOKEN_HEX_LENGTH // 2)}{_PRIVATE_RECORD_SUFFIX}" try: _link_or_find_committed(directory_ref, name, proof) except OSError as error: if error.errno in {ENOENT, ESTALE}: return False reason = f"cannot verify claim: {error.strerror or type(error).__name__}" raise SoftFileLockProtocolError(lock_file, name, reason) from error except NotImplementedError as error: raise SoftFileLockProtocolError(lock_file, name, "cannot verify claim: hard links unavailable") from error try: _unlink_relative(directory_ref, proof) except FileNotFoundError: pass except OSError as error: reason = f"cannot remove claim probe: {error.strerror or type(error).__name__}" raise SoftFileLockProtocolError(lock_file, name, reason) from error return True def _list_claim_names(lock_file: str, directory_ref: tuple[str, int | None], *, probe_clock: bool) -> list[str]: try: with _scandir_relative(directory_ref) as entries: return _public_claim_names(directory_ref, entries, probe_clock=probe_clock) except OSError as error: reason = f"cannot list claim directory: {error.strerror or type(error).__name__}" raise SoftFileLockProtocolError(lock_file, None, reason) from error def _read_claim_records( lock_file: str, directory_ref: tuple[str, int | None], names: list[str], *, probe_clock: bool ) -> dict[str, bytes]: # A contended scan can list a claim that is not yet cleanly readable, in two ways that both resolve on a brief # retry. Windows fails an open with EACCES while a peer's unlink holds the claim; once the deletion is pending, # _attempt_claim_read skips the claim outright, as another process's handle can keep the name listed for seconds. # On NFS, a peer that unlinks its own claim leaves this client's cached filehandle stale, so the next open returns # ESTALE rather than a clean ENOENT until the lookup revalidates against the server. Retrying re-runs the path # lookup, which turns the vanished claim into ENOENT (skip) or reads it if it still exists. A genuinely unreadable # record (a locked-down file, an EIO fault) still fails closed. The grace starts at the first pending result rather # than before the first attempt, so a slow first read cannot use up the window and leave no retry. One deadline # covers the scan: a deadline per claim let claims that clear one after another stretch a scan, and the caller's # timeout with it, by a grace each. records: dict[str, bytes] = {} deadline: float | None = None while True: pending: dict[str, OSError] = {} for name in names: record, error = _attempt_claim_read(lock_file, directory_ref, name) if error is not None: pending[name] = error elif record is not None: records[name] = record if not pending: return records if deadline is None: deadline = time.monotonic() + _CLAIM_READ_GRACE elif time.monotonic() >= deadline: break names = list(pending) time.sleep(_CLAIM_READ_RETRY) # A stale handle that outlives revalidation is a claim the server no longer has (RFC 1813 NFS3ERR_STALE): skip it # like ENOENT. Skipping a vanished claim overcounts contention at worst; it cannot free a held lock. if denied := [name for name, error in pending.items() if error.errno != ESTALE]: # Windows can deny opens after a peer removes the claim from the directory. listed: Final = set(_list_claim_names(lock_file, directory_ref, probe_clock=probe_clock)) if still_listed := [name for name in denied if name in listed]: error = pending[still_listed[0]] reason = f"cannot read claim: {error.strerror or str(error) or type(error).__name__}" raise SoftFileLockProtocolError(lock_file, still_listed[0], reason) from error return records def _attempt_claim_read( lock_file: str, directory_ref: tuple[str, int | None], name: str ) -> tuple[bytes | None, OSError | None]: # Return the record, or (None, None) when the claim has already gone, or (None, error) for an open the caller may # retry: a Windows EACCES while a peer's unlink holds the claim, or an NFS ESTALE from a peer unlinking its own # claim out from under this client's cached filehandle. Any other OSError is a real fault and fails closed. try: return _read_record(directory_ref, name, _CLAIM_RECORD_LIMIT), None except FileNotFoundError: return None, None except PermissionError as error: if sys.platform == "win32" and _is_unlinked(str(Path(directory_ref[0], name))): # pragma: win32 cover return None, None return None, error except OSError as error: if error.errno == ESTALE: return None, error reason = f"cannot read claim: {error.strerror or str(error) or type(error).__name__}" raise SoftFileLockProtocolError(lock_file, name, reason) from error def _public_claim_names( directory_ref: tuple[str, int | None], entries: Iterator[os.DirEntry[str]], *, probe_clock: bool ) -> list[str]: names: list[str] = [] now: Callable[[], float | None] = ( functools.cache( functools.partial( _filesystem_now, directory_ref, _claim_name("held", secrets.token_hex(_TOKEN_HEX_LENGTH // 2)) ) ) if probe_clock else lambda: None ) for entry in entries: if not entry.name.startswith("."): names.append(entry.name) elif _is_claim_probe_name(entry.name) or ( (public_name := _private_public_name(entry.name)) is not None and _parse_claim_name(public_name) is not None ): _reclaim_private_record(directory_ref, entry.name, now) return sorted(names) def _read_existing_claims( lock_file: str, directory: Path, *, probe_clock: bool = True, verify_server: bool = sys.platform != "win32" ) -> tuple[StrictSoftFileClaim, ...]: if not _OPEN_SUPPORTS_DIR_FD: # pragma: lacks dir-fd try: pinned = _pin_claim_directory(directory) except FileNotFoundError: return () except OSError as error: reason = f"cannot open claim directory: {error.strerror or type(error).__name__}" raise SoftFileLockProtocolError(lock_file, None, reason) from error with pinned: return _read_claims( lock_file, (os.fspath(directory), None), probe_clock=probe_clock, verify_server=verify_server ) return _read_claims_through_descriptor( # pragma: needs dir-fd lock_file, directory, probe_clock=probe_clock, verify_server=verify_server ) def _read_claims_through_descriptor( # pragma: needs dir-fd lock_file: str, directory: Path, *, probe_clock: bool, verify_server: bool ) -> tuple[StrictSoftFileClaim, ...]: try: directory_fd = _open_claim_directory(os.fspath(directory)) except FileNotFoundError: return () except OSError as error: reason = f"cannot open claim directory: {error.strerror or type(error).__name__}" raise SoftFileLockProtocolError(lock_file, None, reason) from error try: return _read_claims( lock_file, (os.fspath(directory), directory_fd), probe_clock=probe_clock, verify_server=verify_server ) finally: # A failed close has no result to report after the scan and proof cleanup. with contextlib.suppress(OSError): os.close(directory_fd) def _parse_claim( lock_file: str, name: str, name_parts: tuple[StrictSoftFileClaimState, str], record: bytes, ) -> StrictSoftFileClaim: try: magic, token, pid_text, hostname_hex, start_text, trailing = record.decode("ascii").split("\n") pid = int(pid_text) start = int(start_text) if start_text else None hostname = bytes.fromhex(hostname_hex).decode("utf-8") except (UnicodeDecodeError, ValueError) as error: raise SoftFileLockProtocolError(lock_file, name, "malformed claim record") from error if not all(( not trailing, magic == _CLAIM_MAGIC, token == name_parts[1], 1 <= pid <= 2**31 - 1, str(pid) == pid_text, start is None or (start >= 0 and str(start) == start_text), hostname.encode().hex() == hostname_hex and hostname.isprintable() and not any(character.isspace() for character in hostname), )): raise SoftFileLockProtocolError(lock_file, name, "malformed claim record") return StrictSoftFileClaim(name=name, state=name_parts[0], token=token, pid=pid, hostname=hostname, start=start) def _parse_claim_name(name: str) -> tuple[StrictSoftFileClaimState, str] | None: if not name.endswith(".claim"): return None parts = name.removesuffix(".claim").split("-") if len(parts) != _CLAIM_NAME_PART_COUNT or parts[0] not in _CLAIM_STATES or parts[1] != "v1": return None token = parts[2] if len(token) != _TOKEN_HEX_LENGTH or any(character not in "0123456789abcdef" for character in token): return None return cast("StrictSoftFileClaimState", parts[0]), token def _claim_name(state: StrictSoftFileClaimState, token: str) -> str: return f"{state}-v1-{token}.claim" def _claim_token_key(name: str) -> str: return name.removeprefix("held-").removeprefix("intent-") def _claim_record(token: str) -> bytes: hostname_hex = host_name().encode().hex() start = process_start_token(os.getpid()) start_text = "" if start is None else str(start) return f"{_CLAIM_MAGIC}\n{token}\n{os.getpid()}\n{hostname_hex}\n{start_text}\n".encode("ascii") def _publish_record( public_path: str, record: bytes, mode: int, open_directory: Callable[[str], int] | None = None, ) -> BaseException | None: directory, public_name = os.path.split(public_path) directory = directory or os.curdir private_name = _private_record_name(public_name) directory_fd = (open_directory or _open_directory)(directory) if _OPEN_SUPPORTS_DIR_FD else None directory_ref = directory, directory_fd try: _publish_record_in_directory(directory_ref, (private_name, public_name), mode, record) except BaseException as publication_error: # preserve publication and directory cleanup errors try: if directory_fd is not None: # pragma: needs dir-fd os.close(directory_fd) except BaseException as close_error: # ruff:ignore[blind-except] # pragma: needs dir-fd # preserve publication and directory cleanup errors _raise_cleanup_errors("strict publication directory cleanup failed", publication_error, close_error) raise if directory_fd is not None: # pragma: needs dir-fd try: os.close(directory_fd) except BaseException as close_error: # ruff:ignore[blind-except] # a claim's caller recorded its path before publishing return close_error return None def _publish_record_in_directory( directory_ref: tuple[str, int | None], names: tuple[str, str], mode: int, record: bytes, ) -> None: flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | _O_BINARY if (o_nofollow := getattr(os, "O_NOFOLLOW", None)) is not None: # pragma: needs o-nofollow flags |= o_nofollow private_fd = _open_relative(directory_ref, names[0], flags, mode) private_identity: tuple[int, int] | None = None try: private_identity = _file_identity(os.fstat(private_fd)) write_all(private_fd, record) _link_private_record(directory_ref, names, private_identity) except BaseException as publication_error: # preserve publication and cleanup errors close_error, unlink_error = _close_and_unlink_private_record( directory_ref, names[0], private_fd, private_identity, ) if close_error is not None or unlink_error is not None: _raise_cleanup_errors( "strict record publication cleanup failed", publication_error, close_error, unlink_error, ) raise close_error, unlink_error = _close_and_unlink_private_record( directory_ref, names[0], private_fd, private_identity, ) if close_error is not None or unlink_error is not None: _raise_record_finalization_errors(close_error, unlink_error) def _close_and_unlink_private_record( directory_ref: tuple[str, int | None], private_name: str, private_fd: int, private_identity: tuple[int, int] | None, ) -> tuple[BaseException | None, BaseException | None]: close_error: BaseException | None = None try: os.close(private_fd) except BaseException as error: # ruff:ignore[blind-except] # returned for grouping with unlink failures close_error = error unlink_error: BaseException | None = None try: if private_identity is None: _unlink_relative(directory_ref, private_name) else: _unlink_relative_if_identity(directory_ref, private_name, private_identity) except FileNotFoundError: pass except BaseException as error: # ruff:ignore[blind-except] # returned for grouping with close failures unlink_error = error return close_error, unlink_error def _link_private_record( directory_ref: tuple[str, int | None], names: tuple[str, str], private_identity: tuple[int, int], ) -> None: try: _link_or_find_committed(directory_ref, *names, private_identity) except FileNotFoundError as error: if _relative_identity(directory_ref, names[0]) is not None: raise msg = "private publication record was reclaimed" raise _PrivateRecordReclaimedError(msg) from error if _relative_identity(directory_ref, names[1]) == private_identity: return msg_0 = "private publication record was replaced" raise _PrivateRecordReclaimedError(msg_0) def _raise_record_finalization_errors( close_error: BaseException | None, unlink_error: BaseException | None, ) -> None: errors = [error for error in (close_error, unlink_error) if error is not None] if len(errors) > 1: _raise_cleanup_errors("strict record finalization failed", errors[0], *errors[1:]) raise errors[0] def _private_record_name(public_name: str) -> str: return f".{public_name}{_PRIVATE_RECORD_MARKER}{secrets.token_hex(_PRIVATE_RECORD_RANDOM_HEX_LENGTH // 2)}.tmp" def _private_public_name(private_name: str) -> str | None: if not private_name.startswith(".") or not private_name.endswith(_PRIVATE_RECORD_SUFFIX): return None public_name, marker, random_hex = private_name[1 : -len(_PRIVATE_RECORD_SUFFIX)].rpartition(_PRIVATE_RECORD_MARKER) if ( marker != _PRIVATE_RECORD_MARKER or len(random_hex) != _PRIVATE_RECORD_RANDOM_HEX_LENGTH or any(character not in "0123456789abcdef" for character in random_hex) ): return None return public_name def _is_claim_probe_name(name: str) -> bool: if not name.startswith(_CLAIM_PROBE_PREFIX) or not name.endswith(_PRIVATE_RECORD_SUFFIX): return False token = name[len(_CLAIM_PROBE_PREFIX) : -len(_PRIVATE_RECORD_SUFFIX)] return len(token) == _TOKEN_HEX_LENGTH and all(character in "0123456789abcdef" for character in token) def _reclaim_sentinel_private_records(lock_file: str, path: Path) -> None: directory_ref = os.fspath(path.parent), None now = functools.cache(functools.partial(_filesystem_now, directory_ref, path.name)) try: with os.scandir(path.parent) as entries: for entry in entries: if _private_public_name(entry.name) == path.name: _reclaim_private_record(directory_ref, entry.name, now) except OSError as error: reason = f"cannot reclaim sentinel private records: {error.strerror or error}" raise SoftFileLockProtocolError(lock_file, None, reason) from error def _reclaim_private_record( directory_ref: tuple[str, int | None], private_name: str, now: Callable[[], float | None] ) -> None: directory, directory_fd = directory_ref try: private_stat = ( os.stat(private_name, dir_fd=directory_fd, follow_symlinks=False) if directory_fd is not None and _STAT_SUPPORTS_DIR_FD else Path(directory, private_name).lstat() ) except FileNotFoundError: return if not stat.S_ISREG(private_stat.st_mode): msg = f"{Path(directory, private_name)} is not a regular private record" raise OSError(msg) if private_stat.st_nlink == 1 and ( (current := now()) is None or current - private_stat.st_mtime < _PRIVATE_RECORD_GRACE ): return try: _unlink_private_record_once(directory_ref, private_name) except FileNotFoundError: pass except OSError as error: if error.errno not in {EACCES, EPERM}: raise def _filesystem_now(directory_ref: tuple[str, int | None], public_name: str) -> float | None: # A network filesystem stamps mtimes with the server's clock, which can run seconds off this client's, so read "now" # as the mtime of a probe written like a record (liblockfile uses a read's atime). The probe has a private-record # name, so a scan reaps one left behind. Without a stamp no age is trusted. probe = _private_record_name(public_name) flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | _O_BINARY | getattr(os, "O_NOFOLLOW", 0) try: fd = _open_relative(directory_ref, probe, flags, 0o600) except OSError: return None try: write_all(fd, b"\n") return os.fstat(fd).st_mtime except OSError: return None finally: with contextlib.suppress(OSError): os.close(fd) with contextlib.suppress(OSError): _unlink_private_record_once(directory_ref, probe) def _unlink_private_record_once(directory_ref: tuple[str, int | None], private_name: str) -> None: directory, directory_fd = directory_ref if directory_fd is not None and _UNLINK_SUPPORTS_DIR_FD: # pragma: needs dir-fd os.unlink(private_name, dir_fd=directory_fd) else: Path(directory, private_name).unlink() def _relative_identity(directory_ref: tuple[str, int | None], name: str) -> tuple[int, int] | None: directory, directory_fd = directory_ref try: if directory_fd is not None and _STAT_SUPPORTS_DIR_FD: # pragma: needs dir-fd path_stat = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) else: # pragma: win32 cover path_stat = Path(directory, name).lstat() except FileNotFoundError: return None return _file_identity(path_stat) def _unlink_relative_if_identity( directory_ref: tuple[str, int | None], name: str, identity: tuple[int, int], ) -> None: if _relative_identity(directory_ref, name) == identity: with contextlib.suppress(FileNotFoundError): _unlink_relative(directory_ref, name) def _open_relative(directory_ref: tuple[str, int | None], name: str, flags: int, mode: int) -> int: directory, directory_fd = directory_ref return ( os.open(name, flags, mode, dir_fd=directory_fd) if directory_fd is not None else os.open(Path(directory, name), flags, mode) ) def _link_relative(directory_ref: tuple[str, int | None], source_name: str, destination_name: str) -> None: directory, directory_fd = directory_ref if directory_fd is not None and _LINK_SUPPORTS_DIR_FD: # pragma: needs dir-fd _link_no_follow(source_name, destination_name, src_dir_fd=directory_fd, dst_dir_fd=directory_fd) return _link_no_follow(Path(directory, source_name), Path(directory, destination_name)) # pragma: win32 cover def _link_or_find_committed( directory_ref: tuple[str, int | None], source_name: str, destination_name: str, source_identity: tuple[int, int] | None = None, ) -> None: try: _link_relative(directory_ref, source_name, destination_name) except FileExistsError: # NFS retransmits a LINK whose reply was lost and reports EEXIST for the committed link; as in open(2) NOTES # and liblockfile, it succeeded when the destination names the source. Prefer the fstat-ed identity: a peer's # scan reaps a private record once it has two links, so its name may already resolve to nothing. if (identity := _relative_identity(directory_ref, destination_name)) is None or identity != ( source_identity or _relative_identity(directory_ref, source_name) ): raise def _link_no_follow( source: str | Path, destination: str | Path, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, ) -> None: if not _HAS_LINK: # No os.link at all (Termux/Android): report it as unsupported like a filesystem that refuses hard links, so # the acquire path raises SoftFileLockProtocolError instead of a bare AttributeError. msg = "os.link is unavailable on this platform" raise NotImplementedError(msg) # The source is a private record this process created with O_CREAT | O_EXCL, so follow_symlinks guards nothing an # attacker can reach. Pass the option only when the runtime honors it: PyPy advertises it through # os.supports_follow_symlinks yet its linkat rejects it with EINVAL, so probe once rather than trust the set. if _LINK_HONORS_FOLLOW_SYMLINKS: os.link(source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=False) else: # pragma: lacks link-follow-symlinks os.link(source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd) def _unlink_relative(directory_ref: tuple[str, int | None], name: str) -> None: directory, directory_fd = directory_ref if directory_fd is not None and _UNLINK_SUPPORTS_DIR_FD: # pragma: needs dir-fd os.unlink(name, dir_fd=directory_fd) elif sys.platform == "win32": # pragma: win32 cover _unlink_in_directory(Path(directory), name) else: Path(directory, name).unlink() # pragma: win32 no cover def _link_no_replace(directory: Path, source_name: str, destination_name: str) -> BaseException | None: if _LINK_SUPPORTS_DIR_FD: # pragma: needs dir-fd directory_fd = _open_claim_directory(str(directory)) try: _link_or_find_committed((str(directory), directory_fd), source_name, destination_name) except BaseException as link_error: # preserve link and directory cleanup errors try: os.close(directory_fd) except BaseException as close_error: # ruff:ignore[blind-except] # preserve link and directory cleanup errors _raise_cleanup_errors("strict link directory cleanup failed", link_error, close_error) raise try: os.close(directory_fd) except BaseException as close_error: # ruff:ignore[blind-except] # the caller recorded the held path before linking return close_error return None with _pin_claim_directory(directory): # pragma: win32 cover _link_or_find_committed((str(directory), None), source_name, destination_name) return None # pragma: win32 cover def _unlink_owner_path(path: str) -> BaseException | None: directory, name = os.path.split(path) with _pin_claim_directory(claim_directory := Path(directory or os.curdir)): return _unlink_in_directory(claim_directory, name) def _unlink_owner_paths(paths: tuple[str, ...]) -> tuple[list[str], list[BaseException]]: results = [(path, _unlink_owner_path_result(path)) for path in paths] return [path for path, (removed, _) in results if not removed], [ error for _, (_, error) in results if error is not None ] def _unlink_owner_path_result(path: str) -> tuple[bool, BaseException | None]: try: cleanup_error = _unlink_owner_path(path) except FileNotFoundError: return True, None except BaseException as error: # ruff:ignore[blind-except] # keep ownership when unlink did not commit return False, error return True, cleanup_error def _raise_recorded_errors(message: str, errors: list[BaseException]) -> None: if len(errors) > 1: _raise_cleanup_errors(message, errors[0], *errors[1:]) raise errors[0] def _unlink_in_directory(directory: Path, name: str) -> BaseException | None: if _UNLINK_SUPPORTS_DIR_FD: # pragma: needs dir-fd directory_fd = _open_claim_directory(str(directory)) try: os.unlink(name, dir_fd=directory_fd) except BaseException as unlink_error: # preserve unlink and directory cleanup errors try: os.close(directory_fd) except BaseException as close_error: # ruff:ignore[blind-except] # preserve unlink and directory cleanup errors _raise_cleanup_errors("strict unlink directory cleanup failed", unlink_error, close_error) raise try: os.close(directory_fd) except BaseException as close_error: # ruff:ignore[blind-except] # caller commits the removed path before raising return close_error return None if sys.platform != "win32": # pragma: win32 no cover Path(directory / name).unlink() return None retry_delay = 0.001 # pragma: win32 cover for attempt in range(_UNLINK_MAX_RETRIES): # pragma: win32 cover if (error := _unlink_error(directory / name)) is None: return None if error.errno not in {EACCES, EPERM} or attempt == _UNLINK_MAX_RETRIES - 1: raise error time.sleep(retry_delay) retry_delay *= 2 return None # pragma: no cover # the final retry returns or raises def _unlink_error(path: Path) -> OSError | None: # pragma: win32 cover try: path.unlink() except OSError as error: return error return None def _pin_claim_directory(directory: Path) -> contextlib.ExitStack[bool | None]: return _pin_directories(directory.parent, directory) def _pin_directories(*directories: Path) -> contextlib.ExitStack[bool | None]: # Windows has no dir_fd, so each operation resolves every path component by name again. An open handle on a # directory blocks a rename of it, and with it a swap for a junction (a directory that points at another directory), # until the operation ends. Other platforms work through descriptors and hold nothing. stack: contextlib.ExitStack[bool | None] = contextlib.ExitStack() if sys.platform == "win32": # pragma: win32 cover with stack: for directory in directories: stack.callback(_unpin_directory, _pin_directory(str(directory))) return stack.pop_all() return stack # pragma: win32 no cover def _open_directory(directory: str, dir_fd: int | None = None) -> int: # pragma: needs dir-fd return os.open(directory, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0), dir_fd=dir_fd) def _open_claim_directory(directory: str) -> int: # pragma: needs dir-fd # O_NOFOLLOW applies to the last path component. A process that can rename entries in the lock's directory can # swap the coordination directory for a symlink after any check of its name, so open the claim directory relative # to a descriptor of the coordination directory: a symlink at either level then fails the open. coordination_directory, name = os.path.split(directory) coordination_fd = _open_directory(coordination_directory) try: return _open_directory(name, coordination_fd) finally: # The walk wrote nothing through it, and an error here would leak the descriptor this function returns. with contextlib.suppress(OSError): os.close(coordination_fd) def _scandir_relative( directory_ref: tuple[str, int | None], ) -> contextlib.AbstractContextManager[Iterator[os.DirEntry[str]]]: directory, directory_fd = directory_ref if directory_fd is not None and _SCANDIR_SUPPORTS_FD: # pragma: needs dir-fd return os.scandir(directory_fd) return os.scandir(directory) # pragma: lacks dir-fd def _lstat_relative(directory_ref: tuple[str, int | None], name: str) -> os.stat_result: directory, directory_fd = directory_ref if directory_fd is not None and _STAT_SUPPORTS_DIR_FD: # pragma: needs dir-fd return os.stat(name, dir_fd=directory_fd, follow_symlinks=False) return Path(directory, name).lstat() def _read_record(directory_ref: tuple[str, int | None], name: str, limit: int) -> bytes: fd, record = _open_record(directory_ref, name, limit, deletable=True) os.close(fd) return record def _open_record( directory_ref: tuple[str, int | None], name: str, limit: int, *, deletable: bool = False ) -> tuple[int, bytes]: path = Path(directory_ref[0], name) path_stat = _lstat_relative(directory_ref, name) if not stat.S_ISREG(path_stat.st_mode): msg = f"{path} is not a regular file" raise OSError(msg) if deletable and sys.platform == "win32": # pragma: win32 cover # A claim's owner must be able to unlink it while another process reads it. The sentinel stays for the lock's # lifetime, so it keeps os.open. fd = _open_deletable_for_reading(str(path)) else: flags = os.O_RDONLY | _O_BINARY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) fd = _open_relative(directory_ref, name, flags, 0) try: record = _read_opened_record(fd, path, path_stat, limit) except BaseException as read_error: # preserve read and descriptor cleanup errors try: os.close(fd) except BaseException as close_error: # ruff:ignore[blind-except] # preserve read and descriptor cleanup errors _raise_cleanup_errors("strict record read cleanup failed", read_error, close_error) raise return fd, record def _read_opened_record(fd: int, path: Path, path_stat: os.stat_result, limit: int) -> bytes: opened_stat = os.fstat(fd) if not stat.S_ISREG(opened_stat.st_mode): msg = f"{path} is not a regular file" raise OSError(msg) if _file_identity(opened_stat) != _file_identity(path_stat): msg = f"{path} changed while opening" raise OSError(msg) record = os.read(fd, limit + 1) if len(record) > limit: msg = f"{path} exceeds {limit} bytes" raise OSError(msg) return record def _ensure_claim_directory(lock_file: str, directory: Path, mode: int) -> None: if not (_OPEN_SUPPORTS_DIR_FD and _MKDIR_SUPPORTS_DIR_FD): # pragma: lacks dir-fd try: pinned = _pin_directories(directory.parent) except OSError as error: raise SoftFileLockProtocolError(lock_file, None, f"cannot open {directory.parent}") from error with pinned: _ensure_protocol_directory(lock_file, directory, mode) else: # pragma: needs dir-fd # mkdir follows a symlink in every component but the last, so create against a descriptor of the real parent. try: coordination_fd = _open_directory(os.fspath(directory.parent)) except OSError as error: raise SoftFileLockProtocolError(lock_file, None, f"cannot open {directory.parent}") from error try: _ensure_protocol_directory(lock_file, directory, mode, coordination_fd) finally: with contextlib.suppress(OSError): os.close(coordination_fd) def _ensure_protocol_directory(lock_file: str, directory: Path, mode: int, parent_fd: int | None = None) -> None: try: if parent_fd is None: directory.mkdir(mode) else: # pragma: needs dir-fd os.mkdir(directory.name, mode, dir_fd=parent_fd) except FileExistsError: try: found = _lstat_relative((os.fspath(directory.parent), parent_fd), directory.name) owner = foreign_directory_owner( found, directory.parent.stat() if parent_fd is None else os.fstat(parent_fd) ) except OSError as error: raise SoftFileLockProtocolError(lock_file, None, f"cannot inspect {directory}") from error if ( not stat.S_ISDIR(found.st_mode) or getattr(found, "st_file_attributes", 0) & stat.FILE_ATTRIBUTE_REPARSE_POINT ): raise SoftFileLockProtocolError(lock_file, None, f"{directory} is not a real directory") from None if owner is not None: # pragma: needs foreign-sticky-tmp reason = f"{directory} belongs to uid {owner}, not to this process's account or the parent's owner" raise SoftFileLockProtocolError(lock_file, None, reason) from None _settle_directory_mode(lock_file, directory, mode, parent_fd) def _settle_directory_mode(lock_file: str, directory: Path, mode: int, parent_fd: int | None) -> None: # The umask or a default ACL can leave the directory wider than the mode, now or in an earlier release, so narrow a # directory this account owns to the mode. The sticky bit (a directory flag that lets only a file's owner remove it) # then protects each claim from other accounts even where the mode admits them. Windows has neither mode bits nor a # sticky bit. if sys.platform != "win32": # pragma: win32 no cover try: fd = os.open( directory.name if parent_fd is not None else os.fspath(directory), os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0), dir_fd=parent_fd, ) except OSError as error: raise SoftFileLockProtocolError(lock_file, None, f"cannot open {directory}") from error try: found = os.fstat(fd) settled = found.st_mode & 0o7000 | found.st_mode & mode & 0o777 | stat.S_ISVTX if found.st_uid == os.geteuid() and settled != stat.S_IMODE(found.st_mode): os.fchmod(fd, settled) except OSError as error: raise SoftFileLockProtocolError(lock_file, None, f"cannot set the mode of {directory}") from error finally: os.close(fd) def _validate_force_break_name(name: str) -> None: invalid_component = not name or name.startswith(".") or name in {os.curdir, os.pardir} if invalid_component or any(separator in name for separator in ("/", "\\", "\x00")): msg = "claim_name must be one public claim basename" raise ValueError(msg) def _require_exact_name(directory: Path, name: str) -> None: directory_fd = _open_claim_directory(os.fspath(directory)) if _OPEN_SUPPORTS_DIR_FD else None try: with _scandir_relative((os.fspath(directory), directory_fd)) as entries: if not any(entry.name == name for entry in entries): raise FileNotFoundError(ENOENT, os.strerror(ENOENT), name) finally: if directory_fd is not None: # pragma: needs dir-fd with contextlib.suppress(OSError): os.close(directory_fd) def _raise_if_hard_links_unsupported(lock_file: str, error: NotImplementedError | OSError) -> None: unsupported = ( isinstance(error, NotImplementedError) or error.errno in _HARD_LINK_UNSUPPORTED_ERRNOS or (getattr(error, "winerror", None) in _WINDOWS_HARD_LINK_UNSUPPORTED) ) if unsupported: reason = "filesystem does not support atomic no-replace hard-link publication" raise SoftFileLockProtocolError(lock_file, None, reason) from error def _file_identity(st: os.stat_result) -> tuple[int, int]: return st.st_dev, st.st_ino __all__ = [ "StrictSoftFileClaim", "StrictSoftFileClaimState", "StrictSoftFileLock", ] filelock-4.2.8/src/filelock/_unix.py0000644000000000000000000002535215272561277014343 0ustar00from __future__ import annotations __lazy_modules__ = ["contextlib", "filelock._util", "pathlib", "typing", "warnings"] import os import sys import warnings from contextlib import suppress from errno import EACCES, EAGAIN, ENOSYS, EWOULDBLOCK from pathlib import Path from typing import Final, cast from filelock._api import BaseFileLock from filelock._util import remove_own_file has_fcntl = False if sys.platform == "win32": # pragma: win32 cover class UnixFileLock(BaseFileLock): """Uses the :func:`fcntl.flock` to hard lock the lock file on unix systems.""" def _acquire(self) -> None: raise NotImplementedError def _release(self) -> None: raise NotImplementedError else: # pragma: win32 no cover try: import fcntl _ = (fcntl.flock, fcntl.LOCK_EX, fcntl.LOCK_NB, fcntl.LOCK_UN) except (ImportError, AttributeError): _FCNTL_UNAVAILABLE: Final[str] = "fcntl is unavailable" def _lock_fd_nonblocking(_fd: int) -> bool: raise OSError(ENOSYS, _FCNTL_UNAVAILABLE) def _unlock_fd(_fd: int) -> None: raise OSError(ENOSYS, _FCNTL_UNAVAILABLE) else: has_fcntl = True # Contention errnos for a nonblocking flock. EAGAIN/EWOULDBLOCK are the usual "held elsewhere" codes; some # filesystems report EACCES instead, so treat it as contention too rather than a permanent error. _CONTENTION_ERRNOS: Final[frozenset[int]] = frozenset({EACCES, EAGAIN, EWOULDBLOCK}) def _lock_fd_nonblocking(fd: int) -> bool: # One nonblocking exclusive flock attempt shared by UnixFileLock and lock_descriptor, so both contend on # the same lock and classify errors identically. The caller owns fd; this never closes it. try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError as exception: if exception.errno in _CONTENTION_ERRNOS: return False raise return True def _unlock_fd(fd: int) -> None: fcntl.flock(fd, fcntl.LOCK_UN) def _flock_is_missing(fd: int) -> bool: try: _lock_fd_nonblocking(fd) except OSError as exception: return exception.errno == ENOSYS return False # a flock the probe won is released when the caller closes fd class UnixFileLock(BaseFileLock): """ Uses the :func:`fcntl.flock` to hard lock the lock file on unix systems. We leave the lock file in place after release. Unlinking a locked file on Unix splits waiters across inodes and breaks mutual exclusion for processes that coordinate via the same path. """ def _acquire(self) -> None: if (missing_flock := self._acquire_native()) is not None: fd, exception, created = missing_flock self._switch_to_soft_lock(fd, exception, created=created) def _acquire_native(self) -> tuple[int, OSError, bool] | None: if (opened := self._open_lock_file()) is None: return None fd, created = opened self._mark_descriptor_pending(fd) if created: # Before the flock: a peer may win it first, and only the creator may set the mode. Outside the flock # handler below: an fchmod that answers ENOSYS says nothing about flock. try: self._apply_explicit_mode(fd) except BaseException: self._mark_descriptor_released() os.close(fd) raise # One guard covers the flock through the recording of the hold. A signal handler that raises in between # would otherwise leave an unreferenced descriptor, and its flock, open until the process exits. locked = False try: locked = _lock_fd_nonblocking(fd) if locked and self._finalize_locked_fd(fd): return None except BaseException as exception: if not locked and isinstance(exception, OSError) and exception.errno == ENOSYS: return fd, exception, created # Contention returns False from _lock_fd_nonblocking, so a raise here is a failure. self._mark_descriptor_released() os.close(fd) raise self._mark_descriptor_released() os.close(fd) # contention or an unlinked inode; let the retry loop try again return None def _open_lock_file(self) -> tuple[int, bool] | None: # No O_TRUNC: a contender that loses the flock must not truncate the holder's file (#591), and neither may # the winner, since a file renamed or hard-linked to the lock path is indistinguishable from a lock file. # O_EXCL tells whether this attempt created the inode: only then does it get the explicit mode, and the # ENOSYS fallback removes only its own placeholder. O_NONBLOCK: a lease (Linux F_SETLEASE) on the file would # otherwise block the open for up to fs.lease-break-time, beyond timeout, blocking=False and cancel_check. open_flags = os.O_RDWR | os.O_NONBLOCK if (o_nofollow := getattr(os, "O_NOFOLLOW", None)) is not None: open_flags |= o_nofollow open_mode = self._open_mode() try: return os.open(self._lock_path, open_flags | os.O_CREAT | os.O_EXCL, open_mode), True except FileExistsError: pass except FileNotFoundError: # On FUSE/NFS a split LOOKUP + CREATE lets a concurrent unlink() race the create, so ENOENT is transient # contention. An empty or separator-terminated path (Path() strips the separator) fails this way on # every attempt on macOS, so re-raise rather than retry forever. if self._lock_path and not self._lock_path.endswith(os.sep) and Path(self._lock_path).parent.exists(): return None raise except PermissionError: # Sticky-bit dirs (e.g. /tmp): O_CREAT fails if another user owns the file (#317). if not Path(self._lock_path).exists(): raise return self._open_existing_lock_file(open_flags, open_mode) def _open_existing_lock_file(self, open_flags: int, open_mode: int) -> tuple[int, bool] | None: try: return os.open(self._lock_path, open_flags, open_mode), False except FileNotFoundError: return None # unlinked since the create attempt; let the retry loop start over except BlockingIOError: return None # a lease is breaking: contention, so the retry loop keeps the deadline except PermissionError: # A marker another account left is readable but not writable. Without flock, waiting on it only needs # reading it; with flock the lock writes its file, so the denial stands. try: fd = os.open(self._lock_path, open_flags & ~os.O_RDWR | os.O_RDONLY) except (FileNotFoundError, BlockingIOError): return None # unlinked, or leased: the kernel checks permission before the lease if _flock_is_missing(fd): return fd, False os.close(fd) raise def _switch_to_soft_lock(self, fd: int, missing_flock: OSError, *, created: bool) -> None: # The filesystem does not implement flock. Fail closed when the caller opted out of existence-lock # semantics (#603), asked to preserve the pathname (#605), or set an on_acquired hook (#607), none of which # a soft lock can honor. falls_back: Final = self._fallback_to_soft and not self._preserve_lock_file and self._on_acquired is None try: # A file this attempt did not create may be a peer's live marker, so it stays. Our own placeholder goes # before the close: the open descriptor keeps its inode number from a peer's marker, and an exchange # puts back a peer's marker that replaced it. if created and falls_back: with suppress(OSError): remove_own_file(self._lock_path, fd) finally: self._mark_descriptor_released() os.close(fd) if not falls_back: raise missing_flock self._fallback_to_soft_lock() self._acquire() def _finalize_locked_fd(self, fd: int) -> bool: # Runs with the flock held; the caller closes fd on failure. A concurrent _release() may have unlinked # the file between our open() and flock(), which st_nlink 0 shows. That lock guards no name, so report # it and let the retry loop start over. if (stat_result := os.fstat(fd)).st_nlink == 0: return False self._mark_descriptor_owned(fd, (stat_result.st_dev, stat_result.st_ino)) return True def _apply_explicit_mode(self, fd: int) -> None: if self.has_explicit_mode: with suppress(PermissionError): os.fchmod(fd, self._context.mode) def _fallback_to_soft_lock(self: BaseFileLock) -> None: # Import lazily: this runs only on the rare flock fallback, and asyncio imports _unix, so a # module-level import of it here would cycle. from filelock._soft import SoftFileLock # ruff:ignore[import-outside-top-level] warnings.warn("flock not supported on this filesystem, falling back to SoftFileLock", stacklevel=2) from filelock.asyncio import AsyncSoftFileLock, BaseAsyncFileLock # ruff:ignore[import-outside-top-level] self.__class__ = AsyncSoftFileLock if isinstance(self, BaseAsyncFileLock) else SoftFileLock def _release(self) -> None: fd = cast("int", self._context.lock_file_fd) # Retain the descriptor until flock succeeds: a failed unlock leaves the kernel lock held, so is_locked # must keep reporting held for a retry. Once flock commits, clear held state and close as post-unlock # cleanup; a close failure (EIO on FUSE/Docker bind mounts) does not make the kernel lock held again. _unlock_fd(fd) self._mark_descriptor_released() self._close_released_fd(fd, default_suppresses=True) if sys.platform == "win32": # pragma: win32 cover __all__ = ["UnixFileLock", "has_fcntl"] else: # pragma: win32 no cover __all__ = ["UnixFileLock", "_lock_fd_nonblocking", "_unlock_fd", "has_fcntl"] filelock-4.2.8/src/filelock/_util.py0000644000000000000000000005013515272561277014332 0ustar00from __future__ import annotations __lazy_modules__ = ["pathlib", "platform", "secrets"] import errno import os import platform import secrets import stat import sys import time from contextlib import contextmanager, suppress from errno import EACCES, EINVAL, EIO, EISDIR, ENOSYS, EXDEV from functools import cache from pathlib import Path from typing import TYPE_CHECKING, Final, cast if TYPE_CHECKING: import ctypes from collections.abc import Callable, Generator from contextlib import AbstractContextManager def write_all(fd: int, data: bytes) -> None: """ Write the whole buffer to *fd*, looping over the short writes POSIX lets ``os.write`` make. A caller that stopped after one short write would leave a truncated marker behind. A peer that reads between two writes sees a prefix of the record; each caller has to tolerate that. Lock records need not survive a power loss, so we skip ``fsync``. :param fd: file descriptor open for writing. :param data: bytes to write in full. :raises OSError: if a write reports zero progress before the record is complete. """ remaining = memoryview(data) while remaining: if (written := os.write(fd, remaining)) == 0: raise OSError(EIO, "os.write wrote 0 bytes before the record was complete") remaining = remaining[written:] def raise_on_not_writable_file(filename: str) -> None: """ Raise an exception if attempting to open the file for writing would fail. Separates files that can never be written from files that are writable but currently locked. :param filename: file to check :raises OSError: as if the file was opened for writing. """ try: # lstat, not stat: settles exists-and-writable in one syscall, and a hostile symlink at the lock path would # make stat inspect the link target, letting an attacker turn a contended acquire into a misleading # PermissionError / IsADirectoryError and probe that target's attributes. The real open passes O_NOFOLLOW and # refuses the symlink anyway. file_stat = os.lstat(filename) except OSError: return # does not exist, or an error the caller cannot act on if stat.S_ISDIR(file_stat.st_mode): if sys.platform == "win32": # pragma: win32 cover raise PermissionError(EACCES, "Permission denied", filename) raise IsADirectoryError(EISDIR, "Is a directory", filename) # pragma: win32 no cover # No mtime guard: the old `if st_mtime != 0` skip covered NFS/Linux quirks where os.lstat returned an all-zero # struct, which it no longer does. Skipping on mtime 0 let a read-only file or a directory at the lock path pass # as missing, so acquire() blocked forever on an open that cannot succeed. # Match open() credentials where supported; group permissions and ACLs can grant access without S_IWUSR. Do not # follow a symlink, as the lstat above does not: one swapped in since would be judged by its target. if not (file_stat.st_mode & stat.S_IWUSR) and not os.access( filename, os.W_OK, effective_ids=os.access in os.supports_effective_ids, follow_symlinks=os.access not in os.supports_follow_symlinks, ): try: os.lstat(filename) except FileNotFoundError: # A holder may unlink its marker between lstat() and access(); let the caller attempt creation. return raise PermissionError(EACCES, "Permission denied", filename) def ensure_directory_exists(filename: Path | str) -> None: """ Ensure the directory containing the file exists (create it if necessary). :param filename: file. """ Path(filename).parent.mkdir(parents=True, exist_ok=True) @contextmanager def pin_inode(lock_file: str) -> Generator[int | None]: """ Keep the file at *lock_file* allocated for the length of the block and yield its inode number. An inode is the kernel's identity for a file, independent of its name, and its number names a file only while the inode stays allocated. ext4 hands a freed number to the next file created. Once a stale marker is unlinked, the next holder's marker can therefore carry the number a contender read a moment ago. A caller that reads the marker inside the block, gives up unless it read the yielded inode, and breaks the marker before leaving can rely on the number. :param lock_file: path to the lock file about to be read and perhaps broken. :returns: the pinned inode number, or ``None`` where nothing can be pinned. Windows cannot rename a file this process holds open, so it needs no pin. Without ``O_PATH`` a symlink or a socket cannot be opened. :raises FileNotFoundError: if the path is gone. """ if sys.platform == "win32": # pragma: win32 cover yield None else: # pragma: win32 no cover # O_PATH pins any node, a symlink or socket included, without read permission and without the filesystem seeing # an open; elsewhere a nonblocking read-only open pins what it can. flags: Final = getattr(os, "O_PATH", os.O_RDONLY | getattr(os, "O_NONBLOCK", 0)) | getattr(os, "O_NOFOLLOW", 0) try: fd = os.open(lock_file, flags) except FileNotFoundError: raise except OSError: yield None return try: yield os.fstat(fd).st_ino finally: os.close(fd) def remove_own_file(lock_file: str, fd: int) -> None: # pragma: win32 no cover """ Remove *lock_file* while it still names the file open as *fd*; the caller closes the descriptor afterwards. Where the platform has an atomic exchange, a stale break takes the file through it, so a peer's marker found at the path goes back. Otherwise the path is unlinked by name if it still names this file. The open descriptor keeps the inode allocated, so a matching number cannot belong to a successor's marker. :raises OSError: if the descriptor cannot be read or the unlink fails. """ own: Final = os.fstat(fd) with suppress(FileNotFoundError): break_lock_file(lock_file, own.st_mtime, own.st_ino) if os.path.samestat(os.lstat(lock_file), own): Path(lock_file).unlink() def foreign_directory_owner(directory: os.stat_result, parent: os.stat_result) -> int | None: """ Return the uid that owns *directory* when another account controls it under a sticky parent. A lock that keeps its ownership records in a directory relies on them staying in place. The sticky bit (a directory flag that lets only a file's owner remove it) on a parent such as ``/tmp`` stops other accounts from renaming the directory away. A directory that this account or the parent's owner owns is therefore safe there. One that another account owns is outside the lock's control. Without the sticky bit, any process that can create the directory can also rename this account's own one away, so a refusal there would change nothing. Linux applies the same rule for ``fs.protected_regular``. :param directory: ``lstat`` of the directory found in place. :param parent: ``stat`` of the directory that holds it. :returns: the owning uid when another account controls the directory, otherwise ``None``. """ if sys.platform != "win32" and parent.st_mode & stat.S_ISVTX: # pragma: needs foreign-sticky-tmp return None if directory.st_uid in {os.geteuid(), parent.st_uid} else directory.st_uid return None def break_lock_file(lock_file: str, mtime_before: float, ino_before: int) -> None: """ Remove the lock file a caller found stale with modification time *mtime_before* and inode *ino_before*. The caller keeps the judged file open until we return, through :func:`pin_inode` or its own descriptor, so no other file can carry *ino_before* meanwhile. Right before breaking we ``lstat`` the lock again and leave it alone if a peer recreated it since the caller judged it stale, as Mercurial re-reads a lock under its break lock. On Linux (``renameat2``) and macOS (``renamex_np``) the platform can exchange two names in one step. There we swap the marker with a placeholder of our own, so the lock path never stands empty, and a contender that polls meanwhile finds a file it cannot parse and waits. If the file we took is not the one judged stale, a peer recreated the lock, and a second exchange puts it back. Otherwise we remove it and then our placeholder. Of several processes racing to break the same lock, only one takes the stale file. Windows cannot exchange names, but it refuses to rename a marker its live holder keeps open. There we rename the marker aside and, if it was recreated after all, link it back. ``link`` never replaces a name, so a marker a third process created meanwhile keeps the path. Elsewhere without an exchange (NFS, SMB and other filesystems that refuse it, platforms without the call) a rename would leave the path empty beside a possibly live holder. We then do not move the marker, and the caller waits for ``timeout`` or ``break_lock()``. We compare inodes as well as modification times, because NFS and FAT store coarse modification times. The times are compared for equality, since a file's owner can set any time. We ``lstat`` so a symlink a peer swaps in is never followed. The break name carries a random token so no other process can guess it. :param lock_file: path to the lock file to break. :param mtime_before: modification time the caller saw when it judged the lock stale. :param ino_before: inode number the caller saw when it judged the lock stale. :raises OSError: if taking the marker fails, for example because the file vanished, another user owns it in a sticky directory, or on Windows its holder keeps it open. """ if (current := os.lstat(lock_file)).st_mtime != mtime_before or current.st_ino != ino_before: return break_path: Final[str] = f"{lock_file}.break.{os.getpid()}.{secrets.token_hex(16)}" if exchange_supported(): # pragma: win32 no cover _swap_out(lock_file, break_path, mtime_before, ino_before) elif _MOVES_WITHOUT_EXCHANGE: # pragma: win32 cover _break_by_rename(lock_file, break_path, mtime_before, ino_before) def _swap_out(lock_file: str, break_path: str, mtime_before: float, ino_before: int) -> None: # pragma: win32 no cover # An atomic exchange with a placeholder takes the marker without the lock path ever being empty. No contender can # then create a marker while this one is judged, and a live marker goes back the same way. fd: Final = os.open(break_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600) try: try: # Not a record any lock writes, so a contender that reads it waits, and one left by a crash ages out. The # PID tells the contender that evicts it whose break names to search for a marker this process left. write_all(fd, f"filelock/break\n{os.getpid()}\n".encode()) exchanged = exchange_names(break_path, lock_file) except OSError: exchanged = False if not exchanged: Path(break_path).unlink() return if (grabbed := os.lstat(break_path)).st_mtime == mtime_before and grabbed.st_ino == ino_before: Path(break_path).unlink() # Our descriptor keeps the placeholder's inode allocated, so a matching number at the path is still ours. if os.path.samestat(os.lstat(lock_file), os.fstat(fd)): Path(lock_file).unlink() else: exchange_back(lock_file, break_path, fd) finally: os.close(fd) def exchange_back(lock_file: str, aside: str, fd: int) -> bool: # pragma: win32 no cover # Raced breakers nest: each aside holds the prior marker. Returning out of order can strand a live holder's marker. # A peer may move the path between our check and exchange, so stalled breakers exchange to open any resulting ring. try: settled = _exchange_until_aside(lock_file, aside, os.fstat(fd)) except FileNotFoundError: settled = True if settled: Path(aside).unlink(missing_ok=True) return settled def _exchange_until_aside(lock_file: str, aside: str, own: os.stat_result) -> bool: # pragma: win32 no cover # Peers need a few system calls to unwind; a paused or dead peer must not block the caller indefinitely. deadline: Final = time.monotonic() + 2.0 stall: Final = 0.01 feed_at = time.monotonic() + stall while not os.path.samestat(os.lstat(aside), own): now = time.monotonic() ours = os.path.samestat(os.lstat(lock_file), own) if not ours and now >= deadline: return False if ours or now >= feed_at: if not exchange_names(aside, lock_file): return False feed_at = now + stall else: time.sleep(0.0005) return True def _break_by_rename( lock_file: str, break_path: str, mtime_before: float, ino_before: int ) -> None: # pragma: win32 cover # Renaming the marker aside empties the path until a recreated live marker is put back. link never replaces a name, # so a marker a third process created meanwhile keeps the path, and the live one waits under the break name. # Without os.link, or where the filesystem refuses hard links, the live one waits there too. with _pinned_for_rename(lock_file): Path(lock_file).rename(break_path) if (st_after := os.lstat(break_path)).st_mtime == mtime_before and st_after.st_ino == ino_before: unlink_path(break_path) elif stat.S_ISREG(st_after.st_mode) and hasattr(os, "link"): # pragma: needs hard-link with suppress(OSError): os.link(break_path, lock_file, follow_symlinks=False) unlink_path(break_path) if sys.platform == "win32": # pragma: win32 cover def _pinned_for_rename(path: str) -> AbstractContextManager[object]: # A rename by path waits on a Level 1 or batch oplock holder until it answers or closes. Opening first with # FILE_COMPLETE_IF_OPLOCKED turns that wait into BlockingIOError, and the open handle shares delete, so the # rename then proceeds. Imported here because _windows imports this module. from filelock._windows import open_for_delete_without_oplock_wait # ruff:ignore[import-outside-top-level] return os.fdopen(open_for_delete_without_oplock_wait(path), "rb") def unlink_path(path: str) -> None: from filelock._windows import unlink_without_oplock_wait # ruff:ignore[import-outside-top-level] unlink_without_oplock_wait(path) else: # pragma: win32 no cover def unlink_path(path: str) -> None: Path(path).unlink() def exchange_supported() -> bool: """Whether this platform has a call that swaps two names in one step; a filesystem may still refuse it.""" return _rename_call() is not None def exchange_names(source: str, target: str) -> bool: # pragma: win32 no cover """ Swap the names *source* and *target* in one step, so neither ever stands empty, where :func:`exchange_supported`. :returns: ``False`` where the filesystem cannot, so the caller falls back :raises OSError: for any other failure, such as a missing name or a denied rename """ renamer, exchange_flag = cast("tuple[Callable[[bytes, bytes, int], int], int]", _rename_call()) if not (error := renamer(os.fsencode(source), os.fsencode(target), exchange_flag)): return True # EXDEV: overlayfs and some FUSE filesystems refuse an exchange between layers. if error in {EINVAL, ENOSYS, EXDEV, getattr(errno, "ENOTSUP", EINVAL), getattr(errno, "EOPNOTSUPP", EINVAL)}: return False raise OSError(error, os.strerror(error), source, None, target) @cache def _rename_call() -> tuple[Callable[[bytes, bytes, int], int], int] | None: # The call returns 0 or the errno. Looked up on first use, since most processes never break a lock and ctypes is # slow to import. None where ctypes is missing (GraalPy) or the platform has no exchange. try: import ctypes # ruff:ignore[import-outside-top-level] # only a stale break needs it except ImportError: # pragma: no cover # GraalPy ships without ctypes return None if sys.platform in {"linux", "android"}: # pragma: linux cover if (renameat2 := _renameat2(ctypes.CDLL(None, use_errno=True))) is None: return None # pragma: no cover # an architecture the CI matrix does not run return renameat2, 2 # RENAME_EXCHANGE in linux/fs.h if sys.platform == "darwin": # pragma: darwin cover renamex_np: Final = ctypes.CDLL(None, use_errno=True).renamex_np renamex_np.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint] renamex_np.restype = ctypes.c_int def call_renamex_np(source: bytes, target: bytes, flags: int) -> int: return 0 if renamex_np(source, target, flags) == 0 else ctypes.get_errno() return call_renamex_np, 0x2 # RENAME_SWAP in sys/stdio.h return None # pragma: linux no cover # pragma: android no cover # pragma: darwin no cover def _renameat2(libc: ctypes.CDLL) -> Callable[[bytes, bytes, int], int] | None: # pragma: linux cover # Reached through syscall(2) rather than the C library's wrapper. glibc 2.28, musl 1.2.2 and bionic API 30 added # the wrapper, and Termux ships an older bionic; the kernel has had the call since 3.15. The number is the # architecture's own, so an architecture outside the table gets no exchange rather than another call. import ctypes # ruff:ignore[import-outside-top-level] # only a stale break needs it numbers: Final = { "x86_64": 316, "i386": 353, "i686": 353, "armv7l": 382, "armv8l": 382, "aarch64": 276, "riscv64": 276, "loongarch64": 276, "ppc64le": 357, "s390x": 347, } if (number := numbers.get(platform.machine())) is None: return None # pragma: no cover # an architecture the CI matrix does not run syscall: Final = libc.syscall syscall.restype = ctypes.c_long at_fdcwd: Final = -100 def call_renameat2(source: bytes, target: bytes, flags: int) -> int: arguments: Final = ( ctypes.c_long(number), ctypes.c_int(at_fdcwd), ctypes.c_char_p(source), ctypes.c_int(at_fdcwd), ctypes.c_char_p(target), ctypes.c_uint(flags), ) return 0 if syscall(*arguments) == 0 else ctypes.get_errno() return call_renameat2 def touch(name: str, *, fd: int) -> None: # Prefer the already-open, already-verified fd so a peer that swaps a symlink or a different file in at the # path after our O_NOFOLLOW read cannot redirect the touch: utime then targets the inode behind the fd. # Where the platform cannot utime an fd, fall back to a path-based touch that still refuses to follow a # symlink where supported, matching the O_NOFOLLOW reads used elsewhere here. if _SUPPORTS_UTIME_FD: # pragma: needs utime-fd os.utime(fd, None) return os.utime(name, None, follow_symlinks=not _SUPPORTS_UTIME_NOFOLLOW) # pragma: lacks utime-fd # Windows refuses to rename a marker its live holder keeps open, so renaming aside only moves a marker nobody holds. # Elsewhere without an exchange a contender could take the emptied path beside a live holder, so the marker stays and # the lock counts as held. _MOVES_WITHOUT_EXCHANGE: Final[bool] = sys.platform == "win32" # Retargeting os.utime to an open fd lets a heartbeat refresh the exact inode it verified instead of whatever the # pathname now names. _SUPPORTS_UTIME_FD: Final[bool] = sys.platform != "win32" and os.utime in os.supports_fd # os.utime follows symlinks unless told not to; not every platform can refuse the follow, so probe support. _SUPPORTS_UTIME_NOFOLLOW: Final[bool] = os.utime in os.supports_follow_symlinks __all__ = [ "break_lock_file", "ensure_directory_exists", "exchange_back", "exchange_names", "exchange_supported", "foreign_directory_owner", "pin_inode", "raise_on_not_writable_file", "remove_own_file", "touch", "unlink_path", "write_all", ] filelock-4.2.8/src/filelock/_windows.py0000644000000000000000000011374015272561277015051 0ustar00from __future__ import annotations __lazy_modules__ = ["filelock._util", "msvcrt", "struct"] import os import struct import sys import time from contextlib import contextmanager, suppress from errno import ENOENT, ENOTDIR, EWOULDBLOCK from functools import cache from typing import TYPE_CHECKING, Final, cast from filelock._api import BaseFileLock from filelock._util import raise_on_not_writable_file if TYPE_CHECKING: from collections.abc import Generator if sys.platform == "win32": # pragma: win32 cover import ctypes import msvcrt from ctypes import wintypes _GENERIC_READ: Final[int] = 0x80000000 _GENERIC_WRITE: Final[int] = 0x40000000 _SYNCHRONIZE: Final[int] = 0x00100000 _DELETE: Final[int] = 0x00010000 _DESIRED_ACCESS: Final[int] = _GENERIC_READ | _GENERIC_WRITE | _SYNCHRONIZE _FILE_SHARE_READ_WRITE: Final[int] = ( 0x00000001 | 0x00000002 ) # read | write; matches os.open (_SH_DENYNO), no delete _FILE_OPEN: Final[int] = 1 # open the file only if it exists; the NtCreateFile OPEN_EXISTING _FILE_OPEN_IF: Final[int] = 3 # open the file if it exists, create it otherwise; the NtCreateFile OPEN_ALWAYS # Attribute-only access skips share-mode checks, so the probe sees a name's deletion state rather than a reader. _FILE_READ_ATTRIBUTES: Final[int] = 0x00000080 _FILE_SHARE_ALL: Final[int] = 0x00000001 | 0x00000002 | 0x00000004 # read | write | delete _FILE_ATTRIBUTE_READONLY: Final[int] = 0x00000001 _FILE_ATTRIBUTE_NORMAL: Final[int] = 0x00000080 _FILE_ATTRIBUTE_REPARSE_POINT: Final[int] = 0x00000400 # CreateOptions: keep the handle synchronous (the CRT and msvcrt.locking rely on a maintained file position), # refuse a directory, return at once instead of waiting for an oplock holder to answer the break, and open a # reparse point rather than following it so the check below acts on the link itself. _FILE_SYNCHRONOUS_IO_NONALERT: Final[int] = 0x00000020 _FILE_NON_DIRECTORY_FILE: Final[int] = 0x00000040 _FILE_DELETE_ON_CLOSE: Final[int] = 0x00001000 _FILE_COMPLETE_IF_OPLOCKED: Final[int] = 0x00000100 _FILE_OPEN_REPARSE_POINT: Final[int] = 0x00200000 _CREATE_OPTIONS: Final[int] = ( _FILE_SYNCHRONOUS_IO_NONALERT | _FILE_NON_DIRECTORY_FILE | _FILE_COMPLETE_IF_OPLOCKED | _FILE_OPEN_REPARSE_POINT ) # Listing access takes part in share-mode checks, unlike attribute-only access, so a handle that holds it without # FILE_SHARE_DELETE blocks renames and removals of the directory until it closes. _FILE_LIST_DIRECTORY: Final[int] = 0x00000001 _FILE_DIRECTORY_FILE: Final[int] = 0x00000001 _DIRECTORY_OPTIONS: Final[int] = _FILE_SYNCHRONOUS_IO_NONALERT | _FILE_DIRECTORY_FILE | _FILE_OPEN_REPARSE_POINT _OBJ_CASE_INSENSITIVE: Final[int] = 0x00000040 # Win32 name lookups are case-insensitive _OWNER_WRITE: Final[int] = 0o200 _GROUP_AND_OTHERS: Final[int] = 0o077 # The DACL os.mkdir(mode=0o700) applies, less the inheritance flags a file has no use for: protected, so the # directory's inheritable entries stay out, with full access for SYSTEM, Administrators and the owner. _OWNER_ONLY_SDDL: Final[str] = "D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;OW)" _SDDL_REVISION_1: Final[int] = 1 _INVALID_HANDLE_VALUE: Final[int | None] = ctypes.c_void_p(-1).value # LockFileEx locks a byte range at an offset carried in OVERLAPPED, independent of the descriptor's file position. # msvcrt.locking starts at the current position instead, so a metadata write between lock and unlock could shift # the byte a later unlock targets; the explicit offset removes that hazard for both the path lock and #608's # descriptor lock. _LOCKFILE_FAIL_IMMEDIATELY: Final[int] = 0x00000001 _LOCKFILE_EXCLUSIVE_LOCK: Final[int] = 0x00000002 _ERROR_ACCESS_DENIED: Final[int] = 5 _ERROR_LOCK_VIOLATION: Final[int] = 33 # another handle holds the byte range _ERROR_NOT_LOCKED: Final[int] = 158 # the byte range is not locked # NtCreateFile returns the raw NTSTATUS as its value, where CreateFileW collapses several of these into one # ERROR_ACCESS_DENIED. Telling them apart is the point (#604): a name pending deletion or a share conflict is # transient and worth a retry, a real access denial is not. _STATUS_SUCCESS: Final[int] = 0x00000000 # A success code that comes with a handle: another handle's oplock is breaking and its holder has not answered yet. _STATUS_OPLOCK_BREAK_IN_PROGRESS: Final[int] = 0x00000108 _STATUS_ACCESS_DENIED: Final[int] = 0xC0000022 _STATUS_OBJECT_NAME_NOT_FOUND: Final[int] = 0xC0000034 _STATUS_OBJECT_PATH_NOT_FOUND: Final[int] = 0xC000003A _STATUS_SHARING_VIOLATION: Final[int] = 0xC0000043 _STATUS_DELETE_PENDING: Final[int] = 0xC0000056 #: Probes, 2 ms apart, for transient STATUS_ACCESS_DENIED while a peer unlinks the name (#604). A count ensures #: every probe runs even when the process stalls; oplock breaks use the caller's wait instead. _ACCESS_DENIED_PROBES: Final[int] = 250 _ACCESS_DENIED_GRACE: Final[float] = 0.5 _ACCESS_DENIED_RETRY: Final[float] = 0.002 _ntdll: Final[ctypes.WinDLL] = ctypes.WinDLL("ntdll") _kernel32: Final[ctypes.WinDLL] = ctypes.WinDLL("kernel32", use_last_error=True) _advapi32: Final[ctypes.WinDLL] = ctypes.WinDLL("advapi32", use_last_error=True) class _UNICODE_STRING(ctypes.Structure): # ruff:ignore[invalid-class-name] # mirrors the Win32 struct name _fields_ = ( ("Length", wintypes.USHORT), # byte length, not character count ("MaximumLength", wintypes.USHORT), ("Buffer", wintypes.LPWSTR), ) class _OBJECT_ATTRIBUTES(ctypes.Structure): # ruff:ignore[invalid-class-name] # mirrors the Win32 struct name _fields_ = ( ("Length", wintypes.ULONG), ("RootDirectory", wintypes.HANDLE), ("ObjectName", ctypes.POINTER(_UNICODE_STRING)), ("Attributes", wintypes.ULONG), ("SecurityDescriptor", ctypes.c_void_p), ("SecurityQualityOfService", ctypes.c_void_p), ) class _IO_STATUS_BLOCK(ctypes.Structure): # ruff:ignore[invalid-class-name] # mirrors the Win32 struct name _fields_ = ( ("Status", ctypes.c_void_p), # a union of NTSTATUS and PVOID, so it is pointer-sized ("Information", ctypes.c_void_p), ) class _SECURITY_ATTRIBUTES(ctypes.Structure): # ruff:ignore[invalid-class-name] # mirrors the Win32 struct name _fields_ = ( ("nLength", wintypes.DWORD), ("lpSecurityDescriptor", ctypes.c_void_p), ("bInheritHandle", wintypes.BOOL), ) class _OVERLAPPED(ctypes.Structure): # mirrors the Win32 struct name _fields_ = ( ("Internal", ctypes.c_void_p), # ULONG_PTR: pointer-sized, not DWORD, or the x64 layout corrupts Offset ("InternalHigh", ctypes.c_void_p), ("Offset", wintypes.DWORD), # the DUMMYUNIONNAME struct, flattened: low 32 bits of the byte offset ("OffsetHigh", wintypes.DWORD), ("hEvent", wintypes.HANDLE), ) class _BY_HANDLE_FILE_INFORMATION(ctypes.Structure): # ruff:ignore[invalid-class-name] # mirrors the Win32 struct name _fields_ = ( ("dwFileAttributes", wintypes.DWORD), ("ftCreationTime", wintypes.FILETIME), ("ftLastAccessTime", wintypes.FILETIME), ("ftLastWriteTime", wintypes.FILETIME), ("dwVolumeSerialNumber", wintypes.DWORD), ("nFileSizeHigh", wintypes.DWORD), ("nFileSizeLow", wintypes.DWORD), ("nNumberOfLinks", wintypes.DWORD), ("nFileIndexHigh", wintypes.DWORD), ("nFileIndexLow", wintypes.DWORD), ) _ntdll.NtCreateFile.restype = wintypes.LONG # NTSTATUS _ntdll.NtCreateFile.argtypes = [ ctypes.POINTER(wintypes.HANDLE), wintypes.DWORD, ctypes.POINTER(_OBJECT_ATTRIBUTES), ctypes.POINTER(_IO_STATUS_BLOCK), ctypes.POINTER(ctypes.c_longlong), # PLARGE_INTEGER AllocationSize wintypes.ULONG, wintypes.ULONG, wintypes.ULONG, wintypes.ULONG, ctypes.c_void_p, wintypes.ULONG, ] _ntdll.RtlDosPathNameToNtPathName_U_WithStatus.restype = wintypes.LONG # NTSTATUS _ntdll.RtlDosPathNameToNtPathName_U_WithStatus.argtypes = [ wintypes.LPCWSTR, ctypes.POINTER(_UNICODE_STRING), ctypes.c_void_p, ctypes.c_void_p, ] _ntdll.RtlFreeUnicodeString.restype = None _ntdll.RtlFreeUnicodeString.argtypes = [ctypes.POINTER(_UNICODE_STRING)] _ntdll.RtlNtStatusToDosError.restype = wintypes.ULONG _ntdll.RtlNtStatusToDosError.argtypes = [wintypes.LONG] _kernel32.CloseHandle.argtypes = [wintypes.HANDLE] _kernel32.CloseHandle.restype = wintypes.BOOL _kernel32.GetFileInformationByHandle.argtypes = [wintypes.HANDLE, ctypes.POINTER(_BY_HANDLE_FILE_INFORMATION)] _kernel32.GetFileInformationByHandle.restype = wintypes.BOOL _kernel32.LockFileEx.argtypes = [ wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(_OVERLAPPED), ] _kernel32.LockFileEx.restype = wintypes.BOOL _kernel32.UnlockFileEx.argtypes = [ wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(_OVERLAPPED), ] _kernel32.UnlockFileEx.restype = wintypes.BOOL _advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = [ wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(wintypes.ULONG), ] _advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.restype = wintypes.BOOL _advapi32.GetNamedSecurityInfoW.argtypes = [ wintypes.LPCWSTR, ctypes.c_int, wintypes.DWORD, ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(ctypes.c_void_p), ] _advapi32.GetNamedSecurityInfoW.restype = wintypes.DWORD _advapi32.OpenThreadToken.argtypes = [ wintypes.HANDLE, wintypes.DWORD, wintypes.BOOL, ctypes.POINTER(wintypes.HANDLE), ] _advapi32.OpenThreadToken.restype = wintypes.BOOL _advapi32.OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)] _advapi32.OpenProcessToken.restype = wintypes.BOOL _advapi32.GetTokenInformation.argtypes = [ wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), ] _advapi32.GetTokenInformation.restype = wintypes.BOOL _kernel32.GetCurrentProcess.restype = wintypes.HANDLE _kernel32.GetCurrentThread.restype = wintypes.HANDLE _kernel32.LocalFree.argtypes = [ctypes.c_void_p] _kernel32.LocalFree.restype = ctypes.c_void_p _kernel32.CreateDirectoryW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(_SECURITY_ATTRIBUTES)] _kernel32.CreateDirectoryW.restype = wintypes.BOOL _kernel32.CreateFileW.argtypes = [ wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE, ] _kernel32.CreateFileW.restype = ctypes.c_void_p def _lock_fd_nonblocking(fd: int) -> bool: # One nonblocking exclusive LockFileEx attempt shared by WindowsFileLock and lock_descriptor, over the one-byte # range at offset 0. True on acquisition, False on contention, raise otherwise. The caller owns fd; the handle # from get_osfhandle belongs to the CRT descriptor and must not be closed here. overlapped = _OVERLAPPED() # zero-initialized, so Offset/OffsetHigh/hEvent are 0 flags = _LOCKFILE_EXCLUSIVE_LOCK | _LOCKFILE_FAIL_IMMEDIATELY if _kernel32.LockFileEx(msvcrt.get_osfhandle(fd), flags, 0, 1, 0, ctypes.byref(overlapped)): return True err = ctypes.get_last_error() if err == _ERROR_LOCK_VIOLATION: return False # A non-contention LockFileEx failure is not reproducible in-process. raise ctypes.WinError(err) # pragma: no cover def _unlock_fd(fd: int) -> None: overlapped = _OVERLAPPED() # the same offset 0 and one-byte length the lock used if ( # pragma: no cover # only an interrupted earlier release leaves our own range unlocked not _kernel32.UnlockFileEx(msvcrt.get_osfhandle(fd), 0, 1, 0, ctypes.byref(overlapped)) and (err := ctypes.get_last_error()) != _ERROR_NOT_LOCKED ): raise ctypes.WinError(err) class WindowsFileLock(BaseFileLock): """ Uses ``LockFileEx`` to hard lock a byte range of the lock file on Windows systems. Lock file cleanup: Windows attempts to delete the lock file after release, but deletion is not guaranteed in multi-threaded scenarios where another thread holds an open handle. The lock file may persist on disk, which does not affect lock correctness. """ def _acquire(self) -> None: raise_on_not_writable_file(self._lock_path) # The reparse test is bound to the opened handle, so a symlink or junction swapped in cannot defeat it # through a check-then-open TOCTOU race. try: fd = _open_non_reparse_fd(self._lock_path, self._open_mode()) except BlockingIOError: return # an oplock holder has not answered its break; let the retry loop keep its deadline if fd is None: return # open contention (share conflict or a name pending deletion); let the retry loop try again try: locked = _lock_fd_nonblocking(fd) if locked: self._mark_descriptor_owned(fd) except BaseException: # Closing drops the byte-range lock, so a hold already recorded for this descriptor must go with it. self._mark_descriptor_released() os.close(fd) raise if not locked: os.close(fd) # another holder owns the byte-range lock; let the retry loop try again def _release(self) -> None: fd = cast("int", self._context.lock_file_fd) # Retain the descriptor until the OS unlock succeeds: if UnlockFileEx raises, the byte-range lock is still # held, so is_locked must keep reporting held rather than losing the fd. Only after the unlock commits do # close and unlink run as post-unlock cleanup; their failure cannot make the lock held again. _unlock_fd(fd) self._mark_descriptor_released() self._close_released_fd(fd, default_suppresses=False) if not self._preserve_lock_file: # preserve_lock_file keeps a stable file identity for the caller (#605) with suppress(OSError): unlink_without_oplock_wait(self._lock_path) def _open_non_reparse_fd(path: str, mode: int, *, oplock_wait: float = _ACCESS_DENIED_GRACE) -> int | None: """ Open *path* for locking while refusing reparse points, bound to the handle actually locked. The file is opened through ``NtCreateFile`` with ``FILE_OPEN_REPARSE_POINT`` so a symlink or junction planted at the path is not followed, and the reparse decision is read from *that* handle via ``GetFileInformationByHandle`` rather than from a prior pathname query. Reading the held handle closes the check-then-open race: an attacker cannot swap the path between validation and use because both act on the same handle. Share mode omits delete so a peer cannot unlink or rename the file out from under a live holder, matching ``os.open``'s ``_SH_DENYNO``. ``NtCreateFile`` is used instead of ``CreateFileW`` because its return value carries the raw ``NTSTATUS``. Windows collapses a transient delete-pending name and a permanent access denial into the same Win32 ``ERROR_ACCESS_DENIED``; the status keeps them apart, so a real denial fails fast instead of spinning until the caller's timeout (#604). The reparse option only guards the final path component; Windows still follows reparse points in intermediate directories. This assumes the lock file sits in a lock directory untrusted users cannot modify. A path with attacker-controlled parent directories would need component-by-component handle validation. :param path: the lock file path. :param mode: the permission mode; as ``os.open`` does on Windows, a cleared owner-write bit creates the file read-only. A mode without group and other bits creates it with an owner-only DACL (discretionary access control list, the Windows permission list); any other mode has no Windows counterpart and leaves the file the entries its directory hands down. Both only take effect when the file is created, not when an existing one is opened. :param oplock_wait: seconds to wait for the holder of an oplock on the file to answer its break. :returns: a file descriptor owning the opened handle, or ``None`` on a sharing violation or a delete-pending name, which the caller should treat as contention and retry. :raises BlockingIOError: if an oplock break stays unanswered for *oplock_wait* seconds. :raises OSError: if the path resolves to a reparse point, or the open fails for any other reason, raised with the Win32 error the status maps to. An access denial is raised only once it outlasts a run of probes, since a peer unlinking the file as it releases can make the open answer ``STATUS_ACCESS_DENIED`` for a moment. """ # Emit the audit event os.open would, so consumers watching "open" still see the path-level open and can veto. sys.audit("open", path, None, os.O_RDWR | os.O_CREAT) read_only = not mode & _OWNER_WRITE owner_only = not mode & 0o077 handle, status = _nt_open(path, read_only=read_only, owner_only=owner_only) denied_probes = 0 oplock_deadline = time.monotonic() + oplock_wait while True: if status == _STATUS_ACCESS_DENIED: if denied_probes >= _ACCESS_DENIED_PROBES: break denied_probes += 1 elif status == _STATUS_OPLOCK_BREAK_IN_PROGRESS: if time.monotonic() >= oplock_deadline: break else: break time.sleep(_ACCESS_DENIED_RETRY) handle, status = _nt_open(path, read_only=read_only, owner_only=owner_only) if status == _STATUS_OPLOCK_BREAK_IN_PROGRESS: msg = "oplock break in progress" raise BlockingIOError(EWOULDBLOCK, msg, path) if status != _STATUS_SUCCESS: if status in {_STATUS_SHARING_VIOLATION, _STATUS_DELETE_PENDING}: return None raise _status_error(status, path) return _descriptor_for_non_reparse(handle, path, os.O_RDWR) def open_read_without_oplock_wait(path: str) -> int: """ Open *path* read-only and return a descriptor, without waiting for an oplock holder to answer its break. ``os.open`` waits inside the system call until the holder of an oplock on the file answers or closes its handle, so no timeout or ``blocking=False`` ends the wait. A reparse point at *path* is refused rather than followed. :param path: the file to read. :returns: a read-only file descriptor. :raises BlockingIOError: if an oplock break is in progress, which callers treat as contention. :raises FileNotFoundError: if *path* does not exist. :raises OSError: if the open fails for any other reason, or *path* is a reparse point. """ sys.audit("open", path, None, os.O_RDONLY) return _open_existing_without_oplock_wait(path, _GENERIC_READ | _SYNCHRONIZE, os.O_RDONLY) def open_for_delete_without_oplock_wait(path: str) -> int: """ Open *path* with delete access and return a descriptor, without waiting for an oplock holder to answer. The open handle shares read, write and delete, so a rename of *path* can proceed while the caller keeps it. A holder that took an oplock through another handle is asked to answer by this open, which then raises, so the caller skips the rename instead of waiting for it. :param path: the file to rename. :returns: a file descriptor owning the handle; the caller closes it. :raises BlockingIOError: if an oplock break is in progress, which callers treat as contention. :raises FileNotFoundError: if *path* does not exist. :raises OSError: if the open fails for any other reason, or *path* is a reparse point. """ return _open_existing_without_oplock_wait(path, _DELETE | _FILE_READ_ATTRIBUTES | _SYNCHRONIZE, os.O_RDONLY) def unlink_without_oplock_wait(path: str) -> None: """ Delete *path* through a handle that does not wait for an oplock holder to answer its break. ``os.unlink`` waits like ``os.open`` does. Here an unanswered break leaves the file in place, which callers accept since deleting a lock file is best effort. A symlink at *path* is deleted as a link. :param path: the file to delete. :raises FileNotFoundError: if *path* does not exist. :raises OSError: if the open fails for any other reason, such as a sharing violation. """ sys.audit("os.remove", path, None) handle, status = _nt_create( path, _DELETE | _SYNCHRONIZE, _FILE_ATTRIBUTE_NORMAL, _FILE_SHARE_ALL, _FILE_OPEN, options=_CREATE_OPTIONS | _FILE_DELETE_ON_CLOSE, ) if status == _STATUS_SUCCESS: _kernel32.CloseHandle(handle) # deletes the file elif status != _STATUS_OPLOCK_BREAK_IN_PROGRESS: raise _status_error(status, path) def _open_existing_without_oplock_wait(path: str, access: int, flags: int) -> int: handle, status = _nt_create(path, access, _FILE_ATTRIBUTE_NORMAL, _FILE_SHARE_ALL, _FILE_OPEN) if status != _STATUS_SUCCESS: raise _status_error(status, path) return _descriptor_for_non_reparse(handle, path, flags) def _status_error(status: int, path: str) -> OSError: if status == _STATUS_OPLOCK_BREAK_IN_PROGRESS: return BlockingIOError(EWOULDBLOCK, "oplock break in progress", path) if status in {_STATUS_OBJECT_NAME_NOT_FOUND, _STATUS_OBJECT_PATH_NOT_FOUND}: return FileNotFoundError(ENOENT, os.strerror(ENOENT), path) winerror = _ntdll.RtlNtStatusToDosError(status) return OSError(None, ctypes.FormatError(winerror).strip(), path, winerror) def _descriptor_for_non_reparse(handle: int, path: str, flags: int) -> int: info = _BY_HANDLE_FILE_INFORMATION() # Querying an open handle we just created does not fail. if not _kernel32.GetFileInformationByHandle(handle, ctypes.byref(info)): # pragma: no cover err = ctypes.get_last_error() _kernel32.CloseHandle(handle) raise ctypes.WinError(err) if info.dwFileAttributes & _FILE_ATTRIBUTE_REPARSE_POINT: _kernel32.CloseHandle(handle) msg = f"Lock file is a reparse point (symlink/junction): {path}" raise OSError(msg) try: # O_NOINHERIT mirrors os.open on Windows: the lock fd must not leak into child processes. return msvcrt.open_osfhandle(handle, flags | os.O_NOINHERIT) except BaseException: # pragma: no cover # open_osfhandle audits too; a hook raising must not leak the handle _kernel32.CloseHandle(handle) raise def _open_deletable_for_reading(path: str) -> int: """ Open *path* read-only without blocking a concurrent unlink and without following a reparse point. ``os.open`` omits ``FILE_SHARE_DELETE``, so while a process has a file open that way, a delete of it fails with a sharing violation. Every contender reads a strict claim and its owner unlinks it, so a reader that keeps the claim open past the owner's retries makes the release fail. """ sys.audit("open", path, None, os.O_RDONLY) handle, status = _nt_create(path, _GENERIC_READ | _SYNCHRONIZE, 0, _FILE_SHARE_ALL, _FILE_OPEN) if status != _STATUS_SUCCESS: winerror = _ntdll.RtlNtStatusToDosError(status) raise OSError(None, ctypes.FormatError(winerror).strip(), path, winerror) try: return msvcrt.open_osfhandle(handle, os.O_RDONLY | os.O_NOINHERIT) except BaseException: # pragma: no cover # open_osfhandle audits too; a hook raising must not leak the handle _kernel32.CloseHandle(handle) raise def _pin_directory(path: str) -> int: """ Open the directory at *path* so that no process can rename or remove it until :func:`_unpin_directory`. Windows has no ``dir_fd``, so an operation resolves every path component by name again. A handle on each directory of a record path, opened without following a reparse point, keeps that name bound to the directory checked here until the operation ends. :raises NotADirectoryError: when *path* is a junction, a symlink or anything other than a directory. """ sys.audit("open", path, None, os.O_RDONLY) # GetFileInformationByHandle needs FILE_READ_ATTRIBUTES; listing access is what takes part in share checks. access = _FILE_LIST_DIRECTORY | _FILE_READ_ATTRIBUTES | _SYNCHRONIZE handle, status = _nt_create(path, access, 0, _FILE_SHARE_READ_WRITE, _FILE_OPEN, _DIRECTORY_OPTIONS) if status != _STATUS_SUCCESS: winerror = _ntdll.RtlNtStatusToDosError(status) raise OSError(None, ctypes.FormatError(winerror).strip(), path, winerror) info = _BY_HANDLE_FILE_INFORMATION() # Querying an open handle we just created does not fail. if not _kernel32.GetFileInformationByHandle(handle, ctypes.byref(info)): # pragma: no cover error = ctypes.get_last_error() _kernel32.CloseHandle(handle) raise ctypes.WinError(error) if info.dwFileAttributes & _FILE_ATTRIBUTE_REPARSE_POINT: _kernel32.CloseHandle(handle) raise NotADirectoryError(ENOTDIR, "is a junction or symlink, not a directory", path) return handle def _unpin_directory(handle: int) -> None: _kernel32.CloseHandle(handle) def _is_unlinked(path: str) -> bool: """ Report whether *path* is gone or pending deletion, which ``os.open`` reports as the same EACCES as a denial. NTFS keeps an unlinked name listed, refusing every open with ``STATUS_DELETE_PENDING``, until the last handle to the file closes. Another process holding such a handle, such as an antivirus scan, can keep it that way for longer than any retry grace, yet the name's owner has already removed it. """ handle, status = _nt_create(path, _FILE_READ_ATTRIBUTES | _SYNCHRONIZE, 0, _FILE_SHARE_ALL, _FILE_OPEN) if handle: _kernel32.CloseHandle(handle) return status in {_STATUS_OBJECT_NAME_NOT_FOUND, _STATUS_DELETE_PENDING} def _nt_open(path: str, *, read_only: bool, owner_only: bool) -> tuple[int, int]: """ Open or create *path* for locking through ``NtCreateFile`` and return ``(handle, status)``. Without *owner_only* a file this call creates gets no DACL of its own and inherits its directory's ACL. """ attributes = _FILE_ATTRIBUTE_READONLY if read_only else _FILE_ATTRIBUTE_NORMAL descriptor = _owner_only_descriptor() if owner_only else None return _nt_create( path, _DESIRED_ACCESS, attributes, _FILE_SHARE_READ_WRITE, _FILE_OPEN_IF, descriptor=descriptor ) @cache def _owner_only_descriptor() -> ctypes.c_void_p: # The DACL os.mkdir(mode=0o700) applies, minus the directory-only inheritance flags: protected (P), so the # directory's inheritable entries stay out, with full access (FA) for SYSTEM, Administrators and the owner. # Converted once and never freed: the open retries every few milliseconds while it waits. descriptor = ctypes.c_void_p() if not _advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW( "D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;OW)", 1, ctypes.byref(descriptor), None ): # pragma: no cover # a constant string converts raise ctypes.WinError(ctypes.get_last_error()) return descriptor def _nt_create( # ruff:ignore[too-many-arguments, too-many-positional-arguments] # NtCreateFile's own knobs path: str, access: int, attributes: int, share: int, disposition: int, options: int = _CREATE_OPTIONS, *, descriptor: ctypes.c_void_p | None = None, ) -> tuple[int, int]: """ Open *path* through ``NtCreateFile`` and return ``(handle, status)``. ``RtlDosPathNameToNtPathName_U_WithStatus`` translates the Win32 path to the NT namespace, handling relative, drive, UNC and extended-length path forms as Win32 itself would, and allocates a buffer that ``RtlFreeUnicodeString`` releases. The handle is ``0`` unless the status is ``STATUS_SUCCESS``: one that comes with ``STATUS_OPLOCK_BREAK_IN_PROGRESS`` is closed here, since the file is not ours until the oplock holder answers. """ nt_name = _UNICODE_STRING() status = _ntdll.RtlDosPathNameToNtPathName_U_WithStatus(path, ctypes.byref(nt_name), None, None) & 0xFFFFFFFF if status != _STATUS_SUCCESS: return 0, status try: object_attributes = _OBJECT_ATTRIBUTES() object_attributes.Length = ctypes.sizeof(_OBJECT_ATTRIBUTES) object_attributes.ObjectName = ctypes.pointer(nt_name) object_attributes.Attributes = _OBJ_CASE_INSENSITIVE object_attributes.SecurityDescriptor = descriptor handle = wintypes.HANDLE() io_status = _IO_STATUS_BLOCK() status = ( _ntdll.NtCreateFile( ctypes.byref(handle), access, ctypes.byref(object_attributes), ctypes.byref(io_status), None, attributes, share, disposition, options, None, 0, ) & 0xFFFFFFFF ) finally: _ntdll.RtlFreeUnicodeString(ctypes.byref(nt_name)) if status == _STATUS_OPLOCK_BREAK_IN_PROGRESS: _kernel32.CloseHandle(handle) if status != _STATUS_SUCCESS: return 0, status return handle.value or 0, status def _directory_is_private(path: str) -> bool: """ Report whether only the current user, ``SYSTEM`` and ``Administrators`` can create or remove names in *path*. A directory whose owner is anyone else can have its ACL rewritten by that owner. Entries that only a child inherits count too, since the DACL says nothing more precise about who may write. """ owner, dacl, descriptor = ctypes.c_void_p(), ctypes.c_void_p(), ctypes.c_void_p() error = _advapi32.GetNamedSecurityInfoW( path, 1, # SE_FILE_OBJECT 0x1 | 0x4, # OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION ctypes.byref(owner), None, ctypes.byref(dacl), None, ctypes.byref(descriptor), ) if error == _ERROR_ACCESS_DENIED: # C:\Windows\Temp is one. Anything that cannot be proven private is not. return False if error: # pragma: no cover # the directory was opened or listed an instant earlier raise ctypes.WinError(error) try: # SYSTEM, Administrators, CREATOR OWNER and OWNER RIGHTS; the last two stand for the owner. trusted = {_sid_from_text(text) for text in ("S-1-5-18", "S-1-5-32-544", "S-1-3-0", "S-1-3-4")} trusted.add(_user_sid()) # A missing DACL grants everyone full access. return ( bool(dacl.value) and _sid_at(cast("int", owner.value)) in trusted and _name_writers(cast("int", dacl.value)) <= trusted ) finally: _kernel32.LocalFree(descriptor) def _name_writers(dacl: int) -> set[bytes]: (count,) = struct.unpack("<4xH2x", ctypes.string_at(dacl, 8)) writers = set() offset = 8 for _ in range(count): ace_type, _flags, ace_size, mask = struct.unpack(" bytes: token = wintypes.HANDLE() token_query: Final = 0x0008 open_as_self: Final = True # An impersonating thread acts as the account it impersonates, which is the one whose names the lock creates. if not ( _advapi32.OpenThreadToken(_kernel32.GetCurrentThread(), token_query, open_as_self, ctypes.byref(token)) or _advapi32.OpenProcessToken(_kernel32.GetCurrentProcess(), token_query, ctypes.byref(token)) ): # pragma: no cover # every process may query its own token raise ctypes.WinError(ctypes.get_last_error()) try: size = wintypes.DWORD() _advapi32.GetTokenInformation(token, 1, None, 0, ctypes.byref(size)) buffer = ctypes.create_string_buffer(size.value) if not _advapi32.GetTokenInformation( token, 1, buffer, size, ctypes.byref(size) ): # pragma: no cover # the buffer has the size the first call asked for raise ctypes.WinError(ctypes.get_last_error()) return _sid_at(cast("int", ctypes.c_void_p.from_buffer(buffer).value)) finally: _kernel32.CloseHandle(token) def _sid_from_text(text: str) -> bytes: revision, authority, *subauthorities = (int(part) for part in text.removeprefix("S-").split("-")) return ( struct.pack(" bytes: return ctypes.string_at(address, 8 + 4 * ctypes.string_at(address + 1, 1)[0]) def _make_private_directory(path: str) -> None: """Create *path* with the owner-only descriptor, so that nobody else can add a name to it.""" attributes = _SECURITY_ATTRIBUTES( nLength=ctypes.sizeof(_SECURITY_ATTRIBUTES), lpSecurityDescriptor=_owner_only_descriptor(), bInheritHandle=False, ) if not _kernel32.CreateDirectoryW(path, ctypes.byref(attributes)): # pragma: no cover # the name is fresh raise ctypes.WinError(ctypes.get_last_error()) @contextmanager def _pinned(path: str) -> Generator[tuple[int, int]]: """Hold *path* open without delete sharing, which keeps its name from being renamed or removed.""" with _opened(path, _FILE_SHARE_READ_WRITE) as handle: yield _identity_of(handle) def _identity_at(path: str) -> tuple[int, int]: with _opened(path, _FILE_SHARE_ALL) as handle: return _identity_of(handle) def _identity_of_fd(fd: int) -> tuple[int, int]: return _identity_of(msvcrt.get_osfhandle(fd)) @contextmanager def _opened(path: str, share: int) -> Generator[int]: # The final component is opened as itself, never through a reparse point. handle = _kernel32.CreateFileW( path, _FILE_READ_ATTRIBUTES, share, None, 3, # OPEN_EXISTING 0x02000000 | 0x00200000, # FILE_FLAG_BACKUP_SEMANTICS opens a directory; FILE_FLAG_OPEN_REPARSE_POINT None, ) if handle in {None, _INVALID_HANDLE_VALUE}: # pragma: no cover # callers open a name they just made or listed raise ctypes.WinError(ctypes.get_last_error()) try: yield cast("int", handle) finally: _kernel32.CloseHandle(handle) def _identity_of(handle: int) -> tuple[int, int]: info = _BY_HANDLE_FILE_INFORMATION() if not _kernel32.GetFileInformationByHandle(handle, ctypes.byref(info)): # pragma: no cover raise ctypes.WinError(ctypes.get_last_error()) return info.dwVolumeSerialNumber, info.nFileIndexHigh << 32 | info.nFileIndexLow else: # pragma: win32 no cover class WindowsFileLock(BaseFileLock): """Uses ``LockFileEx`` to hard lock a byte range of the lock file on Windows systems.""" def _acquire(self) -> None: raise NotImplementedError def _release(self) -> None: raise NotImplementedError __all__ = [ "WindowsFileLock", "_directory_is_private", "_identity_at", "_identity_of_fd", "_is_unlinked", "_make_private_directory", "_open_deletable_for_reading", "_open_non_reparse_fd", "_pin_directory", "_pinned", "_unpin_directory", "open_for_delete_without_oplock_wait", "open_read_without_oplock_wait", "unlink_without_oplock_wait", ] filelock-4.2.8/src/filelock/asyncio.py0000644000000000000000000012375115272561277014670 0ustar00"""An asyncio-based implementation of the file lock.""" from __future__ import annotations __lazy_modules__ = [ "asyncio", "concurrent", "contextlib", "filelock._async", "filelock._error", "filelock._util", "functools", "inspect", "threading", ] import asyncio import contextlib import functools import logging import os import sys import time from concurrent import futures from dataclasses import dataclass, field, fields from inspect import iscoroutinefunction from threading import RLock from typing import TYPE_CHECKING, Final, NoReturn, TypeVar, cast from filelock._api import ( _UNSET_FILE_MODE, BaseFileLock, CloseErrorPolicy, ContextErrorPolicy, FileLockContext, FileLockMeta, ThreadLocalFileContext, _append_exception_context, _canonical, _check_blocking, _delegate_to_current, _ExtraValue, _fork_transition, _grouped_errors, _is_system_call_failure, _raise_body_and_release, _raise_chained_errors, _raise_cleanup_errors, _raise_grouped_errors, _register_fork_object, _resolve_poll_interval, _resolve_timeout, ) from filelock._async import ( _AsyncTransitionGate, _AsyncTransitionUnavailableError, _BackendOutcome, _capture_awaitable, _capture_call, _drain_future, _future_result, _run_in_executor, _wait_until_done, ) from filelock._error import Timeout from filelock._lease import SoftFileLease from filelock._soft import SoftFileLock from filelock._strict import StrictSoftFileLock from filelock._unix import UnixFileLock from filelock._util import ensure_directory_exists from filelock._windows import WindowsFileLock if TYPE_CHECKING: from collections.abc import Awaitable, Callable, Coroutine, Hashable, Mapping from types import TracebackType from typing import Self _LOGGER: Final[logging.Logger] = logging.getLogger("filelock") _ASYNC_RELEASE_CANCELLATION_ERRORS: Final[str] = "lock release cancellation and backend release both failed" _ASYNC_CONTEXT_RELEASE_ERRORS: Final[str] = "context body, release cancellation, and backend release failed" _ASYNC_RELEASE_CANCELLATION_MARKER_ATTR: Final[str] = "_filelock_async_release_cancellation" _ASYNC_RELEASE_CANCELLATION_MARKER: Final[list[None]] = [] _FINALIZER_RELEASES: Final[set[asyncio.Task[None] | futures.Future[None]]] = set() _AT = TypeVar("_AT", bound="BaseAsyncFileLock") _T = TypeVar("_T") class AsyncFileLockMeta(FileLockMeta): def __call__( # ruff:ignore[too-many-arguments] # forwards the public constructor's documented parameters cls: type[_AT], # ruff:ignore[invalid-first-argument-name-for-method] # metaclass __call__ receives the class being constructed lock_file: str | os.PathLike[str], timeout: float = -1, mode: int = _UNSET_FILE_MODE, thread_local: bool = False, # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument] # public API: positional bool kept for backwards compatibility *, blocking: bool = True, is_singleton: bool = False, poll_interval: float = 0.05, lifetime: float | None = None, context_error_policy: ContextErrorPolicy = "chain", close_error_policy: CloseErrorPolicy = "default", fallback_to_soft: bool = True, preserve_lock_file: bool = False, on_acquired: Callable[[int], None] | None = None, loop: asyncio.AbstractEventLoop | None = None, run_in_executor: bool = True, executor: futures.Executor | None = None, **kwargs: _ExtraValue, ) -> _AT: if thread_local and run_in_executor: msg = "run_in_executor is not supported when thread_local is True" raise ValueError(msg) return super().__call__( # a subclass may add options of its own, as AsyncSoftFileLease does **kwargs, lock_file=lock_file, timeout=timeout, mode=mode, thread_local=thread_local, blocking=blocking, is_singleton=is_singleton, poll_interval=poll_interval, lifetime=lifetime, context_error_policy=context_error_policy, close_error_policy=close_error_policy, fallback_to_soft=fallback_to_soft, preserve_lock_file=preserve_lock_file, on_acquired=on_acquired, loop=loop, run_in_executor=run_in_executor, executor=executor, ) class BaseAsyncFileLock(BaseFileLock, metaclass=AsyncFileLockMeta): """ Base class for asynchronous file locks. .. versionadded:: 3.15.0 """ _deadlock_holder_desc: str = "BaseAsyncFileLock instance in this task" _constructor_lifetime_warning_stacklevel: int = 4 @staticmethod def _deadlock_scope() -> Hashable | None: # One event loop thread runs every task, so a thread-scoped registry cannot tell a same-task reacquire # (a real deadlock: the polling task never reaches its own release) from another task queuing behind the # holder (no deadlock: each poll yields, so the holder runs on and releases). Only the first may fail # fast, so scope holders to the task. return asyncio.current_task() def __init__( # ruff:ignore[too-many-arguments] # public constructor: one parameter per documented lock option self, lock_file: str | os.PathLike[str], timeout: float = -1, mode: int = _UNSET_FILE_MODE, thread_local: bool = False, # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument] # public API: positional bool kept for backwards compatibility *, blocking: bool = True, is_singleton: bool = False, poll_interval: float = 0.05, lifetime: float | None = None, context_error_policy: ContextErrorPolicy = "chain", close_error_policy: CloseErrorPolicy = "default", fallback_to_soft: bool = True, preserve_lock_file: bool = False, on_acquired: Callable[[int], None] | None = None, loop: asyncio.AbstractEventLoop | None = None, run_in_executor: bool = True, executor: futures.Executor | None = None, ) -> None: """ Create a new lock object. :param lock_file: path to the file :param timeout: default timeout when acquiring the lock, in seconds. It will be used as fallback value in the acquire method, if no timeout value (``None``) is given. If you want to disable the timeout, set it to a negative value. A timeout of 0 means that there is exactly one attempt to acquire the file lock. :param mode: file permissions for the lockfile. When not specified, the OS controls permissions via umask and default ACLs, preserving POSIX default ACL inheritance in shared directories. :param thread_local: Whether this object's internal context should be thread local or not. If this is set to ``False`` (the default) then the lock will be reentrant across threads. When ``True``, **all fields of the lock's internal context are per-thread**, including the configuration values ``poll_interval``, ``timeout``, ``blocking``, ``mode``, and ``lifetime``. Setting one of these properties from one thread does not change the value seen by another thread; threads that did not perform the write continue to see the value supplied at construction time. ``mode`` has no setter, so construction is the only place it is ever set. If you need configuration values to be visible across threads, construct the lock with ``thread_local=False``. :param blocking: whether the lock should be blocking or not :param is_singleton: If this is set to ``True`` then only one instance of this class will be created per lock file. This is useful if you want to use the lock object for reentrant locking without needing to pass the same object around. :param poll_interval: default interval for polling the lock file, in seconds. It will be used as fallback value in the acquire method, if no poll_interval value (``None``) is given. :param lifetime: for :class:`AsyncSoftFileLock`, the age in seconds after which a waiting process may delete the marker, even while its holder remains alive. This legacy expiry mode does not provide strict mutual exclusion. ``None`` (the default) disables age-based expiry. Native OS locks (:class:`AsyncFileLock`) cannot be revoked by file age and ignore a non-``None`` ``lifetime`` with a warning. :param context_error_policy: how a context manager reconciles a failure in its body with a failure while releasing on exit. ``"chain"`` (the default) keeps Python's behavior: the release error propagates with the body error in its ``__context__``. ``"group"`` raises a :class:`BaseExceptionGroup` holding the body error first and the release error second, so neither hides the other. :param close_error_policy: for native locks (:class:`AsyncFileLock`), what to do with an ``os.close`` failure after the OS unlock has already committed. ``"default"`` keeps each platform's historical behavior, ``"raise"`` always propagates the ``OSError``, and ``"suppress"`` always ignores it. :param fallback_to_soft: for :class:`AsyncFileLock`, whether to fall back to soft existence locking when ``flock`` returns ``ENOSYS``. ``True`` (default) keeps the fallback; ``False`` propagates the error. :param preserve_lock_file: for native locks (:class:`AsyncFileLock`), whether filelock promises not to unlink the lock pathname on release. ``False`` (default) keeps each backend's cleanup; ``True`` keeps a stable file identity (Windows skips its unlink, Unix refuses the ``ENOSYS`` soft fallback). :class:`AsyncSoftFileLock` rejects ``True``. :param on_acquired: for native locks (:class:`AsyncFileLock`), a callable invoked with the borrowed lock descriptor once per physical acquisition, after the lock is held but before :meth:`~BaseAsyncFileLock.acquire` returns. With ``run_in_executor=True`` (the default) it runs in the backend executor. It must not close or unlock the descriptor; a raise rolls the acquisition back. :class:`AsyncSoftFileLock` rejects it. :param loop: the event loop a garbage-collected lock schedules its release on when no loop runs in the collecting thread. :meth:`~BaseAsyncFileLock.acquire` and :meth:`~BaseAsyncFileLock.release` always run on the loop that awaits them. :param run_in_executor: If this is set to ``True`` then the lock will be acquired in an executor. :param executor: The executor to use. If not specified, the default executor will be used. """ self._creator_pid = os.getpid() self._holder_id = id(self) self._is_thread_local = thread_local self._is_singleton = is_singleton self._context_error_policy = context_error_policy # already validated by the metaclass self._close_error_policy = close_error_policy # already validated by the metaclass self._fallback_to_soft = fallback_to_soft self._preserve_lock_file = preserve_lock_file # already validated by the metaclass self._on_acquired = on_acquired # already validated by the metaclass # BaseFileLock.__init__ never runs here, and the finalizer releases thread-local holds through these. self._transition_lock = RLock() self._thread_holds = {} self._thread_pending = {} self._async_context: Final[AsyncFileLockContext] = ( AsyncThreadLocalFileContext if thread_local else AsyncFileLockContext )( lock_file=os.fspath(lock_file), timeout=timeout, mode=mode, blocking=blocking, poll_interval=poll_interval, lifetime=lifetime, loop=loop, run_in_executor=run_in_executor, executor=executor, ) # BaseFileLock declares _context as FileLockContext; narrowing that writable attribute here is unsound. self._context = self._async_context _register_fork_object(self) def _singleton_extra_mismatches(self, kwargs: Mapping[str, _ExtraValue], /) -> dict[str, tuple[str, str]]: mismatches = super()._singleton_extra_mismatches(kwargs) # Loops and executors compare by identity: a different one schedules the lock's work elsewhere. mismatches.update( (name, (str(kwargs[name]), str(value))) for name, value in (("loop", self.loop), ("executor", self.executor)) if kwargs[name] is not value ) if kwargs["run_in_executor"] != self.run_in_executor: mismatches["run_in_executor"] = (str(kwargs["run_in_executor"]), str(self.run_in_executor)) return mismatches @property def run_in_executor(self) -> bool: """Whether run in executor.""" return self._async_context.run_in_executor @property def executor(self) -> futures.Executor | None: """The executor.""" return self._async_context.executor @executor.setter def executor(self, value: futures.Executor | None) -> None: # pragma: no cover """ Change the executor. :param futures.Executor | None value: the new executor or ``None`` """ self._async_context.executor = value @property def loop(self) -> asyncio.AbstractEventLoop | None: """The event loop.""" return self._async_context.loop async def acquire( # ty: ignore[invalid-method-override] self, timeout: float | None = None, poll_interval: float | None = None, *, blocking: bool | None = None, cancel_check: Callable[[], bool] | None = None, ) -> AsyncAcquireReturnProxy: """ Try to acquire the file lock. :param timeout: maximum wait time for acquiring the lock, ``None`` means use the default :attr:`~BaseFileLock.timeout` is and if ``timeout < 0``, there is no timeout and this method will block until the lock could be acquired :param poll_interval: interval of trying to acquire the lock file, ``None`` means use the default :attr:`~BaseFileLock.poll_interval` :param blocking: whether to retry until this call holds the lock or the timeout expires, ``None`` means use the default :attr:`~BaseFileLock.blocking`. ``False`` makes one attempt and raises :class:`~Timeout` on failure. :param cancel_check: a callable returning ``True`` when the acquisition should be canceled. Checked on each poll iteration. When triggered, raises :class:`~Timeout` just like an expired timeout. :returns: a context object that will unlock the file when the context is exited :raises Timeout: if fails to acquire lock within the timeout period :raises TypeError: if *blocking* is not a :class:`bool` :raises ValueError: if a blocking call gets a ``nan`` timeout, or *poll_interval* exceeds :data:`threading.TIMEOUT_MAX` .. code-block:: python # You can use this method in the context manager (recommended) async with await lock.acquire(): pass # Or use an equivalent try-finally construct: await lock.acquire() try: pass finally: await lock.release() """ self._raise_if_inherited() if timeout is None: timeout = self._context.timeout if blocking is None: blocking = self._context.blocking _check_blocking(blocking) if poll_interval is None: poll_interval = self._context.poll_interval poll_interval = _resolve_poll_interval(poll_interval) timeout = _resolve_timeout(timeout, blocking=blocking) start_time = time.perf_counter() try: return await self._acquire_with_admission( blocking=blocking, cancel_check=cancel_check, timeout=timeout, poll_interval=poll_interval, start_time=start_time, ) except _AsyncTransitionUnavailableError: raise Timeout(self.lock_file) from None async def _acquire_with_admission( self, *, blocking: bool, cancel_check: Callable[[], bool] | None, timeout: float, poll_interval: float, start_time: float, ) -> AsyncAcquireReturnProxy: async with self._async_context.transition_gate.hold_for_acquire( blocking=blocking, cancel_check=cancel_check, deadline=None if timeout < 0 else start_time + timeout, poll_interval=poll_interval, ): # Re-entry only counts, so it skips the executor round trip that resolving the path costs. if self.is_locked: self._context.lock_counter += 1 return AsyncAcquireReturnProxy(lock=self) # A canceled provisional acquire must finish rollback before another caller can claim its descriptor. canonical = await self._off_loop(functools.partial(_canonical, self.lock_file)) self._context.lock_counter += 1 self._raise_if_would_deadlock(canonical, timeout=timeout, blocking=blocking) self._pin_lock_path(canonical) try: await self._async_poll_until_acquired( blocking=blocking, cancel_check=cancel_check, timeout=timeout, poll_interval=poll_interval, start_time=start_time, ) except BaseException: self._reconcile_failed_acquire(canonical) raise self._commit_acquire(canonical) return AsyncAcquireReturnProxy(lock=self) async def _off_loop(self, call: Callable[[], _T]) -> _T: # A slow mount stalls a filesystem call, and the loop with it; a coroutine backend runs on the loop anyway. if not self.run_in_executor or iscoroutinefunction(self._acquire): return call() future = _run_in_executor(asyncio.get_running_loop(), self.executor, call) await _wait_until_done(future) return _future_result(future) async def _async_poll_until_acquired( self, *, blocking: bool, cancel_check: Callable[[], bool] | None, timeout: float, poll_interval: float, start_time: float, ) -> None: lock_id = id(self) lock_filename = self.lock_file await self._off_loop(functools.partial(ensure_directory_exists, self._lock_path)) while True: self._raise_if_inherited() _LOGGER.debug("Attempting to acquire lock %s on %s", lock_id, lock_filename) await self._run_acquire_attempt() self._raise_if_inherited() if self.is_locked: _LOGGER.debug("Lock %s acquired on %s", lock_id, lock_filename) return if self._check_give_up( blocking=blocking, cancel_check=cancel_check, timeout=timeout, start_time=start_time, ): raise Timeout(lock_filename, await self._off_loop(self._unprobeable_holder)) delay = poll_interval if timeout >= 0: delay = min(delay, max(start_time + timeout - time.perf_counter(), 0.0)) _LOGGER.debug("Lock %s not acquired on %s, waiting %s seconds ...", lock_id, lock_filename, delay) await asyncio.sleep(delay) async def _run_acquire_attempt(self) -> None: acquire_future = self._start_internal_method( self._acquire_with_fork_tracking_async if iscoroutinefunction(self._acquire) else self._break_expired_and_acquire ) try: await _wait_until_done(acquire_future) except asyncio.CancelledError as cancellation: acquire_error: BaseException | None = None try: await _drain_future(acquire_future) except BaseException as error: # ruff:ignore[blind-except] # reported with the cancellation below acquire_error = error rollback_error: BaseException | None = None if self.is_locked: try: await _drain_future(self._start_tracked_release()) except BaseException as error: # ruff:ignore[blind-except] # reported with the cancellation below rollback_error = error if acquire_error is not None: if rollback_error is not None: # pragma: needs fcntl self._raise_cancelled_errors( "lock acquisition cancellation, backend attempt, and rollback failed", cancellation, acquire_error, rollback_error, ) self._raise_cancelled_errors( "lock acquisition cancellation and backend attempt both failed", cancellation, acquire_error ) if rollback_error is not None: self._raise_cancelled_errors( "lock acquisition cancellation and rollback both failed", cancellation, rollback_error ) raise try: _future_result(acquire_future) except asyncio.CancelledError as acquire_error: await self._rollback_backend_cancelled_acquire(acquire_error) async def _rollback_backend_cancelled_acquire(self, acquire_error: asyncio.CancelledError) -> NoReturn: if self.is_locked: try: await _drain_future(self._start_tracked_release()) except BaseException as rollback_error: # ruff:ignore[blind-except] # both backend errors must surface self._raise_acquire_rollback_errors(acquire_error, rollback_error) raise acquire_error def _raise_acquire_rollback_errors(self, acquire_error: BaseException, rollback_error: BaseException) -> NoReturn: if self._context_error_policy == "group": _raise_grouped_errors("lock acquisition backend and rollback both failed", acquire_error, rollback_error) _raise_chained_errors(acquire_error, rollback_error) def _raise_cancelled_errors( self, message: str, cancellation: asyncio.CancelledError, first_error: BaseException, second_error: BaseException | None = None, ) -> NoReturn: if self._context_error_policy == "group": marker = ( (_ASYNC_RELEASE_CANCELLATION_MARKER_ATTR, _ASYNC_RELEASE_CANCELLATION_MARKER) if message == _ASYNC_RELEASE_CANCELLATION_ERRORS else None ) if second_error is None: _raise_grouped_errors(message, cancellation, first_error, marker=marker) _raise_grouped_errors(message, cancellation, first_error, second_error, marker=marker) if (context := first_error.__context__) is not None and context is not cancellation: if (cancellation_context := cancellation.__context__) is not None: _append_exception_context(context, cancellation_context) cancellation.__context__ = context first_error.__context__ = cancellation _raise_chained_errors(first_error, second_error) async def release(self, force: bool = False) -> None: # ty: ignore[invalid-method-override] # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument] # public API: positional bool kept for backwards compatibility """ Release the file lock. The lock is only completely released when the lock counter reaches 0. The lock file itself may be deleted automatically, the behavior is platform-specific. :param force: If true, the lock counter is ignored and the lock is released in every case. """ if self._creator_pid != os.getpid() or not self.is_locked: return async with self._async_context.transition_gate.hold(): await self._release_serialized(force=force) async def _release_serialized(self, *, force: bool) -> None: counter_before: Final = self._context.lock_counter if not self.is_locked: return if not force and self._context.lock_counter > 1: self._context.lock_counter -= 1 return try: await self._release_through_backend() except BaseException as release_error: if self._release_interrupted_before_commit(counter_before, release_error): with contextlib.suppress(Exception): self._release_with_fork_tracking() self._commit_release_if_released() raise def _release_interrupted_before_commit(self, counter_before: int, release_error: BaseException) -> bool: # Same rule as the sync release: a handler that raised before anything committed leaves the counter as it was, # and only then does a second attempt release the hold. A worker thread never runs a handler, so a lock that # runs its backend in the executor has nothing to retry. return ( counter_before == self._context.lock_counter and self.is_locked and not self.run_in_executor and not iscoroutinefunction(self._release) and not isinstance(release_error, asyncio.CancelledError) and not _is_system_call_failure(release_error) ) async def _release_through_backend(self) -> None: lock_id, lock_filename = id(self), self.lock_file _LOGGER.debug("Attempting to release lock %s on %s", lock_id, lock_filename) release_future = self._start_tracked_release() try: await _wait_until_done(release_future) except asyncio.CancelledError as cancellation: try: await _drain_future(release_future) except BaseException as release_error: # ruff:ignore[blind-except] # cancellation and backend failure must both surface self._commit_release_if_released() self._raise_cancelled_errors(_ASYNC_RELEASE_CANCELLATION_ERRORS, cancellation, release_error) self._commit_release() raise try: _future_result(release_future) except BaseException: # state follows the backend for control-flow exceptions too self._commit_release_if_released() raise self._commit_release() _LOGGER.debug("Lock %s released on %s", lock_id, lock_filename) def _commit_release_if_released(self) -> None: # Commit only when the backend actually unlocked (close or unlink failed after the OS unlock). If the lock is # still held, keep the counter so a later release can retry. if not self.is_locked: self._commit_release() def _start_internal_method( self, method: Callable[[], None] | Callable[[], Coroutine[None, None, None]] ) -> asyncio.Future[_BackendOutcome[None]]: if iscoroutinefunction(method): return asyncio.create_task(_capture_awaitable(cast("Callable[[], Coroutine[None, None, None]]", method)())) loop = asyncio.get_running_loop() sync_method = cast("Callable[[], None]", method) if self.run_in_executor: return _run_in_executor(loop, self.executor, sync_method) future: asyncio.Future[_BackendOutcome[None]] = loop.create_future() future.set_result(_capture_call(sync_method)) return future def _start_tracked_release(self) -> asyncio.Future[_BackendOutcome[None]]: return self._start_internal_method( self._release_with_fork_tracking_async if iscoroutinefunction(self._release) else self._release_with_fork_tracking ) def _break_expired_and_acquire(self) -> None: # Runs as one backend call so the lifetime check's lstat and unlink stay off the event loop with the acquire. self._try_break_expired_lock() self._acquire_with_fork_tracking() async def _acquire_with_fork_tracking_async(self) -> None: self._try_break_expired_lock() with _fork_transition(self): try: await cast("Callable[[], Awaitable[None]]", self._acquire)() except BaseException as acquisition_error: await self._rollback_failed_acquire_async(acquisition_error) raise try: self._register_context_descriptor() except BaseException as registration_error: # cancellation must roll back the descriptor await self._rollback_failed_registration_async(registration_error) raise if self.is_locked: await self._invoke_on_acquired_async() async def _rollback_failed_acquire_async(self, acquisition_error: BaseException) -> None: if not self.is_locked: return registration_error: BaseException | None = None tracking_error: BaseException | None = None try: self._register_context_descriptor() except BaseException as error: # ruff:ignore[blind-except] # preserve registration and acquisition failures registration_error = error try: # Rollback may fail too; retain the fd so a child can close it without another identity probe. self._register_unverified_context_descriptor() except BaseException as error: # ruff:ignore[blind-except] # pragma: no cover - allocation/control-flow during fallback tracking_error = error try: await _drain_future(self._start_tracked_release()) except BaseException as rollback_error: # ruff:ignore[blind-except] # preserve rollback and acquisition failures if registration_error is None and tracking_error is None: self._raise_acquire_rollback_errors(acquisition_error, rollback_error) _raise_cleanup_errors( "lock acquisition cleanup failed", acquisition_error, registration_error, tracking_error, rollback_error, ) if registration_error is not None: _raise_cleanup_errors( "lock acquisition cleanup failed", acquisition_error, registration_error, tracking_error ) async def _rollback_failed_registration_async(self, registration_error: BaseException) -> None: tracking_error: BaseException | None = None try: # Rollback may fail too; retain the fd so a child can close it without another identity probe. self._register_unverified_context_descriptor() except BaseException as error: # ruff:ignore[blind-except] # pragma: no cover - allocation/control-flow during fallback tracking_error = error try: await _drain_future(self._start_tracked_release()) except BaseException as rollback_error: # ruff:ignore[blind-except] # preserve rollback and registration failures _raise_cleanup_errors( "descriptor registration cleanup failed", registration_error, tracking_error, rollback_error ) if tracking_error is not None: # pragma: no cover - requires failed in-memory fallback _raise_cleanup_errors("descriptor registration cleanup failed", registration_error, tracking_error) async def _invoke_on_acquired_async(self) -> None: if self._on_acquired is None or self._context.lock_counter != 1: return try: self._on_acquired(cast("int", self._context.lock_file_fd)) except BaseException as callback_error: # caller control-flow errors must release the lock callback_context = callback_error.__context__ try: await _drain_future(self._start_tracked_release()) except BaseException as release_error: # ruff:ignore[blind-except] # both errors surface via the group below _raise_body_and_release(callback_error, release_error) callback_error.__context__ = callback_context raise async def _release_with_fork_tracking_async(self) -> None: with _fork_transition(self): try: await cast("Callable[[], Awaitable[None]]", self._release)() finally: self._unregister_released_descriptor() def __enter__(self) -> NoReturn: """Sync context manager entry is not supported because lock acquisition is a coroutine.""" msg = "Use `async with`: acquire/release are coroutines and cannot be awaited in a sync context manager." raise NotImplementedError(msg) def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: """Sync context manager exit is not supported because lock release is a coroutine.""" msg = "Use `async with`: acquire/release are coroutines and cannot be awaited in a sync context manager." raise NotImplementedError(msg) async def __aenter__(self) -> Self: """ Acquire the lock. :returns: the lock object """ await self.acquire() return self async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: """ Release the lock, reconciling a release failure with any body failure per :attr:`context_error_policy`. :param exc_type: the exception type if raised :param exc_value: the exception value if raised :param traceback: the exception traceback if raised """ await self._release_in_context(exc_value) async def _release_in_context( # ty: ignore[invalid-method-override] self, body_error: BaseException | None ) -> None: # The async counterpart of BaseFileLock._release_in_context: await release, then apply the same policy. try: await self.release() except BaseException as release_error: if body_error is None: raise if self._context_error_policy == "chain": _append_exception_context(release_error, body_error) raise if ( errors := _grouped_errors( release_error, _ASYNC_RELEASE_CANCELLATION_ERRORS, (_ASYNC_RELEASE_CANCELLATION_MARKER_ATTR, _ASYNC_RELEASE_CANCELLATION_MARKER), ) ) is not None: match errors: # The marker is only ever set on a (CancelledError, backend_error) pair, so this always matches. case (asyncio.CancelledError() as cancellation, backend_error): # pragma: no branch _raise_grouped_errors(_ASYNC_CONTEXT_RELEASE_ERRORS, body_error, cancellation, backend_error) _raise_body_and_release(body_error, release_error) def _release_abandoned_holds(self) -> None: # Runs from garbage collection, where no event loop may be running. with contextlib.suppress(Exception): # A thread-local lock never uses an executor, so an in-place release matches the loop's and also reaches # threads whose loops it cannot schedule on. if self._is_thread_local and not iscoroutinefunction(self._release): self._release_without_loop() return try: loop = asyncio.get_running_loop() except RuntimeError: # At interpreter exit daemon threads no longer run, so a loop running in one never runs the release. if (loop := self._async_context.loop) is None or loop.is_closed() or sys.is_finalizing(): self._release_without_loop() return if not loop.is_running(): # pragma: no cover loop.run_until_complete(self.release(force=True)) return # Off the deadlock registry, a new lock in this task waits for the scheduled release instead of failing. self._drop_registry_entry() # The stored loop runs in another thread, and only its own thread may create tasks on it. pending = asyncio.run_coroutine_threadsafe(self.release(force=True), loop) else: self._drop_registry_entry() pending = loop.create_task(self.release(force=True)) # The loop keeps only a weak reference to a task, so hold one until the release finishes. _FINALIZER_RELEASES.add(pending) pending.add_done_callback(self._settle_finalizer_release) def _settle_finalizer_release(self, pending: asyncio.Task[None] | futures.Future[None]) -> None: _FINALIZER_RELEASES.discard(pending) # Nothing awaits this release, so a failure or cancellation falls back to the sync release here. with contextlib.suppress(asyncio.CancelledError, futures.CancelledError): if pending.exception() is None: return with contextlib.suppress(Exception): self._release_without_loop() def _release_without_loop(self) -> None: # No loop can run a release here, so call a sync backend directly as BaseFileLock.__del__ does; a coroutine # backend cannot run and keeps its lock. if iscoroutinefunction(self._release): return if self.is_locked: try: self._release_with_fork_tracking() finally: self._commit_release_if_released() # A thread-local lock also holds for the threads that ran its loops, and nothing else releases those now. self._release_thread_holds(exited_only=False) def _new_transition_gate() -> _AsyncTransitionGate: # Called per context rather than passed as the factory, so the lazy import resolves on first use, not at import. return _AsyncTransitionGate() @dataclass class AsyncFileLockContext(FileLockContext): """A dataclass which holds the context for a ``BaseAsyncFileLock`` object.""" #: Whether run in executor run_in_executor: bool = True #: The executor executor: futures.Executor | None = None #: The loop loop: asyncio.AbstractEventLoop | None = None #: Serializes the tasks that transition this context. An acquire keeps it while it polls, so a thread-local lock #: needs one per thread. A thread waiting for the lock must not keep the thread that holds it from releasing. transition_gate: _AsyncTransitionGate = field(default_factory=_new_transition_gate) class AsyncThreadLocalFileContext(AsyncFileLockContext, ThreadLocalFileContext): """A thread local version of the ``FileLockContext`` class.""" # threading.local runs __init__ again with these arguments the first time each thread touches the context. def __init__( # ruff:ignore[too-many-arguments] # mirrors the AsyncFileLockContext fields set at construction self, *, lock_file: str, timeout: float, mode: int, blocking: bool, poll_interval: float, lifetime: float | None = None, run_in_executor: bool = True, executor: futures.Executor | None = None, loop: asyncio.AbstractEventLoop | None = None, ) -> None: self.current = AsyncFileLockContext( lock_file=lock_file, timeout=timeout, mode=mode, blocking=blocking, poll_interval=poll_interval, lifetime=lifetime, run_in_executor=run_in_executor, executor=executor, loop=loop, ) for _field in fields(AsyncFileLockContext): setattr(AsyncThreadLocalFileContext, _field.name, _delegate_to_current(_field.name)) class AsyncAcquireReturnProxy: """A context-aware object that will release the lock file when exiting.""" def __init__(self, lock: BaseAsyncFileLock) -> None: # ruff:ignore[undocumented-public-init] # trivial release-on-exit proxy self.lock = lock async def __aenter__(self) -> BaseAsyncFileLock: # ruff:ignore[undocumented-magic-method] # returns the wrapped lock return self.lock async def __aexit__( # ruff:ignore[undocumented-magic-method] # releases the wrapped lock on exit self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: await self.lock._release_in_context(exc_value) # ruff:ignore[private-member-access] # releases the wrapped lock's context class AsyncSoftFileLock(SoftFileLock, BaseAsyncFileLock): """Simply watches the existence of the lock file.""" _lifetime_replacements: tuple[str, str] | None = ("AsyncStrictSoftFileLock", "AsyncSoftFileLease") class AsyncStrictSoftFileLock(StrictSoftFileLock, BaseAsyncFileLock): """Run strict owner-claim locking without blocking the event loop.""" class AsyncSoftFileLease(SoftFileLease, BaseAsyncFileLock): """Existence lock whose claim expires, so a peer may take it while the previous holder still runs.""" class AsyncUnixFileLock(UnixFileLock, BaseAsyncFileLock): """Uses the :func:`fcntl.flock` to hard lock the lock file on unix systems.""" class AsyncWindowsFileLock(WindowsFileLock, BaseAsyncFileLock): """Uses ``LockFileEx`` to hard lock a byte range of the lock file on Windows systems.""" __all__ = [ "AsyncAcquireReturnProxy", "AsyncSoftFileLease", "AsyncSoftFileLock", "AsyncStrictSoftFileLock", "AsyncUnixFileLock", "AsyncWindowsFileLock", "BaseAsyncFileLock", ] def __dir__() -> list[str]: # Hide the sync bases and helpers imported at top level. return list(__all__) filelock-4.2.8/src/filelock/py.typed0000644000000000000000000000000015272561277014325 0ustar00filelock-4.2.8/src/filelock/version.py0000644000000000000000000000101015272561277014667 0ustar00# file generated by vcs-versioning # don't change, don't track in version control from __future__ import annotations __all__ = [ "__version__", "__version_tuple__", "version", "version_tuple", "__commit_id__", "commit_id", ] version: str __version__: str __version_tuple__: tuple[int | str, ...] version_tuple: tuple[int | str, ...] commit_id: str | None __commit_id__: str | None __version__ = version = '4.2.8' __version_tuple__ = version_tuple = (4, 2, 8) __commit_id__ = commit_id = None filelock-4.2.8/src/filelock/_soft_rw/__init__.py0000644000000000000000000000070415272561277016574 0ustar00"""Cross-process and cross-host reader/writer lock over a generation log of immutable snapshots.""" from __future__ import annotations __lazy_modules__ = ["filelock._soft_rw._async"] from filelock._soft_rw._async import AsyncAcquireSoftReadWriteReturnProxy, AsyncSoftReadWriteLock from filelock._soft_rw._sync import SoftReadWriteLock __all__ = [ "AsyncAcquireSoftReadWriteReturnProxy", "AsyncSoftReadWriteLock", "SoftReadWriteLock", ] filelock-4.2.8/src/filelock/_soft_rw/_async.py0000644000000000000000000004105315272561277016313 0ustar00"""Async wrapper around :class:`SoftReadWriteLock` for use with ``asyncio``.""" from __future__ import annotations __lazy_modules__ = ["asyncio", "filelock._async", "filelock._error", "functools", "threading"] import asyncio import functools import os import threading from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Final, Literal, ParamSpec, TypeVar from filelock._async import ( _TASKS_THREAD_ID, _BackendOutcome, _capture_call, _drain_future, _future_result, _raise_cancelled_error, _task_owners_for, _wait_until_done, ) from filelock._error import Timeout from filelock._soft_rw._sync import SoftReadWriteLock, _lost_hold_error if TYPE_CHECKING: from collections.abc import AsyncGenerator, Callable from concurrent import futures from types import TracebackType from filelock._api import AcquireReturnProxy from filelock._lease import LeaseCompromise _P = ParamSpec("_P") _R = TypeVar("_R") class AsyncSoftReadWriteLock: """ Async wrapper around :class:`SoftReadWriteLock` for ``asyncio`` applications. The sync class's blocking filesystem operations run on a thread pool via ``loop.run_in_executor()``. Each ``asyncio`` task owns its own hold. Tasks share a read lock, and a task asking for the write lock waits until the other holders release, with waiting writers ahead of new readers. Upgrading or downgrading a held lock raises :class:`RuntimeError`. The underlying :class:`SoftReadWriteLock` handles forks, the heartbeat, and stale eviction. Singleton wrappers share one sync lock and its task holds, and closing one ends only the holds taken through it. :param lock_file: path to the lock file; the protocol directory lives next to it as ``.rw`` :param timeout: maximum wait time in seconds; ``-1`` means block indefinitely :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately on contention :param is_singleton: if ``True``, reuse existing :class:`SoftReadWriteLock` instances per resolved path :param heartbeat_interval: seconds between heartbeat refreshes; default 30 s :param stale_threshold: seconds a holder record may stay unchanged before a contender evicts it; defaults to ``3 * heartbeat_interval`` :param poll_interval: seconds between acquire retries under contention; default 0.25 s :param on_compromise: called from the heartbeat thread with a :class:`~filelock.LeaseCompromise` when the hold is lost :param loop: event loop for ``run_in_executor``; ``None`` uses the running loop :param executor: executor for ``run_in_executor``; ``None`` uses the default executor .. versionadded:: 3.27.0 """ def __init__( # ruff:ignore[too-many-arguments] # public constructor: one parameter per documented lock option self, lock_file: str | os.PathLike[str], timeout: float = -1, *, blocking: bool = True, is_singleton: bool = True, heartbeat_interval: float = 30.0, stale_threshold: float | None = None, poll_interval: float = 0.25, on_compromise: Callable[[LeaseCompromise], None] | None = None, loop: asyncio.AbstractEventLoop | None = None, executor: futures.Executor | None = None, ) -> None: self._creator_pid = os.getpid() self._closed = False self._owns_backend = not is_singleton #: Marks the task holds taken through this wrapper, which its close() ends. self._via: Final = object() self._lock = SoftReadWriteLock( lock_file, timeout, blocking=blocking, is_singleton=is_singleton, heartbeat_interval=heartbeat_interval, stale_threshold=stale_threshold, poll_interval=poll_interval, on_compromise=on_compromise, ) self._owners = _task_owners_for(self._lock) self._loop = loop self._executor = executor @property def lock_file(self) -> str: """The path to the lock file passed to the constructor.""" return self._lock.lock_file @property def timeout(self) -> float: """The default timeout applied when ``acquire_read`` / ``acquire_write`` is called without one.""" return self._lock.timeout @property def blocking(self) -> bool: """Whether ``acquire_*`` defaults to blocking; ``False`` makes contention raise immediately.""" return self._lock.blocking @property def generation(self) -> int | None: """The generation the current hold was granted at, a fencing token; ``None`` when no lock is held.""" return self._lock.generation @property def compromise(self) -> LeaseCompromise | None: """How the current hold was lost, or ``None`` while it stands; acquiring raises until the hold is released.""" return self._lock.compromise @property def loop(self) -> asyncio.AbstractEventLoop | None: """The event loop used for ``run_in_executor``, or ``None`` for the running loop.""" return self._loop @property def executor(self) -> futures.Executor | None: """The executor used for ``run_in_executor``, or ``None`` for the default executor.""" return self._executor @asynccontextmanager async def read_lock(self, timeout: float | None = None, *, blocking: bool | None = None) -> AsyncGenerator[None]: """ Async context manager that acquires and releases a shared read lock. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default :raises RuntimeError: if the calling task already holds the write lock :raises Timeout: if the lock cannot be acquired within *timeout* seconds :raises TypeError: if *timeout* is a :class:`bool` or not a real number :raises ValueError: if a blocking call gets a ``nan`` *timeout* or a negative one other than ``-1`` """ await self.acquire_read(timeout, blocking=blocking) try: yield finally: await self.release() @asynccontextmanager async def write_lock(self, timeout: float | None = None, *, blocking: bool | None = None) -> AsyncGenerator[None]: """ Async context manager that acquires and releases an exclusive write lock. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default :raises RuntimeError: if the calling task already holds the read lock :raises Timeout: if the lock cannot be acquired within *timeout* seconds :raises TypeError: if *timeout* is a :class:`bool` or not a real number :raises ValueError: if a blocking call gets a ``nan`` *timeout* or a negative one other than ``-1`` """ await self.acquire_write(timeout, blocking=blocking) try: yield finally: await self.release() async def acquire_read( self, timeout: float | None = None, *, blocking: bool | None = None ) -> AsyncAcquireSoftReadWriteReturnProxy: """ Acquire a shared read lock. See :meth:`SoftReadWriteLock.acquire_read` for reentrancy / upgrade / fork semantics. The blocking work runs inside ``run_in_executor`` so other coroutines on the same loop keep progressing while this call waits. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default :returns: a proxy usable as an async context manager to release the lock :raises RuntimeError: if the calling task already holds the write lock, if this instance was invalidated by :func:`os.fork`, or if :meth:`close` was called :raises Timeout: if the lock cannot be acquired within *timeout* seconds :raises TypeError: if *timeout* is a :class:`bool` or not a real number :raises ValueError: if a blocking call gets a ``nan`` *timeout* or a negative one other than ``-1`` """ self._raise_if_unusable() await self._acquire("read", timeout, blocking=blocking) return AsyncAcquireSoftReadWriteReturnProxy(lock=self) async def acquire_write( self, timeout: float | None = None, *, blocking: bool | None = None ) -> AsyncAcquireSoftReadWriteReturnProxy: """ Acquire an exclusive write lock. See :meth:`SoftReadWriteLock.acquire_write` for the writer-preferring semantics. The blocking work runs inside ``run_in_executor``. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default :returns: a proxy usable as an async context manager to release the lock :raises RuntimeError: if the calling task already holds the read lock, if this instance was invalidated by :func:`os.fork`, or if :meth:`close` was called :raises Timeout: if the lock cannot be acquired within *timeout* seconds :raises TypeError: if *timeout* is a :class:`bool` or not a real number :raises ValueError: if a blocking call gets a ``nan`` *timeout* or a negative one other than ``-1`` """ self._raise_if_unusable() await self._acquire("write", timeout, blocking=blocking) return AsyncAcquireSoftReadWriteReturnProxy(lock=self) async def _acquire(self, mode: Literal["read", "write"], timeout: float | None, *, blocking: bool | None) -> None: blocking = self._lock.blocking if blocking is None else blocking async def enter(remaining: float) -> None: # A close() may have landed while this task waited for the table. self._raise_if_unusable() await self._run_acquire( functools.partial( self._lock._acquire_level_of, # ruff:ignore[private-member-access] # the wrapper owns this backend hold _TASKS_THREAD_ID, mode, remaining, blocking=blocking, ) ) def joinable() -> bool: self._raise_if_unusable() if (compromise := self._lock.compromise) is not None: raise _lost_hold_error(self.lock_file, mode, compromise) return self._backend_holds() await self._owners.acquire( mode, timeout=self._lock.timeout if timeout is None else timeout, blocking=blocking, lock_file=self.lock_file, via=self._via, enter=enter, joinable=joinable, ) async def release(self, *, force: bool = False) -> None: """ Release one level of the calling task's hold. The wrapper releases the lock once no task holds it. :param force: if ``True``, end every level the calling task took through this wrapper. Levels taken through other wrappers on the same path stay. A wrapper that holds no level for the task releases nothing. :raises RuntimeError: if the calling task holds no level through this wrapper and *force* is ``False`` """ if self._creator_pid == os.getpid(): await self._owners.release( force=force, lock_file=self.lock_file, via=self._via, leave=functools.partial(self._submit, self._leave_backend), still_held=self._backend_holds, ) async def close(self) -> None: """ End the holds of the tasks that acquired through this instance and release the lock once no task holds it. Idempotent. After calling this method the instance can no longer acquire locks. With ``is_singleton=False`` it also closes the underlying :class:`SoftReadWriteLock`. A singleton wrapper leaves the shared one open, so tasks that acquired through another wrapper and synchronous users of the same path keep their holds. The next acquire notices a synchronous ``close()`` or forced release of that shared lock. One that lands in the few bytecodes between that check and the admission still admits the task into the ended hold. """ if self._creator_pid != os.getpid() or self._closed: return # Refuse acquires from here on, so none is admitted into a hold the backend is letting go of. self._closed = True try: await self._owners.retire( None if self._owns_backend else self._via, leave=functools.partial(self._submit, self._lock.close if self._owns_backend else self._leave_backend), still_held=self._backend_holds, ) except BaseException as error: # Only a failed backend call can leave the level held, and then a retried close() has to find it. if not isinstance(error, asyncio.CancelledError) and self._backend_holds(): self._closed = False raise def _raise_if_unusable(self) -> None: if self._creator_pid != os.getpid(): # pragma: forked child msg = f"AsyncSoftReadWriteLock on {self.lock_file} was inherited across fork; construct a new instance" raise RuntimeError(msg) if self._closed: msg = f"AsyncSoftReadWriteLock on {self.lock_file} has been closed" raise RuntimeError(msg) def _backend_holds(self) -> bool: # A sync close() or forced release on a shared singleton ends the tasks' level without the table taking part. return self._lock._holds_level_of(_TASKS_THREAD_ID) # ruff:ignore[private-member-access] # the wrapper owns this backend hold def _leave_backend(self) -> None: # Only the tasks' level: a sync user of the same singleton keeps its own. self._lock._release_level_of(_TASKS_THREAD_ID) # ruff:ignore[private-member-access] # the wrapper owns this backend hold async def _run_acquire(self, acquire: Callable[..., AcquireReturnProxy]) -> None: # run_in_executor cannot recall work the pool already started. A sync acquire that won the lock before it saw # the cancellation has published its claim and started the heartbeat, which keeps the record fresh so no peer # evicts it. Wait the call out and hand the claim back, as AsyncReadWriteLock does. abandoned = threading.Event() acquire_future = self._submit(acquire, cancel_check=abandoned.is_set) try: await _wait_until_done(acquire_future) except asyncio.CancelledError as cancellation: # Stops a backend wait on another process's hold, which would otherwise outlast the caller by as long. abandoned.set() try: await _drain_future(acquire_future) except Timeout: # Nothing was taken, so the caller's cancellation is the whole outcome. raise cancellation from None except BaseException as error: # ruff:ignore[blind-except] # reported with the cancellation below _raise_cancelled_error(cancellation, error) try: await _drain_future(self._submit(self._leave_backend)) except BaseException as error: # ruff:ignore[blind-except] # reported with the cancellation below _raise_cancelled_error(cancellation, error) raise _future_result(acquire_future) def _submit( self, func: Callable[_P, _R], *args: _P.args, **kwargs: _P.kwargs ) -> asyncio.Future[_BackendOutcome[_R]]: loop = self._loop or asyncio.get_running_loop() return loop.run_in_executor(self._executor, _capture_call, functools.partial(func, *args, **kwargs)) class AsyncAcquireSoftReadWriteReturnProxy: """Async context-aware object that releases an :class:`AsyncSoftReadWriteLock` on exit.""" def __init__(self, lock: AsyncSoftReadWriteLock) -> None: self.lock = lock async def __aenter__(self) -> AsyncSoftReadWriteLock: return self.lock async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: await self.lock.release() __all__ = [ "AsyncAcquireSoftReadWriteReturnProxy", "AsyncSoftReadWriteLock", ] filelock-4.2.8/src/filelock/_soft_rw/_protocol.py0000644000000000000000000007442415272561277017047 0ustar00""" The generation-log protocol behind :class:`~filelock.SoftReadWriteLock`. The lock's whole state is a sequence of immutable snapshots, ``gen/``, each naming the writer and the readers that hold the lock at generation ``N``. Every transition is one atomic publication of the next snapshot: a participant reads the latest generation, derives the successor it wants, and links a fully written temporary file to ``gen/``. The link fails if a peer got there first, so the participant re-reads and tries again. No step is ever half done: a crash leaves at most an unlinked temporary file, never a state that another participant must repair before it can proceed. Liveness rests on a nonce, not a clock. Each participant keeps ``holders/`` and rewrites its nonce every heartbeat. A contender records the bytes it read there and the moment it read them on its own monotonic clock; a member whose bytes have not changed for ``stale_threshold`` is evicted by committing a snapshot without it. Two hosts never compare clocks, so a skewed file server or a suspended virtual machine cannot make a live holder look dead. The generation number is monotonic and unique to one transition within one incarnation of the log, so the generation at which a hold was granted fences it: a resource that rejects anything carrying a lower generation than the highest it has accepted refuses a holder that paused past its expiry and resumed. A random epoch file names the incarnation, so a participant that outlives a removal of the log reads the new one instead of continuing the old numbering into it. """ from __future__ import annotations __lazy_modules__ = ["contextlib", "filelock._error", "filelock._identity", "math", "pathlib", "secrets"] import os import secrets from contextlib import suppress from dataclasses import dataclass from math import isfinite from pathlib import Path from typing import TYPE_CHECKING, Final, Literal, Protocol from filelock._error import SoftFileLockProtocolError from filelock._identity import host_name if TYPE_CHECKING: from collections.abc import Callable Mode = Literal["read", "write"] HeartbeatOutcome = Literal["ok", "lost", "transient"] _PROTOCOL: Final[str] = "filelock-rw/1" _GENERATION_DIGITS: Final[int] = 20 _TOKEN_HEX_LENGTH: Final[int] = 32 #: A snapshot line is ``reader=`` plus a token, so this bounds a hostile record without capping real reader counts. _MAX_RECORD_SIZE: Final[int] = 1 << 20 #: Snapshots older than this many generations behind the latest are removed by whoever commits. The window is also how #: far ``latest`` probes past a gap before it trusts that nothing newer exists; ``gen/HEAD``, not the window, is what #: keeps a listing served further behind from passing an old generation off as the head. _RETAINED_GENERATIONS: Final[int] = 64 _GENERATIONS_DIRECTORY: Final[str] = "gen" #: A random token created once per log: removing ``gen/`` removes it, so a participant can tell a log that was wiped and #: restarted from the one it remembers. _EPOCH_NAME: Final[str] = "epoch" #: The epoch plus a copy of the newest snapshot, renamed into place after each commit. NFS revalidates a file opened by #: name but may serve a listing from cache, so this is the head a stale client cannot miss; it may lag, never lead. _HEAD_NAME: Final[str] = "HEAD" _HOLDERS_DIRECTORY: Final[str] = "holders" _TEMPORARY_PREFIX: Final[str] = ".commit-" class TreeReplacedError(RuntimeError): """ Raised by a ``Files`` implementation when a protocol directory is not a real directory (a symlink, a junction, a file) or belongs to another account. """ class Files(Protocol): """The filesystem operations the protocol runs on; :mod:`._storage` implements them over :mod:`os`.""" def read(self, path: str) -> bytes | None: """The file's bytes, ``None`` when it does not exist, or ``b""`` when it is not a regular file.""" def create(self, path: str, data: bytes, *, durable: bool = False) -> tuple[int, int]: """ Create the file exclusively with the whole record written; raise ``FileExistsError`` when it exists. The returned device and inode (the kernel's identity for a file, independent of its name) let ``overwrite`` check that the name still leads to this file. *durable* flushes it to stable storage first, for a record that outlives the host that wrote it: a generation a crash left empty would wedge every other participant. A holder record dies with its writer, so it skips that. """ def link(self, source: str, target: str) -> bool: """Hard-link *source* to *target* without replacing; ``True`` when *target* names *source*'s file afterwards.""" def replace(self, source: str, target: str) -> bool: """Rename *source* over *target* atomically; ``False`` when the platform refused because *target* is in use.""" def overwrite(self, path: str, data: bytes, identity: tuple[int, int]) -> bool: """ Rewrite from offset zero the file ``create`` returned *identity* for. ``False`` when *path* no longer leads to that file. """ def unlink(self, path: str) -> None: """Remove the file, tolerating one that is already gone.""" def listdir(self, path: str) -> list[str]: """The names in the directory, or an empty list when it does not exist.""" def prepare(self, root: str, *, force: bool = False) -> None: """ Create the protocol directories under *root*, refusing anything at those paths that is not a directory. Done once per lock; *force* repeats it after a write found a directory missing. """ @dataclass(frozen=True) class Snapshot: """The lock's state at one generation: which token writes, which tokens read.""" generation: int writer: str | None readers: frozenset[str] @property def members(self) -> frozenset[str]: """Every token this generation names.""" return self.readers if self.writer is None else self.readers | {self.writer} def encode(self) -> bytes: lines = [_PROTOCOL, f"generation={self.generation}"] if self.writer is not None: lines.append(f"writer={self.writer}") lines.extend(f"reader={token}" for token in sorted(self.readers)) return "".join(f"{line}\n" for line in lines).encode("ascii") def parse_snapshot(data: bytes) -> Snapshot | None: """The snapshot a ``gen/`` record encodes, or ``None`` when the record is malformed.""" if not data or len(data) > _MAX_RECORD_SIZE: return None try: lines = data.decode("ascii").split("\n") except UnicodeDecodeError: return None if lines[0] != _PROTOCOL or lines[-1]: return None generation: int | None = None writer: str | None = None readers: set[str] = set() for line in lines[1:-1]: key, _, value = line.partition("=") if ( key == "generation" and generation is None and len(value) <= _GENERATION_DIGITS and value.isdigit() and str(int(value)) == value ): generation = int(value) elif key == "writer" and writer is None and is_token(value): writer = value elif key == "reader" and is_token(value) and value not in readers: readers.add(value) else: return None return None if generation is None else Snapshot(generation=generation, writer=writer, readers=frozenset(readers)) def is_token(value: str) -> bool: """Whether *value* is a claim token: exactly 32 lowercase hex digits.""" return len(value) == _TOKEN_HEX_LENGTH and all(character in "0123456789abcdef" for character in value) def new_token() -> str: """A fresh claim token.""" return secrets.token_hex(_TOKEN_HEX_LENGTH // 2) def encode_holder(token: str, nonce: str, lease: float) -> bytes: """ The ``holders/`` record; only the nonce changes between heartbeats, so the length stays constant. *lease* is the writer's own ``stale_threshold``: a peer configured tighter must not read this holder's longer heartbeat interval as death, so evictors wait the longer of their threshold and this one. """ return ( f"{_PROTOCOL}\ntoken={token}\npid={os.getpid()}\nhost={host_name()}\nlease={float(lease)!r}\nnonce={nonce}\n" ).encode("ascii") def holder_lease(record: bytes | None) -> float | None: """The ``lease`` a holder record carries, or ``None`` when it has none (an older release) or it is malformed.""" try: text = next( line for line in (record or b"").decode("ascii").split("\n") if line.startswith("lease=") ).removeprefix("lease=") lease = float(text) except (StopIteration, ValueError): # UnicodeDecodeError is a ValueError return None # Only the exact spelling repr() writes: float() alone also takes "1_000", " 1", and non-ASCII digits. return lease if isfinite(lease) and lease > 0 and repr(lease) == text else None class Ledger: """ How long each observed value has stayed the same, measured on this observer's monotonic clock. A value that has not changed for ``stale_threshold`` belongs to a participant that stopped refreshing it. The clock is the observer's own and only ever measures the gap between two of its own observations, which is what lets two hosts agree on liveness without agreeing on the time. """ def __init__(self, clock: Callable[[], float]) -> None: self._clock = clock self._seen: dict[str, tuple[bytes | None, float]] = {} def observe(self, key: str, value: bytes | None, *, asked: float | None = None) -> float: """ Record *value* for *key* and return for how long it has stayed the same. *asked* is the clock reading taken before the caller fetched *value*. An unchanged value counts as unchanged up to *asked*, so a pause between the fetch and this call adds no time. """ now = self._clock() if (seen := self._seen.get(key)) is None or seen[0] != value: self._seen[key] = (value, now) return 0.0 return (now if asked is None else asked) - seen[1] def forget(self, key: str) -> None: self._seen.pop(key, None) def retain(self, keys: set[str]) -> None: """Drop every key not in *keys*: peers that left would otherwise stay in the ledger for the lock's lifetime.""" self._seen = {key: seen for key, seen in self._seen.items() if key in keys} class GenerationLog: """The ordered snapshots under ``gen/``, found by listing and probing, advanced by linking the next one.""" def __init__(self, files: Files, lock_file: str, root: str) -> None: self._files = files self._lock_file = lock_file self._directory = Path(root, _GENERATIONS_DIRECTORY) self._epoch_path = str(self._directory / _EPOCH_NAME) self._head_path = str(self._directory / _HEAD_NAME) self._epoch: bytes | None = None self._latest: Snapshot | None = None def latest(self) -> Snapshot: """ The newest snapshot. Starts from the newest readable generation among those ``gen/HEAD``, the listing, or memory name, then probes forward by name across the retained window. ``HEAD`` only names a generation, and that generation must still read back: a committer stalled before its rename can leave ``HEAD`` on a generation compacted since. A generation compacted between the listing and the read, and a hole left by a committer that died before it compacted, are covered by the probe. Memory and ``HEAD`` are bound to the log's epoch, so a log wiped and restarted is never continued from a generation it no longer holds. Only a log that names generations, now or as remembered, of which none can be read is refused, because that is a client so far behind the log that nothing it reads can be trusted. """ # When everything the first pass named is gone, peers compacted past it while this client looked, or the log was # replaced; one more pass settles whether this client cannot see the head at all. if (latest := self._find()) is None and (latest := self._find()) is None: raise SoftFileLockProtocolError(self._lock_file, None, "generation log names no readable snapshot") self._latest = latest return latest def _find(self) -> Snapshot | None: if (epoch := self._incarnation()) != self._epoch: self._epoch, self._latest = epoch, None named = set(self._list()) # Generation 0 is the empty log, which has no record to read back. if self._latest is not None and self._latest.generation: named.add(self._latest.generation) if head := self._head_generation(): named.add(head) for generation in sorted(named, reverse=True): if (start := self._read(generation)) is not None: # Compaction keeps the surviving generations contiguous, so the first missing name past a readable HEAD # generation is the end; probing the whole window on every poll cost 64 failed opens. return self._probe(start, patience=1 if generation == head else _RETAINED_GENERATIONS) newest = max(named, default=0) found = self._probe(Snapshot(generation=newest, writer=None, readers=frozenset())) return found if found.generation > newest or not named else None def _head_generation(self) -> int | None: # HEAD is never flushed, so a crash can leave it unreadable; readers verify what it names anyway, so a torn one # only sends them to the window probe. if (record := self._head_record()) is None or (snapshot := parse_snapshot(record)) is None: return None return snapshot.generation def _head_record(self) -> bytes | None: if (data := self._files.read(self._head_path)) is None: return None # A HEAD from another epoch landed after its log was removed; a log whose epoch was never written runs without. epoch, newline, record = data.partition(b"\n") return record if self._valid_epoch() == epoch + newline else None def _valid_epoch(self) -> bytes | None: epoch = self._epoch return epoch if epoch is not None and is_token(epoch.decode("ascii", "replace").removesuffix("\n")) else None def _incarnation(self) -> bytes | None: if (epoch := self._files.read(self._epoch_path)) is None: # Exclusive creation, not a link: reading the log must keep working where os.link does not exist. A peer # that reads the file before its token is written takes the empty value for the epoch, and its next commit # sees the change and re-reads, so a partial read costs one retry. try: self._files.create(self._epoch_path, f"{new_token()}\n".encode("ascii"), durable=True) except FileExistsError: pass except FileNotFoundError: # No gen/ yet: there is no log to tell apart, and the first participant's prepare() creates it. return None epoch = self._files.read(self._epoch_path) return epoch def _list(self) -> list[int]: listed = self._listed() for generation in listed[:-_RETAINED_GENERATIONS]: self._files.unlink(self._path(generation)) return listed[-_RETAINED_GENERATIONS:] def _listed(self) -> list[int]: # isdigit() alone admits Unicode digits: superscripts int() rejects, fullwidth ones it parses to another number. return sorted( int(name) for name in self._files.listdir(str(self._directory)) if len(name) == _GENERATION_DIGITS and name.isascii() and name.isdigit() ) def _probe(self, start: Snapshot, patience: int = _RETAINED_GENERATIONS) -> Snapshot: latest = start generation = start.generation gap = 0 while gap < patience: generation += 1 if (snapshot := self._read(generation)) is None: gap += 1 continue latest = snapshot gap = 0 return latest def _read(self, generation: int) -> Snapshot | None: if (data := self._files.read(self._path(generation))) is None: return None if (snapshot := parse_snapshot(data)) is None or snapshot.generation != generation: name = self._name(generation) raise SoftFileLockProtocolError(self._lock_file, name, "malformed generation record") return snapshot def commit(self, successor: Snapshot) -> bool: """ Publish *successor* as the next generation; ``False`` when a peer published that generation first. The record is complete before it gets its public name, and a no-replace hard link is the compare-and-swap: only one link to ``gen/`` can ever succeed. The link's return code is not trusted, because an NFS retry can report a link that did land as failed; the file's identity after the call is what decides. A log whose epoch changed since the snapshot was read is a different log, so the commit is refused as lost and the caller re-reads; that narrows a wipe racing this commit to the moment between the epoch check and the link. A slot compaction freed is refused the same way, before the link and again after it, since a committer can stall in between. After the link a surviving predecessor proves little: it may be a record another stalled committer linked into its own freed slot and has yet to remove. A slot comes free once the log is a whole window past it, and the head stays, so a record the listing or ``HEAD`` leads by a window is refused as well. A record refused after it landed is removed when nothing was built on it. One that peers lead by less than a window stays, or its slot would come free early. """ if self._files.read(self._epoch_path) != self._epoch or not self._extends_the_log(successor.generation): return False if not self._publish( self._path(successor.generation), data := successor.encode(), self._files.link, durable=True ): return False lead = max([*self._listed(), self._head_generation() or 0]) - successor.generation if lead >= _RETAINED_GENERATIONS or not self._extends_the_log(successor.generation): if not 0 < lead < _RETAINED_GENERATIONS: self._files.unlink(self._path(successor.generation)) return False self._latest = successor # Readers verify the generation HEAD names, so a failed rename costs a probe, not the commit. A late one would # move HEAD back past later commits, so HEAD only ever moves forward. with suppress(OSError): if ( (epoch := self._valid_epoch()) is not None and self._files.read(self._epoch_path) == epoch and (self._head_generation() or 0) < successor.generation ): self._publish(self._head_path, epoch + data, self._files.replace, durable=False) if (expired := successor.generation - _RETAINED_GENERATIONS - 1) > 0: self._compact_through(expired) return True def _extends_the_log(self, generation: int) -> bool: # Compaction removes oldest first, so a missing predecessor means compaction freed this slot. The first # generation has none; a HEAD of this epoch means the log already moved past it. if generation > 1: return self._files.read(self._path(generation - 1)) is not None return self._head_record() is None def _publish(self, target: str, data: bytes, place: Callable[[str, str], bool], *, durable: bool) -> bool: temporary = str(self._directory / f"{_TEMPORARY_PREFIX}{new_token()}") try: self._files.create(temporary, data, durable=durable) except FileNotFoundError: # No gen/: the log was removed, or nobody has prepared it yet. Either way there is nothing to publish into # until a participant's prepare() creates it, and the caller re-reads whatever log exists by then. return False try: return place(temporary, target) finally: self._files.unlink(temporary) def _compact_through(self, expired: int) -> None: # Oldest first, so survivors stay contiguous: a stalled committer's leftover would otherwise sit below a hole. oldest = expired while oldest > 1 and self._files.read(self._path(oldest - 1)) is not None: oldest -= 1 for generation in range(oldest, expired + 1): self._files.unlink(self._path(generation)) def _path(self, generation: int) -> str: return str(self._directory / self._name(generation)) @staticmethod def _name(generation: int) -> str: return f"{generation:0{_GENERATION_DIGITS}d}" class Participant: """ One acquisition, from publishing a claim through holding the lock to leaving. ``advance`` is polled until it reports the lock granted, ``heartbeat`` runs on the holder's schedule, and ``leave`` commits the participant out. None of them sleep: the caller owns the deadline and the poll interval. The ledger and the log outlive one acquisition: how long a peer's record has stayed unchanged is knowledge every attempt against the same lock shares, or a caller whose attempts are each shorter than the stale threshold could never evict. """ def __init__( # ruff:ignore[too-many-arguments] # one value per protocol input; the lock builds this once per acquisition self, files: Files, root: str, mode: Mode, *, stale_threshold: float, clock: Callable[[], float], ledger: Ledger, log: GenerationLog, ) -> None: self.token: Final[str] = new_token() self.mode: Final[Mode] = mode self.generation: int | None = None self._files = files self._root = root self._stale_threshold = stale_threshold self._clock = clock self._ledger = ledger self._log = log self._holder = self._holder_path(self.token) self._holder_identity: tuple[int, int] | None = None self._entered = False self._next_sweep = clock() def publish(self) -> None: """Create the holder record peers watch for liveness; it exists before the token appears in any snapshot.""" self._files.prepare(self._root) self._create_holder() def _create_holder(self) -> None: try: self._holder_identity = self._files.create(self._holder, self._record()) except FileNotFoundError: # Removing the whole log takes the directories with it. self._files.prepare(self._root, force=True) self._holder_identity = self._files.create(self._holder, self._record()) def _record(self) -> bytes: return encode_holder(self.token, new_token(), self._stale_threshold) def advance(self) -> bool: """ Take the next step toward holding the lock; ``True`` once the lock is granted. A reader enters as soon as no live writer is named. A writer enters as soon as no live writer is named, which blocks every new reader, then waits for the named readers to leave. Stale members are evicted in the same commit that admits this participant, and a writer's final admission is itself a commit, so no admission can rest on a snapshot a peer is about to supersede. """ # A lost race is not a reason to wait: re-read and try again before handing the poll interval back to the # caller. for _ in range(4): latest = self._log.latest() self._keep_claim_fresh(latest) self._sweep(latest) stale = frozenset(token for token in latest.members - {self.token} if self._is_stale(token)) writer = None if latest.writer in stale else latest.writer readers = latest.readers - stale if not self._entered: if writer is not None: # A live writer blocks entry in both modes; only an eviction is worth committing. if stale: self._commit(latest, writer, readers, stale) return False writer, readers = (self.token, readers) if self.mode == "write" else (None, readers | {self.token}) granted = self.mode == "read" or not readers if self._entered and not stale and not granted: return False if (generation := self._commit(latest, writer, readers, stale)) is None: continue self._entered = True if granted: self.generation = generation return granted return False def _keep_claim_fresh(self, latest: Snapshot) -> None: # The snapshot is the truth about membership: a commit the filesystem reported lost may have landed, and a peer # may have evicted this participant while it waited to drain, so both directions are taken from it. A wait can # outlast the stale threshold, so the nonce changes on every poll; a peer then never reads a patient contender # as a corpse. A record that has gone missing was taken by an evictor or a sweeper that did, or by a removal of # the whole log. self._entered = self.token in latest.members if not self._refresh_nonce(): with suppress(FileExistsError): self._create_holder() def _sweep(self, latest: Snapshot) -> None: # A participant that crashed before its first commit leaves a record no snapshot names, and a commit that # crashed between creating and unlinking its temporary file leaves that too. Neither blocks anyone, so they are # collected on the same unchanged-for-a-threshold rule as a member, and only every half threshold at most. if (now := self._clock()) < self._next_sweep: return self._next_sweep = now + self._stale_threshold / 2 watched = set(latest.members) holders = Path(self._root, _HOLDERS_DIRECTORY) for name in self._files.listdir(str(holders)): if name != self.token and name not in latest.members and is_token(name): watched.add(key := f"holder:{name}") self._collect(key, str(holders / name)) generations = Path(self._root, _GENERATIONS_DIRECTORY) for name in self._files.listdir(str(generations)): if name.startswith(_TEMPORARY_PREFIX): watched.add(key := f"temporary:{name}") self._collect(key, str(generations / name)) self._ledger.retain(watched) def _collect(self, key: str, path: str) -> None: if self._unchanged_for_its_lease(key, path): self._files.unlink(path) self._ledger.forget(key) def _commit( self, latest: Snapshot, writer: str | None, readers: frozenset[str], stale: frozenset[str] ) -> int | None: successor = Snapshot(generation=latest.generation + 1, writer=writer, readers=readers) if not self._log.commit(successor): return None for token in stale: self._files.unlink(self._holder_path(token)) self._ledger.forget(token) return successor.generation def _is_stale(self, token: str) -> bool: return self._unchanged_for_its_lease(token, self._holder_path(token)) def _unchanged_for_its_lease(self, key: str, path: str) -> bool: asked = self._clock() record = self._files.read(path) return self._ledger.observe(key, record, asked=asked) >= max(self._stale_threshold, holder_lease(record) or 0.0) def _holder_path(self, token: str) -> str: return str(Path(self._root, _HOLDERS_DIRECTORY, token)) def _refresh_nonce(self) -> bool: if (identity := self._holder_identity) is None: return False return self._files.overwrite(self._holder, self._record(), identity) def heartbeat(self) -> tuple[HeartbeatOutcome, OSError | None]: """ Refresh the nonce and confirm the latest snapshot still names this participant. ``lost`` means a peer evicted this participant or removed its holder record: the lock is no longer held, and the holder must stop using what it protects. ``transient`` is a filesystem error refreshing the nonce, worth retrying on the next tick. A failure to read the log or to sweep decides nothing about this holder's liveness, so it is not counted against it. """ try: if not self._refresh_nonce(): return "lost", None except TreeReplacedError: return "lost", None except OSError as error: return "transient", error try: latest = self._log.latest() self._sweep(latest) except (OSError, TreeReplacedError): return "ok", None return ("ok" if self.token in latest.members else "lost"), None def leave(self) -> None: """Commit this participant out of the latest snapshot, then remove its holder record.""" # The record goes even when the log cannot be read: a contender that never entered then leaves nothing behind, # and a member still named ages out the same way with or without one. # A tree that was swapped out leaves no record this participant can reach, so there is nothing to commit out of. with suppress(TreeReplacedError): try: while self.token in (latest := self._log.latest()).members: successor = Snapshot( generation=latest.generation + 1, writer=None if latest.writer == self.token else latest.writer, readers=latest.readers - {self.token}, ) if self._log.commit(successor): break finally: self._entered = False self.generation = None self._files.unlink(self._holder) __all__ = [ "Files", "GenerationLog", "HeartbeatOutcome", "Ledger", "Mode", "Participant", "Snapshot", "TreeReplacedError", "encode_holder", "holder_lease", "is_token", "new_token", "parse_snapshot", ] filelock-4.2.8/src/filelock/_soft_rw/_storage.py0000644000000000000000000003261615272561277016647 0ustar00"""The :class:`~._protocol.Files` implementation over :mod:`os`.""" from __future__ import annotations __lazy_modules__ = ["filelock._soft_rw._protocol", "filelock._strict", "filelock._util", "pathlib"] import errno import os import stat import sys import time from contextlib import contextmanager, suppress from errno import ELOOP, EMLINK, ENOENT, ENOTDIR, ENXIO, ESTALE from pathlib import Path from typing import TYPE_CHECKING, Final from filelock._soft_rw._protocol import ( _GENERATIONS_DIRECTORY, _HOLDERS_DIRECTORY, _MAX_RECORD_SIZE, TreeReplacedError, ) from filelock._strict import _link_no_follow, _pin_directories, _raise_if_hard_links_unsupported from filelock._util import ensure_directory_exists, foreign_directory_owner, write_all if TYPE_CHECKING: from collections.abc import Generator _O_NOFOLLOW: Final[int] = getattr(os, "O_NOFOLLOW", 0) #: Probed once at import, as :mod:`filelock._strict` does, because a per-call check flips when a test mocks the #: function. The set lists os.rename, whose implementation os.replace shares. _SUPPORTS_DIR_FD: Final[bool] = ( hasattr(os, "link") and {os.link, os.open, os.stat, os.mkdir, os.rename, os.unlink} <= os.supports_dir_fd and os.scandir in os.supports_fd ) #: Windows opens descriptors in text mode by default, which rewrites newlines; every record here is exact bytes. _O_BINARY: Final[int] = getattr(os, "O_BINARY", 0) #: ESTALE is what an NFS client reports for a file a peer unlinked out from under its cached handle: gone, not broken. _MISSING_ERRNOS: Final[frozenset[int]] = frozenset({ENOENT, ESTALE}) #: Windows refuses an open with EACCES while another process is deleting the file or holds it open without sharing; #: both clear within moments, so an open is retried this long before the refusal is taken as real. _WINDOWS_OPEN_GRACE: Final[float] = 0.5 class OsFiles: """Record files under a lock's protocol root, opened without following symlinks and owned by this UID alone.""" def __init__(self, lock_file: str) -> None: self._lock_file = lock_file self._prepared = False self._identities: dict[str, tuple[int, int]] = {} def read(self, path: str) -> bytes | None: # O_NONBLOCK keeps an open of a FIFO planted at the path from blocking; the regular-file check below rejects it. if (fd := self._open(path, os.O_RDONLY | _O_NOFOLLOW | getattr(os, "O_NONBLOCK", 0) | _O_BINARY)) is None: return None try: if not stat.S_ISREG(os.fstat(fd).st_mode): return b"" chunks: list[bytes] = [] remaining = _MAX_RECORD_SIZE + 1 while remaining > 0 and (chunk := os.read(fd, remaining)): chunks.append(chunk) remaining -= len(chunk) return b"".join(chunks) finally: os.close(fd) def create(self, path: str, data: bytes, *, durable: bool = False) -> tuple[int, int]: with self._relative(path) as (name, dir_fd): fd = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | _O_NOFOLLOW | _O_BINARY, 0o600, dir_fd=dir_fd) try: write_all(fd, data) if durable: os.fsync(fd) created = os.fstat(fd) except BaseException: os.close(fd) # The name is unique to this call, so removing it cannot take a record from anyone else. self.unlink(path) raise os.close(fd) return created.st_dev, created.st_ino def link(self, source: str, target: str) -> bool: failure: NotImplementedError | OSError | None = None with self._relative(source) as (source_name, source_fd), self._relative(target) as (target_name, target_fd): try: _link_no_follow(source_name, target_name, src_dir_fd=source_fd, dst_dir_fd=target_fd) except FileExistsError: pass except (NotImplementedError, OSError) as error: _raise_if_hard_links_unsupported(self._lock_file, error) failure = error # The file's identity decides, not the call's result: an NFS retransmit can report a link that landed as # failed, and an EEXIST can be this very link answered twice. Any other error on a link that did not land is # a fault, not a peer winning the race, and a retry would spin. if (identity := _identity(source_name, source_fd)) is not None and identity == _identity( target_name, target_fd ): return True if failure is not None: raise failure return False def replace(self, source: str, target: str) -> bool: try: with self._relative(source) as (source_name, source_fd), self._relative(target) as (target_name, target_fd): os.replace(source_name, target_name, src_dir_fd=source_fd, dst_dir_fd=target_fd) except PermissionError: # Windows refuses to rename over a file a reader holds open; callers tolerate a target one rename behind. return False return True def overwrite(self, path: str, data: bytes, identity: tuple[int, int]) -> bool: # O_NONBLOCK keeps an open of a FIFO (a named pipe) at the path from blocking, as in read. With no reader the # open fails with ENXIO. try: fd = self._open(path, os.O_WRONLY | _O_NOFOLLOW | getattr(os, "O_NONBLOCK", 0) | _O_BINARY) except OSError as error: if error.errno != ENXIO: raise return False # pragma: needs fifo if fd is None: return False try: # O_NOFOLLOW refuses a symlink but not a hard link (a second name for the same file) or another file renamed # over the name, and a write through either lands in a file that is not the record. The protocol does not # link holder records, so a second name marks a file that inherited a removed record's inode number. opened = os.fstat(fd) if not stat.S_ISREG(opened.st_mode) or (opened.st_dev, opened.st_ino) != identity or opened.st_nlink != 1: return False write_all(fd, data) finally: os.close(fd) return True def unlink(self, path: str) -> None: try: with self._relative(path) as (name, dir_fd): os.unlink(name, dir_fd=dir_fd) except OSError as error: # Windows refuses to remove a file another process holds open; the record then outlives its owner until # that process exits, which only delays the cleanup a later participant repeats. if error.errno not in _MISSING_ERRNOS and not isinstance(error, PermissionError): raise def listdir(self, path: str) -> list[str]: try: with self._directory(path) as dir_fd, os.scandir(path if dir_fd is None else dir_fd) as entries: return [entry.name for entry in entries] except FileNotFoundError: return [] def prepare(self, root: str, *, force: bool = False) -> None: if self._prepared and not force: return if force: self._identities.clear() ensure_directory_exists(root) for directory, depth in ( (Path(root), 0), (Path(root, _GENERATIONS_DIRECTORY), 1), (Path(root, _HOLDERS_DIRECTORY), 1), ): with self._relative(str(directory), depth) as (name, dir_fd): with suppress(FileExistsError): os.mkdir(name, 0o700, dir_fd=dir_fd) # mkdir has no O_NOFOLLOW, so check what the name resolves to before the lock creates anything in it. found = os.stat(name, dir_fd=dir_fd, follow_symlinks=False) parent = directory.parent.stat() if dir_fd is None else os.fstat(dir_fd) if ( not stat.S_ISDIR(found.st_mode) or getattr(found, "st_file_attributes", 0) & stat.FILE_ATTRIBUTE_REPARSE_POINT ): msg = f"{directory} exists but is not a directory or is a symlink; refusing to use it" raise TreeReplacedError(msg) owner = foreign_directory_owner(found, parent) if owner is not None: # pragma: needs foreign-sticky-tmp msg = f"{directory} belongs to uid {owner}, not to this process's account or the parent's owner" raise TreeReplacedError(msg) self._identities[str(directory)] = found.st_dev, found.st_ino self._prepared = True def _open(self, path: str, flags: int) -> int | None: # Missing is the one answer a caller acts on without raising. A refused open is real on POSIX, where nothing # this lock does makes its own records unreadable; on Windows it is usually another process mid-delete or # holding the file without sharing, so it is retried through a short grace before it counts, and a file still # refusing after that has been in delete-pending for longer than any deletion takes, so it is on its way out: # missing. deadline: float | None = None while True: if isinstance(opened := self._attempt_open(path, flags), int): return opened if opened.errno in _MISSING_ERRNOS: return None if not isinstance(opened, PermissionError) or sys.platform != "win32": raise opened if deadline is None: # pragma: win32 cover deadline = time.monotonic() + _WINDOWS_OPEN_GRACE elif time.monotonic() >= deadline: # pragma: win32 cover return None time.sleep(0.002) # pragma: win32 cover def _attempt_open(self, path: str, flags: int) -> int | OSError: try: with self._relative(path) as (name, dir_fd): return os.open(name, flags, dir_fd=dir_fd) except OSError as error: return error @contextmanager def _relative(self, path: str, depth: int = 2) -> Generator[tuple[str, int | None]]: directory, name = os.path.split(path) with self._directory(directory, depth) as dir_fd: yield (path if dir_fd is None else name), dir_fd @contextmanager def _directory(self, path: str, depth: int = 2) -> Generator[int | None]: # O_NOFOLLOW applies to the last path component. The check prepare() made on and /gen holds until a # process that can write the lock's directory renames the tree aside and puts one with a symlinked gen in its # place. Each call therefore opens the root and then the record's directory without following symlinks and # works inside what it opened. No descriptor outlives the call, so a later call finds a log removed and started # again by name, as before. None means descriptors cannot anchor a call and the call uses the whole path. if depth and _SUPPORTS_DIR_FD: # pragma: needs link-dir-fd with self._relative(path, depth - 1) as (name, parent_fd): try: fd = os.open(name, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | _O_NOFOLLOW, dir_fd=parent_fd) except OSError as error: # NetBSD uses EFTYPE and FreeBSD uses EMLINK when O_NOFOLLOW refuses a symlink. if error.errno not in {ELOOP, EMLINK, ENOTDIR, getattr(errno, "EFTYPE", ELOOP)}: raise msg = f"{path} is not a directory or is a symlink; refusing to use it" raise TreeReplacedError(msg) from error try: self._admit(fd, path, parent_fd) except BaseException: os.close(fd) raise try: yield fd finally: os.close(fd) else: # Without dir_fd, hold each of those directories open instead, so no process can rename or replace one. try: pins = _pin_directories(*[Path(path), *Path(path).parents][:depth][::-1]) except NotADirectoryError as error: # pragma: win32 cover msg = f"{error.filename} is not a directory or is a symlink; refusing to use it" raise TreeReplacedError(msg) from error with pins: yield None def _admit(self, fd: int, path: str, parent_fd: int | None) -> None: # pragma: needs link-dir-fd # A directory other than the one prepare() checked may belong to another account: the tree can be renamed aside # and recreated while this object lives, and the ownership check has to hold for what is opened now. found = os.fstat(fd) if (found.st_dev, found.st_ino) == self._identities.get(path): return parent = os.fstat(parent_fd) if parent_fd is not None else Path(path).parent.stat() if (owner := foreign_directory_owner(found, parent)) is not None: # pragma: needs foreign-sticky-tmp msg = f"{path} belongs to uid {owner}, not to this process's account or the parent's owner" raise TreeReplacedError(msg) self._identities[path] = found.st_dev, found.st_ino def _identity(path: str, dir_fd: int | None) -> tuple[int, int] | None: try: st = os.stat(path, dir_fd=dir_fd, follow_symlinks=False) except OSError: return None return st.st_dev, st.st_ino __all__ = [ "OsFiles", ] filelock-4.2.8/src/filelock/_soft_rw/_sync.py0000644000000000000000000011142015272561277016146 0ustar00"""Cross-process and cross-host reader/writer lock over a generation log of immutable snapshots.""" from __future__ import annotations __lazy_modules__ = [ "filelock._error", "filelock._lease", "filelock._soft_rw._protocol", "filelock._soft_rw._storage", "math", ] import atexit import os import threading import time from contextlib import contextmanager, suppress from dataclasses import dataclass from math import isfinite from typing import TYPE_CHECKING, ClassVar, Final from weakref import WeakValueDictionary from filelock._api import ( AcquireReturnProxy, _acquire_thread_lock, _canonical, _check_timeout_max, _ensure_current_process, _register_fork_class, _register_fork_object, _resolve_read_write_timeout, _seconds, _timeout_behavior, ) from filelock._error import Timeout from filelock._lease import LeaseCompromise from filelock._soft_rw._protocol import GenerationLog, Ledger, Mode, Participant from filelock._soft_rw._storage import OsFiles if TYPE_CHECKING: from collections.abc import Callable, Generator from filelock._lease import CompromiseReason _ALL_INSTANCES: Final[WeakValueDictionary[int, SoftReadWriteLock]] = WeakValueDictionary() _ALL_INSTANCES_LOCK: threading.Lock = threading.Lock() class _SoftRWMeta(type): _instances: WeakValueDictionary[str, SoftReadWriteLock] _instances_lock: threading.RLock _instances_under_construction: set[str] def __call__( # ruff:ignore[too-many-arguments] # forwards the public constructor's documented parameters cls, lock_file: str | os.PathLike[str], timeout: float = -1, *, blocking: bool = True, is_singleton: bool = True, heartbeat_interval: float = 30.0, stale_threshold: float | None = None, poll_interval: float = 0.25, on_compromise: Callable[[LeaseCompromise], None] | None = None, ) -> SoftReadWriteLock: _ensure_current_process() timeout = _seconds("timeout", timeout) # Passed through only when set, so a subclass that declares its own constructor without it keeps working. extra = {} if on_compromise is None else {"on_compromise": on_compromise} if not is_singleton: return super().__call__( lock_file, timeout, blocking=blocking, is_singleton=is_singleton, heartbeat_interval=heartbeat_interval, stale_threshold=stale_threshold, poll_interval=poll_interval, **extra, ) # Keyed like the log root, so one instance never spans two logs. normalized = _canonical(lock_file) with cls._instances_lock: instance = cls._instances.get(normalized) if instance is None: if normalized in cls._instances_under_construction: msg = f"Singleton lock construction is already active for {lock_file!s}" raise RuntimeError(msg) construction_pid = os.getpid() construction_registry = cls._instances_under_construction construction_registry.add(normalized) try: instance = super().__call__( lock_file, timeout, blocking=blocking, is_singleton=is_singleton, heartbeat_interval=heartbeat_interval, stale_threshold=stale_threshold, poll_interval=poll_interval, **extra, ) finally: construction_registry.discard(normalized) if os.getpid() != construction_pid: # pragma: needs fork msg = "Lock construction cannot continue after fork; construct a new lock in the child" raise RuntimeError(msg) cls._instances[normalized] = instance elif ( _timeout_behavior(instance.timeout, any_negative=False) != _timeout_behavior(timeout, any_negative=False) or instance.blocking != blocking ): msg = ( f"Singleton lock created with timeout={instance.timeout}, blocking={instance.blocking}," f" cannot be changed to timeout={timeout}, blocking={blocking}" ) raise ValueError(msg) elif on_compromise is not None and instance._on_compromise != on_compromise: # ruff: ignore[private-member-access] # the metaclass owns the singleton it compares # A tuning difference keeps the first caller's values; a different loss callback is a different safety # contract, and returning the cached instance would silently drop it. msg = f"Singleton lock created with a different on_compromise callback for {lock_file!s}" raise ValueError(msg) else: _validate_intervals(heartbeat_interval, stale_threshold, poll_interval) return instance class SoftReadWriteLock(metaclass=_SoftRWMeta): """ Cross-process and cross-host reader/writer lock for shared filesystems. Use this class instead of :class:`~filelock.ReadWriteLock` when the lock file lives on a network filesystem (NFS, Lustre, HPC cluster shared storage). ``ReadWriteLock`` is backed by SQLite and cannot run on NFS because SQLite's ``fcntl`` locking is unreliable there. The lock's state is a log of immutable snapshots under ``foo.lock.rw/gen/``, each naming the writer and the readers holding the lock at generation ``N``. Every acquire, release, and eviction publishes the next snapshot with one atomic no-replace hard link, so no transition can be left half done by a crash on any host. Each participant also keeps ``foo.lock.rw/holders/``, a record whose nonce a daemon heartbeat thread rewrites every ``heartbeat_interval`` seconds. A contender evicts a member whose record has not changed for ``stale_threshold`` seconds of the contender's own monotonic clock; no clock is ever compared across hosts, so skew between hosts or against the file server cannot make a live holder look dead. Writer acquire is writer-preferring: a writer enters the snapshot as soon as no live writer is named, which blocks any new reader, then waits for the named readers to leave. Writer starvation is impossible. An expired holder is not fenced by the lock: a process paused past ``stale_threshold`` resumes believing it holds the lock until its next heartbeat reports the loss through ``on_compromise``. :attr:`generation` is a monotonic fencing token for the protected resource: a store that rejects writes carrying a lower generation than the highest it has accepted refuses such a holder. Reentrancy, upgrade/downgrade rules, thread pinning, and singleton caching by canonical path match :class:`~filelock.ReadWriteLock`. Forking invalidates the inherited instance in the child so the child cannot double-own the lock with its parent; ``release()`` on that instance is a no-op, and the child must construct a new instance if it needs a lock. Trust boundary: protects against same-UID non-cooperating processes (one host or cross-host) and same-host different-UID users via ``0o600`` / ``0o700`` permissions. Does not protect against root compromise or multi-tenant mounts where hostile co-tenants share the UID. :param lock_file: path to the lock file; the protocol directory lives next to it as ``.rw`` :param timeout: maximum wait time in seconds; ``-1`` means block indefinitely :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately on contention :param is_singleton: if ``True``, reuse existing instances for the same canonical path :param heartbeat_interval: seconds between heartbeat refreshes; default 30 s :param stale_threshold: seconds a holder record may stay unchanged before a contender evicts it; defaults to ``3 * heartbeat_interval``, matching etcd's ``LeaseKeepAlive`` convention :param poll_interval: seconds between acquire retries under contention; default 0.25 s :param on_compromise: called from the heartbeat thread with a :class:`~filelock.LeaseCompromise` when the hold is lost: a peer evicted it, or refreshes failed for long enough that a peer could have .. versionadded:: 3.27.0 .. versionchanged:: 3.33.0 The on-disk protocol is a generation log; earlier releases cannot share a lock path with this one. """ _instances: ClassVar[WeakValueDictionary[str, SoftReadWriteLock]] = WeakValueDictionary() _instances_lock = threading.RLock() _instances_under_construction: ClassVar[set[str]] = set() def __init_subclass__(cls, **kwargs: object) -> None: """Give each lock subclass its own singleton registry and lock.""" super().__init_subclass__(**kwargs) cls._instances = WeakValueDictionary() cls._instances_lock = threading.RLock() cls._instances_under_construction = set() _register_fork_class(cls) def __init__( # ruff:ignore[too-many-arguments] # public constructor: one parameter per documented lock option self, lock_file: str | os.PathLike[str], timeout: float = -1, *, blocking: bool = True, is_singleton: bool = True, # ruff:ignore[unused-method-argument] # consumed by _SoftRWMeta.__call__ heartbeat_interval: float = 30.0, stale_threshold: float | None = None, poll_interval: float = 0.25, on_compromise: Callable[[LeaseCompromise], None] | None = None, ) -> None: self._creator_pid = os.getpid() heartbeat_interval, stale_threshold, poll_interval = _validate_intervals( heartbeat_interval, stale_threshold, poll_interval ) self.lock_file: str = os.fspath(lock_file) self.timeout: float = timeout self.blocking: bool = blocking self.heartbeat_interval: float = heartbeat_interval self.stale_threshold: float = stale_threshold self.poll_interval: float = poll_interval # Resolved once: a relative lock path must keep naming the same log after the process changes directory. The # final component stays unresolved, as in 4.0.12, so every release shares one log on a symlinked lock path. self._root = f"{_canonical(self.lock_file)}.rw" self._files = OsFiles(self.lock_file) self._ledger = Ledger(time.monotonic) self._log = GenerationLog(self._files, self.lock_file, self._root) self._on_compromise = on_compromise self._locks = _Locks(internal=threading.Lock(), transaction=threading.Lock()) self._hold: _Hold | None = None self._compromise: LeaseCompromise | None = None self._closed: bool = False with _ALL_INSTANCES_LOCK: _ALL_INSTANCES[id(self)] = self _register_fork_object(self) @classmethod def _reset_class_after_fork(cls) -> None: # pragma: forked child global _ALL_INSTANCES_LOCK # ruff:ignore[global-statement] # rebinds the module lock to a fresh one in the fork child _ALL_INSTANCES_LOCK = threading.Lock() cls._instances = WeakValueDictionary() cls._instances_lock = threading.RLock() cls._instances_under_construction = set() @property def generation(self) -> int | None: """ The generation at which the current hold was granted, or ``None`` when no lock is held. Generations are monotonic across all participants and hosts, so this is a fencing token: pass it to the protected resource and have the resource reject any operation carrying a lower generation than the highest it has accepted. That refuses a holder that paused past ``stale_threshold``, was evicted, and resumed. Removing the ``.rw`` directory starts a new log whose generations begin again at 1. .. versionadded:: 3.33.0 """ with self._locks.internal: return None if self._hold is None else self._hold.participant.generation @property def compromise(self) -> LeaseCompromise | None: """ How the current hold was lost, or ``None`` while it stands. Set by the heartbeat thread once a peer has evicted this holder or refreshes have failed for long enough that a peer could have. The holder should stop using the protected resource when it is set. Until the lost hold is released, acquiring on this instance raises :class:`RuntimeError` instead of joining it. .. versionadded:: 3.33.0 """ with self._locks.internal: return self._compromise @contextmanager def read_lock(self, timeout: float | None = None, *, blocking: bool | None = None) -> Generator[None]: """ Context manager that acquires and releases a shared read lock. Falls back to instance defaults for *timeout* and *blocking* when ``None``. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default :raises RuntimeError: if a write lock is already held on this instance :raises Timeout: if the lock cannot be acquired within *timeout* seconds :raises TypeError: if *timeout* is a :class:`bool` or not a real number :raises ValueError: if a blocking call gets a ``nan`` *timeout* or a negative one other than ``-1`` """ self.acquire_read(timeout, blocking=blocking) try: yield finally: self.release() @contextmanager def write_lock(self, timeout: float | None = None, *, blocking: bool | None = None) -> Generator[None]: """ Context manager that acquires and releases an exclusive write lock. Falls back to instance defaults for *timeout* and *blocking* when ``None``. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default :raises RuntimeError: if a read lock is already held, or a write lock is held by a different thread :raises Timeout: if the lock cannot be acquired within *timeout* seconds :raises TypeError: if *timeout* is a :class:`bool` or not a real number :raises ValueError: if a blocking call gets a ``nan`` *timeout* or a negative one other than ``-1`` """ self.acquire_write(timeout, blocking=blocking) try: yield finally: self.release() def acquire_read(self, timeout: float | None = None, *, blocking: bool | None = None) -> AcquireReturnProxy: """ Acquire a shared read lock. If this instance already holds a read lock, the lock level is incremented (reentrant). Attempting to acquire a read lock while holding a write lock raises :class:`RuntimeError` (downgrade not allowed). On the 0→1 transition the reader publishes its holder record, enters the next snapshot once no live writer is named, and starts a daemon heartbeat thread that rewrites the record's nonce every ``heartbeat_interval`` seconds so peers on other hosts do not evict it. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default; ``-1`` means block indefinitely :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately when the lock is unavailable; ``None`` uses the instance default :returns: a proxy that can be used as a context manager to release the lock :raises RuntimeError: if a write lock is already held on this instance, if this instance was invalidated by :func:`os.fork`, or if :meth:`close` was called :raises Timeout: if the lock cannot be acquired within *timeout* seconds :raises TypeError: if *timeout* is a :class:`bool` or not a real number :raises ValueError: if a blocking call gets a ``nan`` *timeout* or a negative one other than ``-1`` :raises SoftFileLockProtocolError: if a snapshot cannot be read without risking overlap, or the filesystem refuses the no-replace hard links the protocol commits with """ return self._acquire("read", timeout, blocking=blocking) def acquire_write(self, timeout: float | None = None, *, blocking: bool | None = None) -> AcquireReturnProxy: """ Acquire an exclusive write lock. If this instance already holds a write lock from the same thread, the lock level is incremented (reentrant). Attempting to acquire a write lock while holding a read lock raises :class:`RuntimeError` (upgrade not allowed). Write locks are pinned to the acquiring thread: a different thread trying to re-enter also raises :class:`RuntimeError`. A writer enters the next snapshot as its writer as soon as no live writer is named, which blocks every new reader on every host, then waits for the readers that snapshot names to leave. Writer starvation is impossible: new readers see the named writer and wait behind it. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default; ``-1`` means block indefinitely :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately when the lock is unavailable; ``None`` uses the instance default :returns: a proxy that can be used as a context manager to release the lock :raises RuntimeError: if a read lock is already held, if a write lock is held by a different thread, if this instance was invalidated by :func:`os.fork`, or if :meth:`close` was called :raises Timeout: if the lock cannot be acquired within *timeout* seconds :raises TypeError: if *timeout* is a :class:`bool` or not a real number :raises ValueError: if a blocking call gets a ``nan`` *timeout* or a negative one other than ``-1`` :raises SoftFileLockProtocolError: if a snapshot cannot be read without risking overlap, or the filesystem refuses the no-replace hard links the protocol commits with """ return self._acquire("write", timeout, blocking=blocking) @classmethod def get_lock( cls, lock_file: str | os.PathLike[str], timeout: float = -1, *, blocking: bool = True, ) -> SoftReadWriteLock: """ Return the singleton :class:`SoftReadWriteLock` for *lock_file*. :param lock_file: path to the lock file; the protocol directory lives next to it as ``.rw`` :param timeout: maximum wait time in seconds; ``-1`` means block indefinitely :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately when the lock is unavailable :returns: the singleton lock instance :raises ValueError: if an instance already exists for this path with different *timeout* or *blocking* values """ return cls(lock_file, timeout, blocking=blocking) def close(self) -> None: """ Release any held lock and mark the instance closed. Idempotent. After calling this method the instance can no longer acquire locks — subsequent acquires raise :class:`RuntimeError`, and so does an acquire another thread is still waiting in, which gives up its claim instead of taking the lock. A fork-invalidated instance is closed without raising. """ if self._creator_pid != os.getpid(): # pragma: forked child return # Closed first, under the lock an acquire publishes its hold under: an acquire still waiting then sees the flag # and leaves instead of publishing, and one that published before it is released below. with self._locks.internal: self._closed = True self.release(force=True) def release(self, *, force: bool = False) -> None: """ Release one level of the current lock. When the lock level reaches zero the heartbeat thread is stopped, the participant is committed out of the next snapshot, and its holder record is removed. On a fork-invalidated instance (that is, the child of a :func:`os.fork` call made while the parent held a lock) this method is a no-op so inherited ``with`` blocks can unwind cleanly in the child. :param force: if ``True``, release the lock completely regardless of the current lock level :raises RuntimeError: if no lock is currently held and *force* is ``False``, or if the calling thread holds no level of the current lock and *force* is ``False`` """ if self._creator_pid != os.getpid(): # pragma: forked child return self._release(threading.get_ident(), force=force) def _acquire_level_of( self, holder: int, mode: Mode, timeout: float, *, blocking: bool, cancel_check: Callable[[], bool] ) -> AcquireReturnProxy: # Records the level under *holder* instead of the calling thread: an async wrapper's executor thread does not # identify the tasks it holds for. Gives up with Timeout once *cancel_check* is true. return self._acquire(mode, timeout, blocking=blocking, holder=holder, cancel_check=cancel_check) def _release_level_of(self, holder: int) -> None: self._release(holder, force=False) def _holds_level_of(self, holder: int) -> bool: # Read without the internal lock: an async wrapper asks from its event loop, which must not wait out a release # running on another thread. return (hold := self._hold) is not None and (holder == hold.write_thread_id or holder in hold.read_depths) def _release(self, thread_id: int, *, force: bool) -> None: with self._locks.internal: hold = self._hold if hold is None: if force: return msg = f"Cannot release a lock on {self.lock_file} (lock id: {id(self)}) that is not held" raise RuntimeError(msg) if force: hold.level = 0 else: self._raise_if_not_holder(hold, thread_id) hold.leave_one_level(thread_id) if hold.level > 0: return self._hold = None # Stop the heartbeat before leaving, so a late tick cannot re-read a snapshot this participant just left and # report an eviction that was its own release. on_compromise runs on that thread and may be what called here, # in which case there is nothing to join. hold.heartbeat_stop.set() try: if hold.heartbeat_thread is not threading.current_thread(): hold.heartbeat_thread.join(timeout=min(self.heartbeat_interval + 1.0, threading.TIMEOUT_MAX)) finally: # The hold is already gone from this instance, so a join that raises must not strand its record too. hold.participant.leave() def _raise_if_not_holder(self, hold: _Hold, thread_id: int) -> None: # The hold's own heartbeat thread may release it, since on_compromise runs there. if thread_id == hold.heartbeat_thread.ident: return # Another thread's release would leave the generation log under the holder and let a peer in. if hold.mode == "write" and thread_id != hold.write_thread_id: msg = ( f"Cannot release write lock on {self.lock_file} (lock id: {id(self)}) " f"from thread {thread_id} while it is held by thread {hold.write_thread_id}" ) raise RuntimeError(msg) # Readers share one participant, so a thread that holds no read level would end another's hold. if hold.mode == "read" and thread_id not in hold.read_depths: msg = ( f"Cannot release read lock on {self.lock_file} (lock id: {id(self)}) " f"from thread {thread_id}, which does not hold it" ) raise RuntimeError(msg) def _acquire( self, mode: Mode, timeout: float | None, *, blocking: bool | None, holder: int | None = None, cancel_check: Callable[[], bool] | None = None, ) -> AcquireReturnProxy: if self._creator_pid != os.getpid(): # pragma: forked child msg = f"SoftReadWriteLock on {self.lock_file} was invalidated by fork(); construct a new instance" raise RuntimeError(msg) timeout = self.timeout if timeout is None else timeout blocking = self.blocking if blocking is None else blocking holder = threading.get_ident() if holder is None else holder with self._locks.internal: if self._closed: raise self._closed_error() timeout = _resolve_read_write_timeout(timeout, blocking=blocking) if self._hold is not None: return self._validate_reentrant(mode, holder) deadline = None if timeout == -1 else time.perf_counter() + timeout if not _acquire_thread_lock( self._locks.transaction, blocking=blocking, timeout=timeout, poll_interval=self.poll_interval, cancel_check=cancel_check, ): raise Timeout(self.lock_file) from None try: return self._do_acquire_inner(mode, deadline, holder, blocking=blocking, cancel_check=cancel_check) finally: self._locks.transaction.release() def _closed_error(self) -> RuntimeError: return RuntimeError(f"SoftReadWriteLock on {self.lock_file} has been closed") def _do_acquire_inner( self, mode: Mode, deadline: float | None, holder: int, *, blocking: bool, cancel_check: Callable[[], bool] | None, ) -> AcquireReturnProxy: with self._locks.internal: if self._hold is not None: return self._validate_reentrant(mode, holder) participant = Participant( self._files, self._root, mode, stale_threshold=self.stale_threshold, clock=time.monotonic, ledger=self._ledger, log=self._log, ) participant.publish() try: self._wait_for(participant.advance, deadline=deadline, blocking=blocking, cancel_check=cancel_check) except BaseException: # A contender that gives up must not stay named in the snapshot: a writer left there would block every # reader until a peer waits out the stale threshold. Its own token is all leave() touches, so it cannot # undo a peer's claim. with suppress(OSError): participant.leave() raise stop_event = threading.Event() hold = _Hold( level=1, mode=mode, write_thread_id=holder if mode == "write" else None, read_depths={holder: 1} if mode == "read" else {}, participant=participant, heartbeat_thread=_HeartbeatThread( refresh=lambda: self._refresh(participant, hold), interval=self.heartbeat_interval, stop_event=stop_event, name=f"filelock-heartbeat-{id(self):x}", ), heartbeat_stop=stop_event, last_refresh=time.monotonic(), ) # Publish the hold and start its heartbeat under one internal-lock section, so a concurrent release() never # observes a hold whose thread has not started and joins it. If the OS refuses the thread, clear the hold and # leave: left in place, a peer evicts the unrefreshed record and acquires while this instance still believes it # holds the lock. A close() that landed while this acquire waited gets the same treatment: published, the hold # would keep its heartbeat and its slot on an instance nobody can release through. start_error: BaseException | None = None with self._locks.internal: if self._closed: start_error = self._closed_error() else: self._hold = hold self._compromise = None try: hold.heartbeat_thread.start() except BaseException as error: # ruff:ignore[blind-except] # clear the slot below and re-raise self._hold = None start_error = error if start_error is not None: participant.leave() raise start_error return AcquireReturnProxy(lock=self) def _validate_reentrant(self, mode: Mode, holder: int) -> AcquireReturnProxy: hold = self._hold assert hold is not None # ruff:ignore[assert] # callers dispatch here only inside the self._hold is not None branch if hold.mode != mode: opposite = "write" if mode == "read" else "read" direction = "downgrade" if mode == "read" else "upgrade" msg = ( f"Cannot acquire {mode} lock on {self.lock_file} (lock id: {id(self)}): " f"already holding a {opposite} lock ({direction} not allowed)" ) raise RuntimeError(msg) if mode == "write" and holder != hold.write_thread_id: msg = ( f"Cannot acquire write lock on {self.lock_file} (lock id: {id(self)}) " f"from thread {holder} while it is held by thread {hold.write_thread_id}" ) raise RuntimeError(msg) # Publishing a hold clears the loss recorded for the one before, so a loss seen here is this hold's. Its # heartbeat has stopped and a peer may hold the lock, so a new level must not bypass the log. if (compromise := self._compromise) is not None: raise _lost_hold_error(self.lock_file, mode, compromise) if mode == "read": hold.read_depths[holder] = hold.read_depths.get(holder, 0) + 1 hold.level += 1 return AcquireReturnProxy(lock=self) def _wait_for( self, predicate: Callable[[], bool], *, deadline: float | None, blocking: bool, cancel_check: Callable[[], bool] | None, ) -> None: while not predicate(): # The flag is read without the lock here only to stop waiting early; publishing the hold re-checks it. if self._closed: raise self._closed_error() now = time.perf_counter() if not blocking or (deadline is not None and now >= deadline): raise Timeout(self.lock_file) # An abandoned acquire gives up like a timed-out one, so the caller's rollback withdraws its claim. if cancel_check is not None and cancel_check(): raise Timeout(self.lock_file) sleep_for = self.poll_interval if deadline is not None: sleep_for = min(sleep_for, max(deadline - now, 0.0)) time.sleep(sleep_for) def _refresh(self, participant: Participant, hold: _Hold) -> bool: # The loop ends at the first loss, so the holder hears about it once. A transient filesystem error (ESTALE / # EIO on the NFS-style filesystems this lock targets) is not a loss: retry rather than report a false # compromise. Report the record unrefreshable only once failures have run long enough that a peer could evict # it before the next success would land, a margin before the record actually ages out. try: outcome, error = participant.heartbeat() except Exception as failure: # ruff:ignore[blind-except] # the thread would end with the hold still published self._report_compromise(hold, "refresh-failed", failure) return False if outcome == "ok": hold.last_refresh = time.monotonic() return True if outcome == "lost": self._report_compromise(hold, "evicted", None) return False if time.monotonic() - hold.last_refresh >= self.stale_threshold - self.heartbeat_interval: self._report_compromise(hold, "refresh-failed", error) return False return True def _report_compromise(self, hold: _Hold, reason: CompromiseReason, error: Exception | None) -> None: # A tick that outlived its release's join must not stamp the old hold's loss onto whatever was acquired since. with self._locks.internal: if self._hold is not hold: return compromise = LeaseCompromise( lock_file=self.lock_file, token=hold.participant.token, reason=reason, error=error ) self._compromise = compromise if self._on_compromise is not None: self._on_compromise(compromise) def _reset_after_fork_in_child(self) -> None: # pragma: forked child self._locks = _Locks(internal=threading.Lock(), transaction=threading.Lock()) self._hold = None class _HeartbeatThread(threading.Thread): def __init__( self, refresh: Callable[[], bool], interval: float, stop_event: threading.Event, name: str, ) -> None: super().__init__(name=name, daemon=True) self._refresh = refresh self._interval = interval self._stop_event = stop_event def run(self) -> None: # Set on every exit, an on_compromise that raises included, so nothing waits on a thread that is gone. try: while not self._stop_event.wait(self._interval): if not self._refresh(): return finally: self._stop_event.set() def _lost_hold_error(lock_file: str, mode: Mode, compromise: LeaseCompromise) -> RuntimeError: return RuntimeError( f"Cannot acquire {mode} lock on {lock_file}: the current hold was lost ({compromise.reason}), " "release it before acquiring again" ) def _validate_intervals( heartbeat_interval: float, stale_threshold: float | None, poll_interval: float ) -> tuple[float, float, float]: heartbeat_interval = _seconds("heartbeat_interval", heartbeat_interval) if not isfinite(heartbeat_interval) or heartbeat_interval <= 0: msg = f"heartbeat_interval must be positive and finite, got {heartbeat_interval}" raise ValueError(msg) # The heartbeat thread waits this long between refreshes, and Event.wait overflows past TIMEOUT_MAX. _check_timeout_max("heartbeat_interval", heartbeat_interval) stale_threshold = ( heartbeat_interval * 3 if stale_threshold is None else _seconds("stale_threshold", stale_threshold) ) if not isfinite(stale_threshold) or stale_threshold <= heartbeat_interval: msg = ( f"stale_threshold must exceed heartbeat_interval ({heartbeat_interval}) " f"and be finite, got {stale_threshold}" ) raise ValueError(msg) if not isfinite(poll_interval := _seconds("poll_interval", poll_interval)) or poll_interval <= 0: msg = f"poll_interval must be positive and finite, got {poll_interval}" raise ValueError(msg) _check_timeout_max("poll_interval", poll_interval) # A waiting writer refreshes its record once per poll, so a poll slower than the threshold reads as dead. if poll_interval >= stale_threshold: msg = f"poll_interval must be below stale_threshold ({stale_threshold}), got {poll_interval}" raise ValueError(msg) return heartbeat_interval, stale_threshold, poll_interval @dataclass class _Locks: internal: threading.Lock transaction: threading.Lock @dataclass class _Hold: """Everything that exists only while a lock is held; ``None`` when the instance has no lock.""" level: int mode: Mode write_thread_id: int | None #: Read levels per thread, so a thread can give back only the levels it took. read_depths: dict[int, int] participant: Participant heartbeat_thread: _HeartbeatThread heartbeat_stop: threading.Event last_refresh: float def leave_one_level(self, thread_id: int) -> None: self.level -= 1 if self.mode == "read": # The heartbeat thread holds no level of its own, so its release gives back one of a reader's. owner = thread_id if thread_id in self.read_depths else next(iter(self.read_depths)) if depth := self.read_depths.pop(owner) - 1: self.read_depths[owner] = depth def _cleanup_all_instances() -> None: # pragma: no cover - runs from atexit at interpreter shutdown for instance in list(_ALL_INSTANCES.values()): with suppress(Exception): instance.release(force=True) atexit.register(_cleanup_all_instances) _register_fork_class(SoftReadWriteLock) __all__ = [ "SoftReadWriteLock", "_lost_hold_error", ] filelock-4.2.8/tasks/benchmark.py0000644000000000000000000001723515272561277013722 0ustar00"""Measure the filelock performance matrix and print it as a report. The numbers establish a baseline rather than gate a build: run it before and after a change to compare medians and tail latency, the way :issue:`642` describes. Run with ``python tasks/benchmark.py`` or ``tox -e bench``. """ from __future__ import annotations import asyncio import os import platform import statistics import sys import time from contextlib import suppress from dataclasses import dataclass from multiprocessing import Event, Process from pathlib import Path from tempfile import TemporaryDirectory from typing import TYPE_CHECKING, Final import filelock from filelock import AsyncFileLock, FileLock, SoftFileLease, SoftFileLock, StrictSoftFileLock if TYPE_CHECKING: from collections.abc import Callable, Iterator from multiprocessing.synchronize import Event as EventType _ITERATIONS: Final[int] = 200 _WARMUP: Final[int] = 20 _CONTENTION_PROCESSES: Final[int] = 8 _CONTENTION_ACQUISITIONS: Final[int] = 500 @dataclass(frozen=True) class Sample: """One measured metric, in the unit named by ``unit``.""" name: str median: float p95: float unit: str def main() -> None: print(_environment()) samples: list[Sample] = [] with TemporaryDirectory(prefix="filelock-bench-") as directory: root = Path(directory) samples += _construction(root) samples += _uncontended(root) samples.append(_timeout_cpu(root)) samples.append(_descriptor_growth(root)) samples += _cancellation_latency(root) samples += _contention(root) _report(samples) def _environment() -> str: gil = getattr(sys, "_is_gil_enabled", lambda: True)() build = "gil" if gil else "free-threaded" return ( f"filelock {filelock.__version__} | {platform.python_implementation()} {platform.python_version()} ({build}) | " f"{platform.system()} {platform.machine()} | {os.cpu_count()} cpus" ) def _backends(root: Path) -> Iterator[tuple[str, Callable[[], filelock.BaseFileLock]]]: # Each entry builds a fresh lock on its own path so no two cases contend. ReadWriteLock is soft-optional, so it is # measured separately where it applies rather than through this exclusive-lock set. yield "FileLock", lambda: FileLock(str(root / "file.lock")) yield "SoftFileLock", lambda: SoftFileLock(str(root / "soft.lock")) yield "StrictSoftFileLock", lambda: StrictSoftFileLock(str(root / "strict.lock")) yield "SoftFileLease", lambda: SoftFileLease(str(root / "lease.lock"), lease_duration=30) def _construction(root: Path) -> list[Sample]: return [_timed(f"construct {name}", build, unit="us", scale=1e6) for name, build in _backends(root)] def _uncontended(root: Path) -> list[Sample]: samples: list[Sample] = [] for name, build in _backends(root): lock = build() def acquire_release(held: filelock.BaseFileLock = lock) -> None: held.acquire() held.release() samples.append(_timed(f"acquire+release {name}", acquire_release, unit="us", scale=1e6)) return samples def _timeout_cpu(root: Path) -> Sample: # A contended acquire with a zero poll interval should spin on the clock, not the CPU. Hold the lock, time out a # second acquirer, and report the CPU time the wait burned; a busy loop would show wall-sized CPU here. holder = FileLock(str(root / "timeout.lock")) holder.acquire() waiter = FileLock(str(root / "timeout.lock")) try: started = time.process_time() with suppress(filelock.Timeout): waiter.acquire(timeout=0.25, poll_interval=0.0) cpu = time.process_time() - started finally: holder.release() return Sample("timeout cpu (0.25s wall, poll=0)", cpu * 1e3, cpu * 1e3, "ms cpu") def _descriptor_growth(root: Path) -> Sample: # Acquire and release many times; a descriptor leak would show as a rising open-descriptor count. lock = FileLock(str(root / "fd.lock")) before = _open_descriptors() for _ in range(100): lock.acquire() lock.release() grew = _open_descriptors() - before return Sample("descriptor growth over 100 cycles", float(grew), float(grew), "fds") def _cancellation_latency(root: Path) -> list[Sample]: # Hold the lock from another process so the async waiter blocks on the OS lock rather than on a peer instance in the # same task, which the deadlock guard would refuse. Then time canceling the awaiting task to the acquire unwinding. path = str(root / "cancel.lock") acquired, release = Event(), Event() holder = Process(target=_hold_until, args=(path, acquired, release)) holder.start() async def measure() -> float: waiter = AsyncFileLock(path) task = asyncio.ensure_future(waiter.acquire(poll_interval=0.01)) await asyncio.sleep(0.02) started = time.perf_counter() task.cancel() with suppress(asyncio.CancelledError): await task return time.perf_counter() - started try: acquired.wait(timeout=5) latencies = [asyncio.run(measure()) for _ in range(20)] finally: release.set() holder.join(timeout=5) return [ Sample( "async acquire cancellation latency", statistics.median(latencies) * 1e3, _percentile(latencies, 95) * 1e3, "ms", ) ] def _hold_until(path: str, acquired: EventType, release: EventType) -> None: lock = FileLock(path) lock.acquire() acquired.set() release.wait(timeout=30) lock.release() def _contention(root: Path) -> list[Sample]: samples: list[Sample] = [] for name, path in (("FileLock", root / "c-file.lock"), ("StrictSoftFileLock", root / "c-strict.lock")): started = time.perf_counter() workers = [Process(target=_hammer, args=(name, str(path))) for _ in range(_CONTENTION_PROCESSES)] for worker in workers: worker.start() for worker in workers: worker.join() elapsed = time.perf_counter() - started total = _CONTENTION_PROCESSES * _CONTENTION_ACQUISITIONS per = elapsed * 1e3 / total samples.append(Sample(f"contention {name} ({total} acquisitions)", per, per, "ms/acq")) return samples def _hammer(backend: str, path: str) -> None: lock = FileLock(path) if backend == "FileLock" else StrictSoftFileLock(path) for _ in range(_CONTENTION_ACQUISITIONS): lock.acquire() lock.release() def _timed(name: str, run: Callable[[], object], *, unit: str, scale: float) -> Sample: for _ in range(_WARMUP): run() durations = [_one(run) for _ in range(_ITERATIONS)] return Sample(name, statistics.median(durations) * scale, _percentile(durations, 95) * scale, unit) def _one(run: Callable[[], object]) -> float: started = time.perf_counter() run() return time.perf_counter() - started def _percentile(values: list[float], percentile: int) -> float: ordered = sorted(values) index = min(len(ordered) - 1, round(percentile / 100 * len(ordered))) return ordered[index] def _open_descriptors() -> int: for directory in ("/proc/self/fd", "/dev/fd"): with suppress(OSError): return sum(1 for _ in Path(directory).iterdir()) return 0 # pragma: no cover # neither procfs nor /dev/fd exists on this platform def _report(samples: list[Sample]) -> None: width = max(len(sample.name) for sample in samples) print(f"\n{'metric':<{width}} {'median':>12} {'p95':>12} unit") print("-" * (width + 34)) for sample in samples: print(f"{sample.name:<{width}} {sample.median:>12.3f} {sample.p95:>12.3f} {sample.unit}") if __name__ == "__main__": main() filelock-4.2.8/tasks/capabilities.py0000644000000000000000000003366315272561277014424 0ustar00"""What the runtime this suite is running on can actually do. Probed rather than inferred from a platform or interpreter name: a name answers who is running, and every question here is about what the runtime can do. The coverage pragmas and the tests' skipif gates both read these, so a test cannot skip while coverage still demands its lines. Kept apart from the pragma plugin so reading a capability never requires coverage to be installed. """ from __future__ import annotations import gc import os import signal import socket import stat import sys import tempfile import time import weakref from asyncio import CancelledError from contextlib import asynccontextmanager, contextmanager, suppress from importlib import import_module from importlib.util import find_spec from itertools import count from pathlib import Path from typing import TYPE_CHECKING, Final if TYPE_CHECKING: from collections.abc import AsyncGenerator, Generator from types import FrameType from _typeshed import Unused def _supports_symlink() -> bool: # Windows grants this per privilege, not per platform, so ask the filesystem. with tempfile.TemporaryDirectory() as directory: target = Path(directory, "target") target.touch() try: Path(directory, "link").symlink_to(target) except (OSError, NotImplementedError, AttributeError): return False return True def _supports_unlinking_an_open_file() -> bool: # Where this is refused, a peer can never take a live holder's marker. with tempfile.TemporaryDirectory() as directory: victim = Path(directory, "victim") victim.touch() with victim.open("rb"): try: victim.unlink() except OSError: return False return True def _honors_link_follow_symlinks() -> bool: # PyPy advertises the option then rejects it with EINVAL, so trust a real link over os.supports_follow_symlinks. if not hasattr(os, "link"): return False with tempfile.TemporaryDirectory() as directory: source = Path(directory, "source") source.touch() try: os.link(source, Path(directory, "link"), follow_symlinks=False) except (OSError, NotImplementedError, ValueError): return False return True def _finalizes_on_last_reference() -> bool: # Only a refcounting collector runs __del__ the moment the last reference goes; a tracing one defers it. finalized: list[bool] = [] class Probe: def __del__(self) -> None: finalized.append(True) Probe() return bool(finalized) def _finalizes_on_collection() -> bool: # GraalPy queues finalizers on the host collector, so even a forced collection does not run __del__. finalized: list[bool] = [] class Probe: def __del__(self) -> None: finalized.append(True) Probe() gc.collect() return bool(finalized) def _collects_classes() -> bool: # GraalPy can collect a plain class while retaining one instantiated through a custom metaclass. class ProbeMeta(type): def __call__(cls) -> object: return super().__call__() def build() -> type: class Probe(metaclass=ProbeMeta): pass Probe() return Probe reference: Final = weakref.ref(build()) gc.collect() return reference() is None def _preserves_context_thrown_into_a_generator() -> bool: # GraalPy resets __context__ when contextlib throws into the suspended generator, losing the chained cause. @contextmanager def probe() -> Generator[None]: yield error = KeyError("thrown") error.__context__ = ValueError("context") try: with probe(): raise error except KeyError as caught: return caught.__context__ is not None return False # pragma: no cover # the raise above always propagates class _Suspend: """An awaitable that parks its coroutine once, so a probe can throw into a suspended frame without a loop.""" def __await__(self) -> Generator[None, None, None]: yield def _propagates_a_cancellation_thrown_into_a_coroutine() -> bool: # Driven by hand so the probe needs no event loop: GraalPy answers athrow with RuntimeError instead of the # CancelledError, so every cancellation crossing an async context manager surfaces as the wrong exception. @asynccontextmanager async def gate() -> AsyncGenerator[None]: await _Suspend() yield async def body() -> None: async with gate(): pass coroutine = body() coroutine.send(None) try: coroutine.throw(CancelledError()) except CancelledError: return True except BaseException: # ruff:ignore[blind-except] # whatever else a runtime substitutes counts as the deviation return False return False # pragma: no cover # throwing into the suspended coroutine always raises def _finishes_a_generator_a_profile_hook_interrupts_on_resume() -> bool: # PyPy and GraalPy can end the generator without its finally, leaking resources when a test injects an interrupt. finished: Final[list[bool]] = [] def probe() -> Generator[None]: try: yield finally: finished.append(True) entries: Final = count() def interrupt(frame: FrameType, event: str, _arg: Unused) -> None: # The first entry starts the generator; every later one resumes it. if event == "call" and frame.f_code is probe.__code__ and next(entries): raise KeyboardInterrupt previous: Final = sys.getprofile() sys.setprofile(interrupt) try: with suppress(KeyboardInterrupt), contextmanager(probe)(): pass finally: sys.setprofile(previous) return bool(finished) def _runs_a_raised_signal_handler_at_once() -> bool: # GraalPy runs the handler some time after raise_signal returns, so the code the signal should interrupt has already # moved on, and the late signal lands in whatever runs next. delivered: Final[list[bool]] = [] try: previous: Final = signal.signal(signal.SIGINT, lambda _signum, _frame: delivered.append(True)) except ValueError: # only the main thread may install a handler return False try: signal.raise_signal(signal.SIGINT) at_once: Final = bool(delivered) # Restoring the previous handler while the signal is still pending would hand it a KeyboardInterrupt. deadline: Final = time.monotonic() + 5 while not delivered and time.monotonic() < deadline: time.sleep(0.01) finally: signal.signal(signal.SIGINT, previous) return at_once _AUDIT_PROBE_EVENT: Final[str] = "filelock.capability-probe" def _delivers_audit_events() -> bool: # GraalPy accepts a hook and never calls it. The hook is a permanent no-op; tests install their own anyway. delivered: list[bool] = [] sys.addaudithook(lambda event, _args: delivered.append(True) if event == _AUDIT_PROBE_EVENT else None) sys.audit(_AUDIT_PROBE_EVENT) return bool(delivered) def _survives_settrace_inside_audit_hook() -> bool: # CPython before 3.12 holds one process-wide flag while settrace and setprofile run their audit hooks, so a nested # call raises. Probe through setprofile: re-installing the trace function would unhook coverage's C tracer. nested: list[bool] = [] def setprofile_again(event: str, _args: object) -> None: if event == "sys.setprofile" and not nested: nested.append(True) sys.setprofile(sys.getprofile()) sys.addaudithook(setprofile_again) try: sys.setprofile(sys.getprofile()) except RuntimeError: return False return True def _refuses_to_open_a_symlink() -> bool: # GraalPy accepts O_NOFOLLOW then follows the link anyway, so ask for the refusal rather than the constant. if not hasattr(os, "O_NOFOLLOW"): return False if not _supports_symlink(): # Nothing to point the probe at, so keep the constant's answer rather than reporting a gap we cannot see. return True with tempfile.TemporaryDirectory() as directory: target = Path(directory, "target") target.touch() link = Path(directory, "link") link.symlink_to(target) try: descriptor = os.open(link, os.O_RDONLY | os.O_NOFOLLOW) except OSError: return True os.close(descriptor) return False def _grants_a_file_lease() -> bool: # Only Linux has F_SETLEASE, and a filesystem or fs.leases-enable=0 can still refuse it. try: fcntl = import_module("fcntl") except ImportError: return False if not hasattr(fcntl, "F_SETLEASE"): return False with tempfile.TemporaryFile() as handle: try: fcntl.fcntl(handle.fileno(), fcntl.F_SETLEASE, fcntl.F_WRLCK) except OSError: return False return True def _reports_object_tracebacks() -> bool: # pytest's thread and unraisable exception hooks ask tracemalloc where the offending object was allocated. GraalPy # raises NotImplementedError from that lookup; CPython answers None while not tracing, and PyPy, which ships no # tracemalloc, is never asked. try: get_object_traceback = import_module("tracemalloc").get_object_traceback except ImportError: return True try: get_object_traceback(object()) except NotImplementedError: return False return True def _supports_file_mode() -> bool: with _unreadable_file() as probe: return probe.stat().st_mode & 0o777 == 0 def _enforces_file_permissions() -> bool: # Privileged processes can retain mode bits while bypassing access checks. with _unreadable_file() as probe: try: probe.read_bytes() except PermissionError: return True return False @contextmanager def _unreadable_file() -> Generator[Path]: with tempfile.TemporaryDirectory() as directory: probe: Final[Path] = Path(directory, "probe") probe.touch() probe.chmod(0) try: yield probe finally: probe.chmod(0o600) def _has_a_sticky_tmp_of_another_user() -> bool: # A lock refuses a protocol directory of another account only under a sticky parent this account does not own. # /tmp is that parent on Linux and macOS, unless the suite runs as the user who owns it. if sys.platform == "win32": return False try: tmp: Final = Path("/tmp").stat() # ruff:ignore[hardcoded-temp-file] # the shared directory is the subject except OSError: return False return bool(tmp.st_mode & stat.S_ISVTX) and tmp.st_uid != os.geteuid() def _lists_every_open_descriptor() -> bool: # FreeBSD's devfs and NetBSD provide only the standard streams under /dev/fd until fdescfs is mounted, so a listing # there misses every descriptor a test opened. view: Final = next((view for view in ("/dev/fd", "/proc/self/fd") if Path(view).is_dir()), None) with Path(os.devnull).open("rb") as probe: return view is not None and Path(view, str(probe.fileno())).exists() _LINK_FOLLOW_SYMLINKS: Final[bool] = _honors_link_follow_symlinks() #: Capability -> whether this runtime provides it. Tests gate their skipif on this same mapping. CAPABILITIES: Final[dict[str, bool]] = { "fork": hasattr(os, "fork") and hasattr(os, "register_at_fork"), # Narrower than "fork": GraalPy registers fork handlers but cannot fork. "register-at-fork": hasattr(os, "register_at_fork"), "dir-fd": os.open in os.supports_dir_fd, # Narrower than "dir-fd": GraalPy takes os.open relative to a directory descriptor but not os.link. "link-dir-fd": hasattr(os, "link") and os.link in os.supports_dir_fd, "fork1": hasattr(os, "fork1"), "hard-link": hasattr(os, "link"), "symlink": _supports_symlink(), "fcntl": find_spec("fcntl") is not None, "unlink-open-file": _supports_unlinking_an_open_file(), "posix-signals": hasattr(signal, "SIGKILL"), "prompt-signal-delivery": _runs_a_raised_signal_handler_at_once(), "file-mode": _supports_file_mode(), "file-permissions": _enforces_file_permissions(), "prompt-finalization": _finalizes_on_last_reference(), "collected-finalization": _finalizes_on_collection(), "class-collection": _collects_classes(), "generator-exception-context": _preserves_context_thrown_into_a_generator(), "coroutine-cancellation": _propagates_a_cancellation_thrown_into_a_coroutine(), "audit-events": _delivers_audit_events(), "settrace-safe-audit-hooks": _survives_settrace_inside_audit_hook(), "profile-interrupted-generator-finishes": _finishes_a_generator_a_profile_hook_interrupts_on_resume(), "fd-directory": any(Path(view).is_dir() for view in ("/dev/fd", "/proc/self/fd")), "complete-fd-directory": _lists_every_open_descriptor(), "fifo": hasattr(os, "mkfifo"), "foreign-sticky-tmp": _has_a_sticky_tmp_of_another_user(), "af-unix": hasattr(socket, "AF_UNIX"), # Distinct from "symlink": a runtime can create them yet still not refuse to follow one. "o-nofollow": _refuses_to_open_a_symlink(), "file-lease": _grants_a_file_lease(), "utime-nofollow": os.utime in os.supports_follow_symlinks, "access-nofollow": os.access in os.supports_follow_symlinks, "utime-fd": os.utime in os.supports_fd, "sqlite3": find_spec("sqlite3") is not None, "lazy-imports": hasattr(sys, "set_lazy_imports"), # A source consumer may run the suite unmeasured, and a forked child then has nothing to flush. "coverage": find_spec("coverage") is not None, "link-follow-symlinks": _LINK_FOLLOW_SYMLINKS, "posix-hard-link": sys.platform != "win32" and _LINK_FOLLOW_SYMLINKS, "tracemalloc-object-traceback": _reports_object_tracebacks(), # Only the tox env that installs a released filelock sets this. "old-client": bool(os.environ.get("FILELOCK_OLD_CLIENT_PATH")), } __all__ = [ "CAPABILITIES", ] filelock-4.2.8/tasks/coverage_pragmas.py0000644000000000000000000000706215272561277015272 0ustar00"""Coverage exclusions keyed on the capability that code needs. covdefaults keys its pragmas on ``os.name``, ``sys.platform`` and ``sys.implementation.name``, so code that cannot run for a capability reason has to name a platform as a stand-in. The stand-in goes stale. A ``win32 no cover`` sat on ``os.link``'s follow_symlinks fallback and demanded a branch modern Windows never takes, and the same guess hid real Windows gaps behind platform-agnostic code. ``# pragma: needs `` drops out only where the capability is absent, and ``# pragma: lacks `` only where it is present. Both read the probes in :mod:`capabilities`, as do the tests' skipif gates, so a test cannot skip while coverage still demands its lines. List this after covdefaults in ``[tool.coverage] run.plugins``; both merge into the same options. """ from __future__ import annotations from typing import TYPE_CHECKING, Final, cast from capabilities import CAPABILITIES from coverage import CoveragePlugin if TYPE_CHECKING: from collections.abc import Iterable from coverage.plugin_support import Plugins from coverage.types import TConfigurable # coverage passes the config's plugin options; this plugin takes none. def coverage_init(reg: Plugins, options: dict[str, str]) -> None: # ruff:ignore[unused-function-argument] reg.add_configurer(CapabilityPragmas()) class CapabilityPragmas(CoveragePlugin): """Exclude ``needs``/``lacks`` capability pragmas on the runtimes where that code cannot run.""" def configure(self, config: TConfigurable) -> None: excluded = [ rf"# pragma: {'lacks' if present else 'needs'} {name}\b" for name, present in sorted(CAPABILITIES.items()) ] self._extend(config, "report:exclude_lines", [*excluded, *_ALWAYS_EXCLUDED]) # A guarded clause header only ever takes one arc, as covdefaults already assumes for its own pragmas. self._extend(config, "report:partial_branches", [_CAPABILITY_PRAGMA, *_ALWAYS_EXCLUDED]) unrunnable = [ pattern for name, patterns in sorted(_CAPABILITY_MODULES.items()) if not CAPABILITIES[name] for pattern in patterns ] if unrunnable: self._extend(config, "report:omit", unrunnable) @staticmethod def _extend(config: TConfigurable, option: str, patterns: list[str]) -> None: # get_option is typed for every coverage option; these two always hold regexes. merged: set[str] = set(cast("Iterable[str]", config.get_option(option) or ())) merged.update(patterns) config.set_option(option, sorted(merged)) #: Modules a missing capability makes unrunnable in full; marking every line would restate one module-level gate. _CAPABILITY_MODULES: Final[dict[str, tuple[str, ...]]] = { "posix-hard-link": ( "*/tests/test_read_write_private_link.py", "*\\tests\\test_read_write_private_link.py", ), "hard-link": ( "*/tests/test_strict_soft*.py", "*/tests\\test_strict_soft*.py", "*/tests/soft_rw/*.py", "*\\tests\\soft_rw\\*.py", "*/filelock/_strict.py", "*\\filelock\\_strict.py", "*/filelock/_soft_rw/*.py", "*\\filelock\\_soft_rw\\*.py", ), } #: A forked child exits through os._exit without writing coverage data, so no job in the matrix can see these. _ALWAYS_EXCLUDED: Final[tuple[str, ...]] = (r"# pragma: forked child\b",) _CAPABILITY_PRAGMA: Final[str] = rf"# pragma: (needs|lacks) ({'|'.join(sorted(CAPABILITIES))})\b" __all__ = [ "CAPABILITIES", "coverage_init", ] filelock-4.2.8/tasks/deny_symlink.py0000644000000000000000000001503615272561277014472 0ustar00"""Run a command on Windows with symbolic-link creation denied, and report why it is or is not denied. Windows grants symlink creation through two independent paths, so closing one proves nothing. A process holding SeCreateSymbolicLinkPrivilege may always create them; separately, Developer Mode lets an unprivileged process create them through SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE. The CI job clears the Developer Mode registry value, and this script removes the privilege from its own token. SE_PRIVILEGE_REMOVED is documented as irreversible, with checks for a removed privilege returning STATUS_PRIVILEGE_NOT_HELD, which is the guarantee this relies on. The documentation does not say whether a child inherits the removal or whether clearing Developer Mode needs a reboot, so this reports what it observes rather than assuming. Read those lines in the job log before trusting the job. Usage: python deny_symlink.py [args...] """ from __future__ import annotations import ctypes import subprocess # ruff:ignore[suspicious-subprocess-import] # launching the suite is this script's whole job import sys import tempfile from ctypes import wintypes from pathlib import Path from typing import Final _TOKEN_ADJUST_PRIVILEGES: Final[int] = 0x0020 _TOKEN_QUERY: Final[int] = 0x0008 _SE_PRIVILEGE_REMOVED: Final[int] = 0x0004 _ERROR_NOT_ALL_ASSIGNED: Final[int] = 1300 _PRIVILEGE: Final[str] = "SeCreateSymbolicLinkPrivilege" class _Luid(ctypes.Structure): _fields_ = (("low_part", wintypes.DWORD), ("high_part", wintypes.LONG)) class _LuidAndAttributes(ctypes.Structure): _fields_ = (("luid", _Luid), ("attributes", wintypes.DWORD)) class _TokenPrivileges(ctypes.Structure): _fields_ = (("privilege_count", wintypes.DWORD), ("privileges", _LuidAndAttributes * 1)) def main() -> int: if sys.platform != "win32": print("deny_symlink only applies to Windows") return 1 print(f"developer mode: {_developer_mode()}") print(f"{_PRIVILEGE} before: {_privilege_state()}") print(f"symlink before: {_can_symlink()}") print(f"removal reported: {_remove_privilege()}") print(f"{_PRIVILEGE} after: {_privilege_state()}") print(f"symlink after (this process): {_can_symlink()}") child = subprocess.run( [sys.executable, "-c", _CHILD_PROBE], capture_output=True, text=True, check=False, ) print(f"symlink after (child process): {child.stdout.strip() or child.stderr.strip()}") return subprocess.run(sys.argv[1:], check=False).returncode def _developer_mode() -> str: # ty needs the platform narrowed to see the Windows-only members from any host. assert sys.platform == "win32" import winreg # ruff:ignore[import-outside-top-level] Windows-only, and this module already refuses to run elsewhere try: with winreg.OpenKey( winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" ) as key: return str(winreg.QueryValueEx(key, "AllowDevelopmentWithoutDevLicense")[0]) except OSError as error: return f"unreadable ({error})" def _privilege_state() -> str: result = subprocess.run( ["whoami", "/priv"], # ruff:ignore[start-process-with-partial-path] resolved from PATH on every Windows image capture_output=True, text=True, check=False, ) for line in result.stdout.splitlines(): if _PRIVILEGE in line: return line.strip() return "absent from the token" def _remove_privilege() -> str: # ty needs the platform narrowed to see the Windows-only members from any host. assert sys.platform == "win32" advapi32 = ctypes.WinDLL("advapi32", use_last_error=True) kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) # Declare the signatures: GetCurrentProcess returns the pseudo-handle (HANDLE)-1, and ctypes' default c_int restype # truncates it on 64-bit, so OpenProcessToken is handed a bad handle and fails with ERROR_INVALID_HANDLE. kernel32.GetCurrentProcess.argtypes = () kernel32.GetCurrentProcess.restype = wintypes.HANDLE advapi32.OpenProcessToken.argtypes = (wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)) advapi32.OpenProcessToken.restype = wintypes.BOOL advapi32.LookupPrivilegeValueW.argtypes = (wintypes.LPCWSTR, wintypes.LPCWSTR, ctypes.POINTER(_Luid)) advapi32.LookupPrivilegeValueW.restype = wintypes.BOOL advapi32.AdjustTokenPrivileges.argtypes = ( wintypes.HANDLE, wintypes.BOOL, ctypes.POINTER(_TokenPrivileges), wintypes.DWORD, ctypes.c_void_p, ctypes.c_void_p, ) advapi32.AdjustTokenPrivileges.restype = wintypes.BOOL token = wintypes.HANDLE() if not advapi32.OpenProcessToken( kernel32.GetCurrentProcess(), _TOKEN_ADJUST_PRIVILEGES | _TOKEN_QUERY, ctypes.byref(token) ): return f"OpenProcessToken failed with {ctypes.get_last_error()}" luid = _Luid() if not advapi32.LookupPrivilegeValueW(None, _PRIVILEGE, ctypes.byref(luid)): return f"LookupPrivilegeValue failed with {ctypes.get_last_error()}" privileges = _TokenPrivileges(1, (_LuidAndAttributes * 1)(_LuidAndAttributes(luid, _SE_PRIVILEGE_REMOVED))) if not advapi32.AdjustTokenPrivileges(token, False, ctypes.byref(privileges), 0, None, None): # ruff:ignore[boolean-positional-value-in-call] return f"AdjustTokenPrivileges failed with {ctypes.get_last_error()}" # The call reports success even when the token never held the privilege, so read the code it leaves behind. if (code := ctypes.get_last_error()) == _ERROR_NOT_ALL_ASSIGNED: return "the token did not hold the privilege" return "removed" if code == 0 else f"unexpected code {code}" _CHILD_PROBE: Final[str] = """ import pathlib, tempfile with tempfile.TemporaryDirectory() as directory: target = pathlib.Path(directory, "target") target.touch() try: pathlib.Path(directory, "link").symlink_to(target) except OSError as error: print(f"False (WinError {error.winerror})") else: print("True") """ def _can_symlink() -> str: # ty needs the platform narrowed to see the Windows-only members from any host. assert sys.platform == "win32" with tempfile.TemporaryDirectory() as directory: target = Path(directory, "target") target.touch() try: Path(directory, "link").symlink_to(target) except OSError as error: return f"False (WinError {error.winerror})" return "True" if __name__ == "__main__": raise SystemExit(main()) filelock-4.2.8/tasks/verify_filesystem.py0000644000000000000000000002202415272561277015530 0ustar00"""Verify that filelock holds mutual exclusion on a target filesystem, and exit non-zero when it does not. Point it at a directory on the filesystem under test (an NFS or SMB mount, say) and it runs a mutual-exclusion check: several processes each take the lock a fixed number of times and record the wall-clock interval they spend holding it. A correct lock never lets two holders overlap and lets every process finish its holds, so intervals never intersect and the completed count matches the expected total. Run with ``python tasks/verify_filesystem.py [directory]``. Overlap detection rather than a shared counter is deliberate. A lost-update counter conflates lock exclusion with data cache coherence: two independent NFS client caches can lose an update to a counter even under a perfectly exclusive lock, because a read-modify-write reads a stale cached copy. Each process here only returns its own intervals (no shared mutable state read across caches), so the check measures the lock and nothing else. CLOCK_MONOTONIC is system-wide on one host, so intervals from sibling processes are directly comparable. The contention is deliberately moderate and dispersed so the result is deterministic: a small process count, a short hold, and a randomized gap between holds keep any contender from being starved out, and the genuinely transient NFS errors (a stale handle or a momentary permission race under concurrent create and unlink, which robust NFS clients retry) are retried rather than treated as failures. A correct lock then finishes every hold on every run. Every lock type is always run and printed, so the output records the real behavior of each on the filesystem. FILELOCK_VERIFY_LOCKS narrows only which lock types failing turns the exit code non-zero: a filesystem where a type is known-unsupported (the strict claim over SMB) still reports its result without failing the gate. """ from __future__ import annotations import os import random import sys import time from concurrent.futures import ProcessPoolExecutor from errno import EACCES, ENOENT, ESTALE from pathlib import Path from tempfile import TemporaryDirectory from typing import TYPE_CHECKING, Final from filelock import FileLock, SoftFileLock, SoftFileLockProtocolError, StrictSoftFileLock if TYPE_CHECKING: from collections.abc import Callable _PROCESSES: Final[int] = 4 _HOLDS: Final[int] = 100 _ACQUIRE_TIMEOUT: Final[float] = 60.0 _HOLD_SECONDS: Final[float] = 0.004 _GAP_MAX_SECONDS: Final[float] = 0.008 _TRANSIENT_RETRIES: Final[int] = 16 _RETRY_BACKOFF: Final[float] = 0.01 _TRANSIENT_ERRNOS: Final[frozenset[int]] = frozenset({EACCES, ENOENT, ESTALE}) _ALL_LOCKS: Final[tuple[str, ...]] = ("FileLock", "SoftFileLock", "StrictSoftFileLock") def main() -> int: # Each argument is a mount of the same filesystem. Two independent mounts (an NFS export mounted twice with # nosharecache, say) are two client caches over one server, so contending across them is a genuine multi-client # check, not two views of one cache. With no arguments a temporary directory checks the local single-mount case. if mounts := [Path(argument) for argument in sys.argv[1:]]: for mount in mounts: mount.mkdir(parents=True, exist_ok=True) return _verify_across(mounts) with TemporaryDirectory(prefix="filelock-verify-") as directory: return _verify_across([Path(directory)]) def _verify_across(mounts: list[Path]) -> int: where = str(mounts[0]) if len(mounts) == 1 else f"{len(mounts)} mounts of {mounts[0]} .. {mounts[-1]}" print(f"verifying mutual exclusion across {where} ({_PROCESSES} processes x {_HOLDS} holds)") gated = _gated_locks() failures = 0 for name in _ALL_LOCKS: counts, overlaps, reason = _run_one(name, mounts) expected = _PROCESSES * _HOLDS # A correct lock never overlaps, finishes every hold, and raises no error the retries could not clear. ok = overlaps == 0 and sum(counts) == expected and reason is None failures += (not ok) and name in gated note = "" if name in gated else " (ungated)" tail = f"{note}{f' {reason}' if reason else ''}" stats = f"held={sum(counts)}/{expected} overlaps={overlaps}" print(f" {name:20} {'PASS' if ok else 'FAIL'} {stats}{tail}") return 1 if failures else 0 def _gated_locks() -> frozenset[str]: if not (requested := os.environ.get("FILELOCK_VERIFY_LOCKS")): return frozenset(_ALL_LOCKS) return frozenset(requested.split(",")) def _run_one(name: str, mounts: list[Path]) -> tuple[list[int], int, str | None]: # Same basename on every mount, so the mounts contend on one server file through their independent caches. lock_paths = [str(mounts[index % len(mounts)] / f"{name}.lock") for index in range(_PROCESSES)] with ProcessPoolExecutor(max_workers=_PROCESSES) as pool: results = list(pool.map(_hammer, [name] * _PROCESSES, lock_paths)) counts = [len(intervals) for intervals, _ in results] reason = next((reason for _, reason in results if reason is not None), None) return counts, _count_overlaps([intervals for intervals, _ in results]), reason def _count_overlaps(per_process: list[list[tuple[float, float]]]) -> int: # Sweep the intervals in start order: a hold that begins before the latest end seen so far, and belongs to another # process, means two holders were inside the lock at once. A correct lock hands off strictly, so nothing overlaps. intervals = sorted((enter, leave, owner) for owner, held in enumerate(per_process) for enter, leave in held) overlaps = 0 latest_leave = float("-inf") latest_owner = -1 for enter, leave, owner in intervals: if enter < latest_leave and owner != latest_owner: overlaps += 1 if leave > latest_leave: latest_leave, latest_owner = leave, owner return overlaps def _hammer(name: str, lock_path: str) -> tuple[list[tuple[float, float]], str | None]: lock = _build(name, lock_path) intervals: list[tuple[float, float]] = [] for _ in range(_HOLDS): if (reason := _resiliently(lambda: lock.acquire(timeout=_ACQUIRE_TIMEOUT))) is not None: if isinstance(lock, StrictSoftFileLock): try: reason = f"{reason}; claims={[(claim.state, claim.pid, claim.name) for claim in lock.claims]}" except SoftFileLockProtocolError as error: reason = f"{reason}; claims unavailable: {error}" return intervals, reason enter = time.monotonic() # Hold briefly so a broken lock lets a second holder in during an observable window; a correct lock serializes # the holds regardless. enter is stamped after acquire and leave before release, so a correct hand-off can never # look like an overlap even though release and the next acquire race. time.sleep(_HOLD_SECONDS) leave = time.monotonic() if (reason := _resiliently(lock.release)) is not None: intervals.append((enter, leave)) return intervals, reason intervals.append((enter, leave)) # A randomized gap outside the lock breaks the lock-step herd, so a poll-based lock hands off fairly and no # contender is starved out of finishing its holds. time.sleep(random.uniform(0, _GAP_MAX_SECONDS)) # ruff:ignore[suspicious-non-cryptographic-random-usage] - test dispersion, not cryptographic return intervals, None def _resiliently(action: Callable[[], object]) -> str | None: # Retry the genuinely transient NFS errors (a stale handle, a create/unlink permission race under contention) that # a robust client retries, so one hiccup never aborts a hold. A Timeout from starvation or a rejection the # filesystem always makes (EINVAL for the strict claim on CIFS) is not transient and ends this process's run. for attempt in range(_TRANSIENT_RETRIES): if (error := _attempt(action)) is None: return None if not _has_transient(error) or attempt == _TRANSIENT_RETRIES - 1: return _describe(error) time.sleep(_RETRY_BACKOFF * (attempt + 1)) return "transient errors exhausted retries" def _attempt(action: Callable[[], object]) -> BaseException | None: try: action() except Exception as error: # ruff:ignore[blind-except] - the caller classifies transient versus terminal; a harness must not die return error return None def _has_transient(error: BaseException) -> bool: if isinstance(error, BaseExceptionGroup): return any(_has_transient(leaf) for leaf in error.exceptions) return isinstance(error, OSError) and not isinstance(error, TimeoutError) and error.errno in _TRANSIENT_ERRNOS def _describe(error: BaseException) -> str: return f"{type(error).__name__}: {error}"[:160] def _build(name: str, lock_path: str) -> FileLock | SoftFileLock | StrictSoftFileLock: if name == "FileLock": return FileLock(lock_path) if name == "SoftFileLock": return SoftFileLock(lock_path) return StrictSoftFileLock(lock_path) if __name__ == "__main__": raise SystemExit(main()) filelock-4.2.8/tasks/capability/assert_fallback.py0000644000000000000000000000162215272561277017222 0ustar00"""Confirm filelock took its documented fallback when ``FILELOCK_BLOCK_MODULE`` hid a module, or exit non-zero. The capability jobs set the module and put ``tasks/capability`` on ``PYTHONPATH`` so the sibling ``sitecustomize`` hides it; this checks the import-time consequence before the verifier exercises the resulting lock. """ from __future__ import annotations import os import filelock def main() -> None: blocked = os.environ["FILELOCK_BLOCK_MODULE"] if blocked == "fcntl" and filelock.has_fcntl: msg = f"without fcntl, expected has_fcntl False, got {filelock.has_fcntl!r}" raise SystemExit(msg) if blocked == "sqlite3" and filelock.ReadWriteLock is not None: msg = f"without sqlite3, expected ReadWriteLock None, got {filelock.ReadWriteLock!r}" raise SystemExit(msg) print(f"fallback engaged without {blocked}") if __name__ == "__main__": main() filelock-4.2.8/tasks/capability/sitecustomize.py0000644000000000000000000000146315272561277017014 0ustar00"""Hide the module named in ``FILELOCK_BLOCK_MODULE`` so a capability job can run filelock without it. Put this directory on ``PYTHONPATH`` and set ``FILELOCK_BLOCK_MODULE=fcntl`` (or ``sqlite3``); every process, including the workers a verifier spawns, then imports it and refuses that one module, so filelock takes its documented fallback. A ``None`` entry in ``sys.modules`` is CPython's marker for "known unimportable", so it forces ``ImportError`` for a module already cached at interpreter start (``fcntl`` is a builtin) as well as for a later first import (``sqlite3``). """ from __future__ import annotations import os import sys if _blocked := os.environ.get("FILELOCK_BLOCK_MODULE"): sys.modules[_blocked] = None # ty: ignore[invalid-assignment] # a None entry is the documented unimportable marker filelock-4.2.8/tasks/termux/Dockerfile0000644000000000000000000000142115272561277014722 0ustar00# Cached base image for the Termux (Android/bionic) test run: only the expensive, source-independent setup lives here, # so iterating on filelock re-runs just the copy, the local install and the tests (see compose.yaml), never this # bootstrap. termux-docker's apt refuses to run as root and only works after its bootstrap, which lives in the image # entrypoint, so the build step is dispatched through /entrypoint.sh. compose.yaml copies the source into $HOME; that # copy preserves the mount's host ownership, so git is told to trust the tree for hatch-vcs' version lookup. FROM termux/termux-docker:aarch64 RUN ["/entrypoint.sh", "bash", "-lc", "apt-get update -qq && apt-get install -yq python python-pip git && pip install -q tox && git config --system --add safe.directory '*'"] filelock-4.2.8/tests/async_filelock_cancellation_helpers.py0000644000000000000000000000431015272561277021216 0ustar00from __future__ import annotations import importlib import multiprocessing import threading from typing import TYPE_CHECKING, cast from filelock import FileLock, Timeout if TYPE_CHECKING: import asyncio from multiprocessing.sharedctypes import Synchronized from typing import Protocol class FcntlModule(Protocol): LOCK_UN: int @staticmethod def flock(fd: int, operation: int) -> None: ... def assert_cancellation_message(error: asyncio.CancelledError, message: str) -> None: assert error.args == (message,) def get_fcntl() -> FcntlModule: # pragma: win32 no cover return cast("FcntlModule", importlib.import_module("fcntl")) def assert_file_lock_state(lock_file: str, *, available: bool) -> None: context = multiprocessing.get_context("spawn") acquired = context.Value("b", False) probe = context.Process(target=_probe_file_lock, args=(lock_file, acquired)) probe.start() try: probe.join(timeout=5) assert not probe.is_alive(), "lock probe did not exit" assert (probe.exitcode, acquired.value) == (0, available) finally: if probe.is_alive(): # pragma: no cover - cleanup for a hung child after the assertion fails probe.terminate() probe.join(timeout=5) probe.close() def start_file_lock_holder(lock_file: str) -> tuple[threading.Thread, threading.Event, threading.Event]: holder_started = threading.Event() finish_holder = threading.Event() holder = threading.Thread(target=_hold_file_lock, args=(lock_file, holder_started, finish_holder)) holder.start() return holder, holder_started, finish_holder def _probe_file_lock(lock_file: str, acquired: Synchronized[bool]) -> None: try: with FileLock(lock_file, timeout=0): acquired.value = True except Timeout: # pragma: win32 no cover pass # pragma: win32 no cover def _hold_file_lock(lock_file: str, holder_started: threading.Event, finish_holder: threading.Event) -> None: with FileLock(lock_file): holder_started.set() assert finish_holder.wait(timeout=5) __all__ = ["assert_cancellation_message", "assert_file_lock_state", "get_fcntl", "start_file_lock_holder"] filelock-4.2.8/tests/capability_marks.py0000644000000000000000000001403215272561277015313 0ustar00"""Marks for runtime capabilities several test modules gate on. Capabilities used by a single module stay private to it, as ``_NEEDS_LINK_DIR_FD`` and friends do. These span modules, so they live here and read the same ``CAPABILITIES`` probes the coverage pragmas use. Gate on a probe rather than on an interpreter or platform name: a name answers who is running, and every one of these questions is about what the runtime can do. """ from __future__ import annotations import sys from typing import TYPE_CHECKING import pytest from capabilities import CAPABILITIES from filelock._util import exchange_supported if TYPE_CHECKING: from typing import Final #: The one gate here that asks who is running rather than what it can do. GraalPy's multiprocessing semaphores raise #: ``OSError: [Errno 0] Success`` from ``_wait_semaphore.acquire(False)`` when ``Event.set()`` notifies a contended #: condition, which fails whichever test happens to be coordinating processes at the time. The defect is in the #: interpreter's own ``multiprocessing/synchronize.py``, it only appears on Linux under load, and an uncontended probe #: cannot see it, so there is nothing to measure and no fix to make from here. SKIP_ON_UNRELIABLE_PROCESS_SYNC: Final[pytest.MarkDecorator] = pytest.mark.skipif( sys.implementation.name == "graalpy", reason="this runtime raises OSError from multiprocessing Event.set() under contention", ) NEEDS_FORK: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["fork"], reason="installing fork handlers needs os.register_at_fork" ) NEEDS_REGISTER_AT_FORK: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["register-at-fork"], reason="the fork transition gate is installed through os.register_at_fork" ) NEEDS_FORK1: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["fork1"], reason="forking a single thread needs the Solaris os.fork1" ) NEEDS_FCNTL: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["fcntl"], reason="native flock semantics need the fcntl module" ) NEEDS_SYMLINK: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["symlink"], reason="creating a symlink needs a privilege this runtime does not grant" ) #: Windows resolves a lock's parent with abspath, so a symlinked parent stays a distinct key and never collapses. NEEDS_PARENT_SYMLINK_COLLAPSE: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["symlink"] or sys.platform == "win32", reason="a symlinked parent collapses into one key only where the parent is resolved with realpath", ) NEEDS_FILE_MODE: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["file-mode"], reason="requires a filesystem that preserves POSIX mode bits", ) NEEDS_FOREIGN_STICKY_TMP: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["foreign-sticky-tmp"], reason="a protocol directory of another account is refused only under a sticky parent this account does not own", ) NEEDS_FILE_PERMISSIONS: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["file-permissions"], reason="requires enforced file permissions", ) NEEDS_UNLINK_OPEN_FILE: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["unlink-open-file"], reason="this runtime keeps an open marker undeletable, so no peer can take it from a live holder", ) NEEDS_STALE_RECLAIM: Final[pytest.MarkDecorator] = pytest.mark.skipif( sys.platform != "win32" and not exchange_supported(), reason="safe stale recovery needs an atomic exchange of two names", ) NEEDS_POSIX_SIGNALS: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["posix-signals"], reason="liveness is probed with os.kill only where POSIX signals exist" ) NEEDS_PROMPT_FINALIZATION: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["prompt-finalization"], reason="a dropped reference does not run __del__ on a deferred collector", ) NEEDS_PROMPT_SIGNAL_DELIVERY: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["prompt-signal-delivery"], reason="this runtime runs a signal handler after raise_signal returns, so the signal escapes the call", ) NEEDS_COLLECTED_FINALIZATION: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["collected-finalization"], reason="gc.collect() does not run __del__ on this runtime", ) NEEDS_CLASS_COLLECTION: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["class-collection"], reason="gc.collect() does not reclaim classes instantiated through a custom metaclass", ) NEEDS_GENERATOR_EXCEPTION_CONTEXT: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["generator-exception-context"], reason="this runtime clears __context__ when an exception is thrown into a suspended frame", ) NEEDS_AUDIT_EVENTS: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["audit-events"], reason="this runtime never delivers audit events to an installed hook", ) #: Raised from ``_GeneratorContextManagerBase.__aexit__``, so the lock's cancellation contract cannot be observed at #: all there. Not strict: the deviation depends on where the cancellation lands, so some of these tests still pass. XFAIL_WITHOUT_COROUTINE_CANCELLATION: Final[pytest.MarkDecorator] = pytest.mark.xfail( not CAPABILITIES["coroutine-cancellation"], reason="GraalPy's contextlib answers athrow() with RuntimeError instead of propagating the CancelledError", strict=False, ) __all__ = [ "NEEDS_AUDIT_EVENTS", "NEEDS_CLASS_COLLECTION", "NEEDS_COLLECTED_FINALIZATION", "NEEDS_FCNTL", "NEEDS_FILE_MODE", "NEEDS_FILE_PERMISSIONS", "NEEDS_FORK", "NEEDS_FORK1", "NEEDS_GENERATOR_EXCEPTION_CONTEXT", "NEEDS_PARENT_SYMLINK_COLLAPSE", "NEEDS_POSIX_SIGNALS", "NEEDS_PROMPT_FINALIZATION", "NEEDS_PROMPT_SIGNAL_DELIVERY", "NEEDS_REGISTER_AT_FORK", "NEEDS_STALE_RECLAIM", "NEEDS_SYMLINK", "NEEDS_UNLINK_OPEN_FILE", "SKIP_ON_UNRELIABLE_PROCESS_SYNC", "XFAIL_WITHOUT_COROUTINE_CANCELLATION", ] filelock-4.2.8/tests/conftest.py0000644000000000000000000003400315272561277013622 0ustar00from __future__ import annotations import gc import itertools import os import secrets import shutil import subprocess # ruff:ignore[suspicious-subprocess-import] # only the fixed-argument, no-shell run below import sys from contextlib import contextmanager, suppress from pathlib import Path from typing import TYPE_CHECKING, Final, TypeVar import pytest from capabilities import CAPABILITIES import filelock._identity from filelock import BaseFileLock, MarkerSoftFileLock, SoftFileLease, SoftFileLock, Timeout from filelock._identity import host_name from tests.descriptor_helpers import descriptors_on if TYPE_CHECKING: from collections.abc import Callable, Generator, Iterator from pytest_mock import MockerFixture # Coverage restarts in every patched subprocess and re-imports the plugin there; pytest's pythonpath reaches only # this process, so export it. os.environ["PYTHONPATH"] = os.pathsep.join( part for part in (str(Path(__file__).parent.parent / "tasks"), os.environ.get("PYTHONPATH")) if part ) _LockT = TypeVar("_LockT", bound=BaseFileLock) def acquire_after_reclaim(lock: BaseFileLock) -> None: """ Take a path whose stale marker the lock has to break first, without a wall-clock budget. A try finds the marker and breaks it, or first watches an unreadable one age; the last creates its own. A deadline would test how fast the machine is: a first try that outlasts it times out after the marker is gone and before the lock is held. """ for _ in range(3): with suppress(Timeout): lock.acquire(blocking=False) if lock.is_locked: return @contextmanager def held_after_reclaim(lock: _LockT) -> Generator[_LockT]: """Hold *lock* through :func:`acquire_after_reclaim` and release it on exit.""" acquire_after_reclaim(lock) try: yield lock finally: lock.release() def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: # StrictSoftFileLock publishes claims with hard links, so tests marked requires_hard_links cannot run where # os.link is missing (Termux/Android CPython ships without it). The no-hard-link degradation itself is covered by # the os.link tests in test_filelock.py, which carry no marker and run everywhere. if not hasattr(os, "link"): # pragma: no cover # the body runs only on Termux/Android, absent from the CI matrix skip = pytest.mark.skip(reason="StrictSoftFileLock requires os.link (hard links), absent on Termux/Android") for item in items: if item.get_closest_marker("requires_hard_links") is not None: item.add_marker(skip) @pytest.fixture(autouse=True, scope="session") def _tracemalloc_lookup_without_support() -> Iterator[None]: # pytest's thread and unraisable exception hooks ask tracemalloc where the offending object was allocated. Where # that lookup raises (GraalPy), the hook reports "Failed to process thread exception" in place of the thread's real # error, so hand it the answer tracemalloc gives while it is not tracing. with pytest.MonkeyPatch.context() as patch: if not CAPABILITIES["tracemalloc-object-traceback"]: # pragma: lacks tracemalloc-object-traceback patch.setattr("tracemalloc.get_object_traceback", lambda _source: None) yield _HOST_COLLECTION_INTERVAL: Final[int] = 20 _HOST_COLLECTION_DUE: Final = itertools.cycle((False,) * (_HOST_COLLECTION_INTERVAL - 1) + (True,)) @pytest.fixture(autouse=True) def _collect_garbage() -> Iterator[None]: # Multiprocessing objects register finalizers that close pipes, unlink semaphores and write to the resource # tracker; when cyclic garbage from one test is collected during a later one, those run against whatever that # later test has patched onto os.close or os.write. Collect after every test so finalizers fire in the test that # created the objects, before any such patch is in place. This runs after mocker's undo, since autouse fixtures # set up first and tear down last. Where a collection runs no finalizers (GraalPy queues them on the host # collector), it cannot do that, and a full host collection after every test was nearly all of the suite's runtime. # It still needs one now and then: with none at all, GraalPy CI runs stalled past the timeout inside trivial tests. yield if CAPABILITIES["collected-finalization"] or next(_HOST_COLLECTION_DUE): # pragma: needs collected-finalization gc.collect() @pytest.fixture def close_failure( mocker: MockerFixture, ) -> Iterator[tuple[Callable[[int], None], OSError, RuntimeError]]: locked_fd: int | None = None release_error = OSError("release failed") release_cause = RuntimeError("release cause") release_error.__cause__ = release_cause release_error.__suppress_context__ = True real_close = os.close def capture(fd: int) -> None: nonlocal locked_fd locked_fd = fd mocker.patch("filelock._api.os.close", side_effect=close) def close(fd: int) -> None: # filelock._api.os is the os module, so this patches os.close process-wide. Raise only for the descriptor # under test. An unrelated close inside a GC finalizer would escape as an unraisable exception. if fd == locked_fd: raise release_error real_close(fd) # pragma: no cover # only an unrelated close (e.g. a GC finalizer) reaches here yield capture, release_error, release_cause if locked_fd is not None: # pragma: no branch # every consumer calls capture, so this is always set real_close(locked_fd) @pytest.fixture(params=[pytest.param("native", id="native"), pytest.param("no-exchange", id="no-exchange")]) def break_route(request: pytest.FixtureRequest, mocker: MockerFixture) -> None: """Run the test with the platform's own stale break, then as where no atomic exchange of two names exists.""" if request.param == "no-exchange": mocker.patch("filelock._util._rename_call", return_value=None) @pytest.fixture def on_break_recheck(mocker: MockerFixture) -> Callable[[Path, Callable[[], None]], None]: """Schedule an action for right after the stale break re-reads the lock file and before it takes the marker.""" def schedule(lock: Path, action: Callable[[], None]) -> None: alive: Final = filelock._identity.process_alive lstat: Final = os.lstat state: list[str] = [] def judged(pid: int) -> bool: # The owner check comes right before the break, whose first look at the path is the recheck. state.append("judged") return alive(pid) def lstat_then_act(path: str | os.PathLike[str], *, dir_fd: int | None = None) -> os.stat_result: found: Final = lstat(path, dir_fd=dir_fd) if state == ["judged"] and os.fspath(path) == os.fspath(lock): state.append("acted") action() return found mocker.patch("filelock._identity.process_alive", side_effect=judged) mocker.patch.object(os, "lstat", new=lstat_then_act) return schedule @pytest.fixture def reuse_inode(mocker: MockerFixture) -> Callable[[Path, os.stat_result], None]: # pragma: win32 no cover """ Stand in for ext4, which hands a freed inode number to the next file created. A tmpfs test directory never reuses a number. The stand-in makes ``os.lstat`` report a successor under the number of the unlinked file it is given, once no descriptor in this process keeps that inode allocated. """ def stand_in(successor: Path, freed: os.stat_result) -> None: created: Final = successor.lstat() lstat: Final = os.lstat def lstat_with_reuse(path: str | os.PathLike[str], *, dir_fd: int | None = None) -> os.stat_result: found: Final = lstat(path, dir_fd=dir_fd) if (found.st_dev, found.st_ino) != (created.st_dev, created.st_ino) or descriptors_on(freed): return found # The tuple carries the integer fields up to st_size; the float and nanosecond times ride in the mapping. extra: Final = { name: getattr(found, name) for name in dir(found) if name.startswith("st_") and name not in {"st_mode", "st_ino", "st_dev", "st_nlink", "st_uid", "st_gid", "st_size"} } return os.stat_result((found.st_mode, freed.st_ino, *found[2:10]), extra) mocker.patch("os.lstat", side_effect=lstat_with_reuse) return stand_in #: Linux and macOS cap PIDs below 2**22 + 1, and Windows answers OpenProcess for it with an invalid-parameter error, so #: the marker names a holder every platform reads as dead. DEAD_PID: Final[int] = 2**22 + 1 DEAD_HOLDER: Final[str] = f"{DEAD_PID}\n{host_name()}\n" DEAD_OWNER: Final[str] = f"filelock/2\npid={DEAD_PID}\nhost={host_name()}\nmode=exclusive\n" #: Every soft lock that breaks a stale marker, with a record naming a dead owner in the format it reads. STALE_BREAKERS: Final[pytest.MarkDecorator] = pytest.mark.parametrize( ("lock_type", "record"), [ pytest.param(SoftFileLock, DEAD_HOLDER, id="soft"), pytest.param(MarkerSoftFileLock, DEAD_OWNER, id="marker"), pytest.param(SoftFileLease, DEAD_OWNER, id="lease"), ], ) @pytest.fixture def working_directories(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]: first = tmp_path / "first" second = tmp_path / "second" first.mkdir() second.mkdir() monkeypatch.chdir(first) return first, second @pytest.fixture def lock_path_in_shared_tmp() -> Iterator[Path]: # pragma: needs foreign-sticky-tmp # /tmp itself, where every account can create names, rather than this test's own directory. lock_path = Path("/tmp", f"filelock-{secrets.token_hex(8)}.lock") # ruff:ignore[hardcoded-temp-file] # the subject yield lock_path for derived in (Path(f"{lock_path}.filelock"), Path(f"{lock_path}.rw")): shutil.rmtree(derived, ignore_errors=True) lock_path.unlink(missing_ok=True) @pytest.fixture def directories_of_another_account(mocker: MockerFixture) -> None: # pragma: needs foreign-sticky-tmp # Directories this process creates then look, to the lock, like ones another account created first. assert sys.platform != "win32" # the capability is absent there; narrows os.geteuid for the type checker mocker.patch("filelock._util.os.geteuid", autospec=True, return_value=os.geteuid() + 1) @pytest.fixture def create_junction() -> Callable[[Path, Path], None]: # pragma: win32 cover # A junction needs no privilege, unlike a symlink, and lstat reports it as a directory. def create(link: Path, target: Path) -> None: if sys.platform == "win32": # pragma: no branch # narrows _winapi for the type checker import _winapi _winapi.CreateJunction(str(target), str(link)) return create @pytest.fixture def inheriting_directory(tmp_path: Path) -> Path: # pragma: win32 cover # A directory without inheritable entries gives a new file the token's default permissions, which icacls shows # like explicit ones; an inheritable entry for Users makes inherited entries show as (I). directory = tmp_path / "inheriting" directory.mkdir() subprocess.run( [Path(os.environ["SYSTEMROOT"], "System32", "icacls.exe"), directory, "/grant", "*S-1-5-32-545:(OI)(CI)(RX)"], capture_output=True, check=True, ) return directory @pytest.fixture def hold_oplock() -> Iterator[Callable[[Path], Callable[[], None]]]: # pragma: win32 cover # The marker skips the run off Windows; this only narrows ctypes.WinDLL for ty. assert sys.platform == "win32" import ctypes from ctypes import wintypes class Overlapped(ctypes.Structure): _fields_ = ( ("Internal", ctypes.c_void_p), ("InternalHigh", ctypes.c_void_p), ("Offset", wintypes.DWORD), ("OffsetHigh", wintypes.DWORD), ("hEvent", wintypes.HANDLE), ) kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) kernel32.CreateFileW.restype = kernel32.CreateEventW.restype = wintypes.HANDLE kernel32.CreateFileW.argtypes = [ wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE, ] kernel32.DeviceIoControl.argtypes = [ wintypes.HANDLE, wintypes.DWORD, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p, ctypes.POINTER(Overlapped), ] closers: list[Callable[[], None]] = [] def hold(path: Path) -> Callable[[], None]: generic_read, share_all, open_existing, flag_overlapped = 0x80000000, 0x1 | 0x2 | 0x4, 3, 0x40000000 handle = kernel32.CreateFileW(str(path), generic_read, share_all, None, open_existing, flag_overlapped, None) overlapped = Overlapped(hEvent=kernel32.CreateEventW(None, True, False, None)) # FSCTL_REQUEST_OPLOCK_LEVEL_1 stays pending while granted; a read handle suffices to make other opens wait. assert not kernel32.DeviceIoControl(handle, 0x00090000, None, 0, None, 0, None, ctypes.byref(overlapped)) assert ctypes.get_last_error() == 997 # ERROR_IO_PENDING closed = False def close() -> None: nonlocal closed if closed: return closed = True kernel32.CloseHandle(handle) # The request completes on close and writes overlapped, so wait before freeing it. kernel32.WaitForSingleObject(overlapped.hEvent, 5000) kernel32.CloseHandle(overlapped.hEvent) closers.append(close) return close yield hold for close in closers: close() @pytest.fixture def oplocked_lock_path( request: pytest.FixtureRequest, tmp_path: Path, hold_oplock: Callable[[Path], Callable[[], None]] ) -> Path: # pragma: win32 cover lock_path = tmp_path / "a" lock_path.write_text(getattr(request, "param", ""), encoding="utf-8") hold_oplock(lock_path) return lock_path filelock-4.2.8/tests/descriptor_helpers.py0000644000000000000000000000207215272561277015676 0ustar00"""Counts the descriptors this process holds on an inode.""" from __future__ import annotations import os import sys from contextlib import suppress from pathlib import Path from capabilities import CAPABILITIES def descriptors_on(node: os.stat_result) -> int: # pragma: needs fd-directory if CAPABILITIES["complete-fd-directory"]: view = Path("/dev/fd") if Path("/dev/fd").is_dir() else Path("/proc/self/fd") descriptor_ids = [int(entry.name) for entry in view.iterdir()] else: # FreeBSD's devfs and NetBSD list only the standard streams under /dev/fd until fdescfs is mounted, so every # number the process may hold is probed instead. assert sys.platform != "win32" # narrows os.sysconf for ty descriptor_ids = range(os.sysconf("SC_OPEN_MAX")) count = 0 for descriptor_id in descriptor_ids: with suppress(OSError): # the descriptor that listed the directory is already closed count += os.path.samestat(os.fstat(descriptor_id), node) return count __all__ = [ "descriptors_on", ] filelock-4.2.8/tests/fork_helpers.py0000644000000000000000000000470615272561277014467 0ustar00from __future__ import annotations import os from typing import TYPE_CHECKING, Final, NoReturn, cast from capabilities import CAPABILITIES if TYPE_CHECKING: from collections.abc import Callable from typing import Protocol class _RegisterAtFork(Protocol): def __call__(self, *, after_in_child: Callable[[], None] | None = None) -> None: ... _FORK: Final[Callable[[], int] | None] = cast( "Callable[[], int] | None", getattr(os, "fork", None), # fork is absent from Windows at runtime and in its type interface ) _REGISTER_AT_FORK: Final[_RegisterAtFork | None] = cast( "_RegisterAtFork | None", getattr( # register_at_fork is absent from Windows at runtime and in its type interface os, "register_at_fork", None, ), ) def fork_process(child: Callable[[], NoReturn] | None = None) -> int: # pragma: win32 no cover if _FORK is None: # pragma: no cover - platform without os.fork msg = "os.fork is unavailable" raise RuntimeError(msg) child_pid = _FORK() if child_pid == 0 and child is not None: # pragma: win32 no cover child() # pragma: no cover - child coverage starts after fork returns to this frame return child_pid def exit_child(status: int) -> NoReturn: try: _flush_coverage() finally: # A coverage write failure must not return the fork child to pytest. os._exit(status) def _flush_coverage() -> None: # pragma: win32 no cover - a spawned child inherits no coverage to flush # An unmeasured run has nothing to write, and importing coverage there would stop the suite from running at all. if not CAPABILITIES["coverage"]: # pragma: lacks coverage return from coverage import Coverage # Reaching the other side needs coverage inactive, which is when nothing gets recorded anyway. if (coverage := Coverage.current()) is not None: # pragma: no branch coverage.save() def _restart_coverage_after_fork() -> None: # pragma: win32 no cover from coverage import Coverage, process_startup if (coverage := Coverage.current()) is not None: # pragma: win32 no cover coverage.stop() process_startup(force=True, slug="fork") # pragma: no cover - coverage is stopped until this call returns if _REGISTER_AT_FORK is not None and CAPABILITIES["coverage"]: # pragma: win32 no cover _REGISTER_AT_FORK(after_in_child=_restart_coverage_after_fork) __all__ = ["exit_child", "fork_process"] filelock-4.2.8/tests/pid_namespace_helpers.py0000644000000000000000000000443515272561277016315 0ustar00from __future__ import annotations import os from pathlib import Path from typing import TYPE_CHECKING, Final if TYPE_CHECKING: from pytest_mock import MockerFixture #: The inode the kernel ABI fixes for the initial PID namespace. INITIAL_PID_NAMESPACE: Final[int] = 0xEFFFFFFC #: The inode the kernel ABI fixes for the initial time namespace. INITIAL_TIME_NAMESPACE: Final[int] = 0xEFFFFFFA def pin_pid_namespace(mocker: MockerFixture, namespace: int | OSError) -> None: """Answer a stat of ``/proc/self/ns/pid`` with *namespace*, as its inode or as the error the stat raises.""" _pin_namespace(mocker, Path("/proc/self/ns/pid"), namespace) def pin_time_namespace( # pragma: linux cover mocker: MockerFixture, namespace: int | OSError, offsets: str | OSError, *, for_children: int | None = None ) -> None: """ Answer for this process's time namespace. :param namespace: the inode a stat of ``/proc/self/ns/time`` reports, or the error it raises :param offsets: what reading ``/proc/self/timens_offsets`` returns, or the error it raises :param for_children: the inode of ``/proc/self/ns/time_for_children``, when an unshare made it differ """ _pin_namespace(mocker, Path("/proc/self/ns/time"), namespace) _pin_namespace(mocker, Path("/proc/self/ns/time_for_children"), namespace if for_children is None else for_children) answer: Final = ( mocker.Mock(side_effect=offsets) if isinstance(offsets, OSError) else mocker.Mock(return_value=offsets) ) read_text: Final = Path.read_text mocker.patch.object( Path, "read_text", new=lambda path, *args, **kwargs: ( answer() if path == Path("/proc/self/timens_offsets") else read_text(path, *args, **kwargs) ), ) def _pin_namespace(mocker: MockerFixture, link: Path, namespace: int | OSError) -> None: answer: Final = ( mocker.Mock(side_effect=namespace) if isinstance(namespace, OSError) else mocker.Mock(return_value=os.stat_result((0, namespace, 0, 0, 0, 0, 0, 0, 0, 0))) ) # A plain function, not an autospec: a test that moves to another namespace pins again over this patch. stat: Final = Path.stat mocker.patch.object(Path, "stat", new=lambda path, **kwargs: answer() if path == link else stat(path, **kwargs)) filelock-4.2.8/tests/process_helpers.py0000644000000000000000000000520215272561277015174 0ustar00from __future__ import annotations import threading import time from contextlib import contextmanager from typing import TYPE_CHECKING, Final from unittest.mock import patch import filelock._soft from filelock import SoftFileLock if TYPE_CHECKING: from collections.abc import Callable, Generator from multiprocessing.process import BaseProcess from multiprocessing.synchronize import Event #: Stays under the suite's per-test timeout so a wedged worker still surfaces as the hang it is. _REAP_DEADLINE: Final[int] = 5 @contextmanager def cleanup_processes(processes: list[BaseProcess]) -> Generator[None]: """Reap every process on the way out, whatever the block did.""" try: yield finally: for process in processes: # Terminating an unstarted process raises over the assertion that escaped before it started. if process.pid is not None: process.terminate() process.join(timeout=_REAP_DEADLINE) if process.is_alive(): # pragma: no cover # SIGTERM only loses this race on a saturated runner process.kill() process.join(timeout=_REAP_DEADLINE) # Left open, the finalizer runs during whichever test the collector lands in, as an unraisable exception. process.close() def break_then_hang_after_exchange( lock_file: str, ready: Event, go: Event, exchanged: Event ) -> None: # pragma: win32 no cover """Break a stale marker, pausing right before the exchange and hanging right after it, until the test kills it.""" exchange: Final = filelock._soft.exchange_names def exchange_then_hang(source: str, target: str) -> bool: ready.set() go.wait() exchange(source, target) exchanged.set() while True: time.sleep(_REAP_DEADLINE) with patch.object(filelock._soft, "exchange_names", new=exchange_then_hang): SoftFileLock(lock_file).acquire(blocking=False) def call_within(call: Callable[[], object], seconds: float) -> tuple[bool, BaseException | None]: # pragma: win32 cover """Run *call* on a daemon thread and report whether it returned within *seconds* and what it raised.""" raised: list[BaseException] = [] def run() -> None: try: call() except Exception as error: # handed to the caller, which asserts on it raised.append(error) thread = threading.Thread(target=run, daemon=True) thread.start() thread.join(seconds) return not thread.is_alive(), next(iter(raised), None) __all__ = ["break_then_hang_after_exchange", "call_within", "cleanup_processes"] filelock-4.2.8/tests/read_write_helpers.py0000644000000000000000000001017715272561277015652 0ustar00from __future__ import annotations import multiprocessing from typing import TYPE_CHECKING, Final, Literal, TypeVar import pytest from filelock import ReadWriteLock, Timeout if TYPE_CHECKING: import sys from collections.abc import Callable if sys.platform == "win32": from multiprocessing.connection import PipeConnection as Connection else: from multiprocessing.connection import Connection ACQUIRE_SETTINGS: Final[pytest.MarkDecorator] = pytest.mark.parametrize( ("instance_timeout", "instance_blocking", "call_timeout", "call_blocking", "minimum", "omitted"), [ pytest.param(0.2, True, None, None, 0.1, True, id="instance-timeout"), pytest.param(30, False, None, None, 0, True, id="instance-nonblocking"), pytest.param(0.2, True, None, None, 0.1, False, id="none-timeout"), pytest.param(30, False, None, None, 0, False, id="none-nonblocking"), pytest.param(30, True, 0.2, None, 0.1, False, id="call-timeout"), pytest.param(0.2, False, None, True, 0.1, False, id="call-blocking"), pytest.param(30, True, None, False, 0, False, id="call-nonblocking"), pytest.param(30, True, 0, None, 0, False, id="zero-timeout"), ], ) INVALID_TIMEOUTS: Final[pytest.MarkDecorator] = pytest.mark.parametrize( ("timeout", "message"), [ pytest.param(-2, "timeout must be a non-negative number or -1, not -2.0", id="integer"), pytest.param(-0.5, "timeout must be a non-negative number or -1, not -0.5", id="fraction"), pytest.param(float("-inf"), "timeout must be a non-negative number or -1, not -inf", id="negative-infinite"), pytest.param(float("nan"), "timeout must be a number of seconds, not nan", id="nan"), ], ) def assert_read_write_lock_state(lock_file: str, mode: Literal["read", "write"], *, available: bool) -> None: context: Final = multiprocessing.get_context("spawn") # Pipes avoid GraalPy's multiprocessing semaphore failures. receiving: Final[Connection] sending: Final[Connection] receiving, sending = context.Pipe(duplex=False) probe: Final = context.Process(target=_probe_read_write_lock, args=(lock_file, mode, sending)) probe.start() sending.close() try: # Spawn imports and coverage shutdown need separate budgets from the lock operation. assert receiving.poll(timeout=10), "read-write lock probe did not start" receiving.recv_bytes() assert receiving.poll(timeout=5), "read-write lock probe did not finish" acquired: Final = receiving.recv_bytes() probe.join(timeout=10) assert not probe.is_alive(), "read-write lock probe did not exit" assert (probe.exitcode, acquired) == (0, bytes([available])) finally: if probe.is_alive(): # pragma: no cover - cleanup for a hung child after the assertion fails probe.terminate() probe.join(timeout=5) probe.close() receiving.close() def _probe_read_write_lock(lock_file: str, mode: Literal["read", "write"], sending: Connection) -> None: with sending: lock: Final = ReadWriteLock(lock_file, is_singleton=False) sending.send_bytes(b"") acquired = False try: try: (lock.acquire_read if mode == "read" else lock.acquire_write)(blocking=False) except Timeout: return acquired = True lock.release() finally: lock.close() sending.send_bytes(bytes([acquired])) _T = TypeVar("_T") def interrupt_once(real: Callable[[_T], None], *, before: bool) -> Callable[[_T], None]: pending = True def call(instance: _T) -> None: nonlocal pending if pending and before: pending = False raise Interrupt real(instance) if pending: pending = False raise Interrupt return call class Interrupt(BaseException): """Stands in for an exception a signal handler raises, without pytest treating it as a KeyboardInterrupt.""" __all__ = ["ACQUIRE_SETTINGS", "INVALID_TIMEOUTS", "Interrupt", "assert_read_write_lock_state", "interrupt_once"] filelock-4.2.8/tests/test_async_filelock.py0000644000000000000000000016774715272561277016047 0ustar00from __future__ import annotations import asyncio import gc import logging import os import signal import subprocess # ruff:ignore[suspicious-subprocess-import] # interpreter exit finalizes a held lock import sys import threading import time import traceback from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from errno import EINTR, EIO, ENOSYS from functools import partial from itertools import count from pathlib import Path, PurePath from typing import TYPE_CHECKING, Final, Literal, TypedDict, cast import pytest import pytest_asyncio import filelock.asyncio from filelock import ( AsyncFileLock, AsyncSoftFileLease, AsyncSoftFileLock, BaseAsyncFileLock, BaseFileLock, CloseErrorPolicy, ContextErrorPolicy, FileLock, Timeout, ) from tests.capability_marks import ( NEEDS_FCNTL, NEEDS_PARENT_SYMLINK_COLLAPSE, NEEDS_PROMPT_FINALIZATION, NEEDS_PROMPT_SIGNAL_DELIVERY, NEEDS_STALE_RECLAIM, ) if TYPE_CHECKING: from collections.abc import Callable, Generator, Iterator from types import FrameType from _typeshed import Unused from pytest_mock import MockerFixture, MockType # Windows resolves a lock's parent with abspath, so a symlinked parent stays a distinct key and never collapses. @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.parametrize("via_acquire", [pytest.param(False, id="context"), pytest.param(True, id="acquire")]) @pytest.mark.asyncio async def test_simple( lock_type: type[BaseAsyncFileLock], path_type: type[str | Path], filename: str, tmp_path: Path, caplog: pytest.LogCaptureFixture, *, via_acquire: bool, ) -> None: caplog.set_level(logging.DEBUG) lock_path = tmp_path / filename lock = lock_type(path_type(lock_path)) async with await lock.acquire() if via_acquire else lock as locked: assert lock.is_locked assert lock is locked assert not lock.is_locked # A runtime with a lazy collector may finalize, mid-test, a lock an earlier test dropped. assert [(r.name, r.levelno, r.getMessage()) for r in caplog.records if str(lock_path) in r.getMessage()] == [ ("filelock", logging.DEBUG, f"Attempting to acquire lock {id(lock)} on {lock_path}"), ("filelock", logging.DEBUG, f"Lock {id(lock)} acquired on {lock_path}"), ("filelock", logging.DEBUG, f"Attempting to release lock {id(lock)} on {lock_path}"), ("filelock", logging.DEBUG, f"Lock {id(lock)} released on {lock_path}"), ] assert logging.getLogger("filelock").level == logging.NOTSET @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_non_blocking(lock_type: type[BaseAsyncFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_1, lock_2 = lock_type(str(lock_path)), lock_type(str(lock_path)) lock_3 = lock_type(str(lock_path), blocking=False) lock_4 = lock_type(str(lock_path), timeout=0) lock_5 = lock_type(str(lock_path), blocking=False, timeout=-1) await lock_1.acquire() assert lock_1.is_locked assert not lock_2.is_locked assert not lock_3.is_locked assert not lock_4.is_locked assert not lock_5.is_locked 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 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 with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): async with lock_3: pass # pragma: no cover # __aenter__ raises Timeout, so the body never runs assert not lock_3.is_locked assert lock_1.is_locked 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 with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): async with lock_4: pass # pragma: no cover # __aenter__ raises Timeout, so the body never runs assert not lock_4.is_locked assert lock_1.is_locked # blocking precedence over timeout 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 with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): async with lock_5: pass # pragma: no cover # __aenter__ raises Timeout, so the body never runs assert not lock_5.is_locked assert lock_1.is_locked await lock_1.release() assert not lock_1.is_locked assert not lock_2.is_locked assert not lock_3.is_locked assert not lock_4.is_locked assert not lock_5.is_locked async def _set_poll_interval( lock_type: type[BaseAsyncFileLock], entry_point: Literal["constructor", "setter", "acquire"], lock: BaseAsyncFileLock, value: object, ) -> None: value = cast("float", value) if entry_point == "constructor": lock_type(lock.lock_file, poll_interval=value) elif entry_point == "setter": lock.poll_interval = value else: await lock.acquire(poll_interval=value, timeout=0) @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.parametrize("entry_point", ["constructor", "setter", "acquire"]) @pytest.mark.parametrize( ("bad_value", "error_type", "message"), [ pytest.param(-1, ValueError, "finite and non-negative", id="negative-int"), pytest.param(-0.5, ValueError, "finite and non-negative", id="negative-float"), pytest.param(float("nan"), ValueError, "finite and non-negative", id="nan"), pytest.param(float("inf"), ValueError, "finite and non-negative", id="positive-infinity"), pytest.param(float("-inf"), ValueError, "finite and non-negative", id="negative-infinity"), pytest.param(threading.TIMEOUT_MAX * 2, ValueError, "TIMEOUT_MAX", id="above-timeout-max"), pytest.param(True, TypeError, "poll_interval must be", id="true"), pytest.param(False, TypeError, "poll_interval must be", id="false"), pytest.param("5", TypeError, "poll_interval must be", id="string"), ], ) @pytest.mark.asyncio async def test_poll_interval_rejects_invalid_value( lock_type: type[BaseAsyncFileLock], entry_point: Literal["constructor", "setter", "acquire"], bad_value: object, error_type: type[ValueError | TypeError], message: str, tmp_path: Path, ) -> None: lock = lock_type(str(tmp_path / "a")) with pytest.raises(error_type, match=message): await _set_poll_interval(lock_type, entry_point, lock, bad_value) @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.parametrize("blocking", [pytest.param("false", id="str"), pytest.param(0, id="int")]) @pytest.mark.asyncio async def test_acquire_rejects_non_bool_blocking( lock_type: type[BaseAsyncFileLock], blocking: str | int, tmp_path: Path ) -> None: lock = lock_type(str(tmp_path / "a")) with pytest.raises(TypeError, match="blocking must be a bool"): await lock.acquire(blocking=blocking) # ty: ignore[invalid-argument-type] # the rejected type is the contract assert (lock.lock_counter, lock.is_locked) == (0, False) @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.parametrize("thread_local", [True, False]) @pytest.mark.asyncio async def test_non_executor(lock_type: type[BaseAsyncFileLock], thread_local: bool, tmp_path: Path) -> None: lock = lock_type(str(tmp_path / "a"), thread_local=thread_local, run_in_executor=False) async with lock as locked: assert lock.is_locked assert lock is locked assert not lock.is_locked @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_non_executor_release_interrupted_before_the_backend_completes( lock_type: type[BaseAsyncFileLock], tmp_path: Path, mocker: MockerFixture ) -> None: lock = lock_type(str(tmp_path / "a"), run_in_executor=False) await lock.acquire() mocker.patch("filelock.asyncio._LOGGER.debug", autospec=True, side_effect=[KeyboardInterrupt, None, None]) with pytest.raises(KeyboardInterrupt): await lock.release() mocker.stopall() assert not lock.is_locked async with lock_type(str(tmp_path / "a"), blocking=False) as peer: assert peer.is_locked @NEEDS_PROMPT_SIGNAL_DELIVERY # pragma: needs prompt-signal-delivery @pytest.mark.asyncio async def test_non_executor_release_interrupted_by_a_signal_before_the_backend_completes( tmp_path: Path, mocker: MockerFixture ) -> None: lock = AsyncFileLock(str(tmp_path / "a"), run_in_executor=False) await lock.acquire() pending = [signal.SIGINT] def signal_once(*_args: object) -> None: signals = pending.copy() pending.clear() list(map(signal.raise_signal, signals)) mocker.patch("filelock.asyncio._LOGGER.debug", autospec=True, side_effect=signal_once) previous = signal.signal(signal.SIGINT, signal.default_int_handler) try: with pytest.raises(KeyboardInterrupt): await lock.release() finally: signal.signal(signal.SIGINT, previous) mocker.stopall() assert not lock.is_locked async with AsyncFileLock(str(tmp_path / "a"), blocking=False) as peer: assert peer.is_locked @pytest.mark.asyncio async def test_coroutine_function(tmp_path: Path) -> None: acquired = released = False class AioFileLock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] nonlocal acquired acquired = True self._context.lock_file_fd = 1 async def _release(self) -> None: # ty: ignore[invalid-method-override] nonlocal released released = True self._context.lock_file_fd = None lock = AioFileLock(str(tmp_path / "a")) await lock.acquire() assert acquired assert not released await lock.release() assert acquired assert released @pytest.mark.asyncio async def test_coroutine_acquire_without_holding_polls_and_skips_on_acquired(tmp_path: Path) -> None: attempts: list[str] = [] class NeverHoldsLock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] attempts.append(self.lock_file) # returns without setting the fd, so is_locked stays False async def _release(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = None # pragma: no cover # never held, so release never runs lock = NeverHoldsLock(str(tmp_path / "a"), timeout=0.1) with pytest.raises(Timeout): await lock.acquire() # The coroutine backend returned without acquiring, so the tracker skipped on_acquired and the poll loop retried. assert len(attempts) >= 2 assert not lock.is_locked @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: # ruff:ignore[unused-async] # the asyncio marker requires a coroutine though the body awaits nothing 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) await lock.acquire(timeout=0.1) # reentrant acquire 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() 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() @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_cancel_check_triggers(lock_type: type[BaseAsyncFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_1 = lock_type(str(lock_path)) lock_2 = lock_type(str(lock_path)) await lock_1.acquire() with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): await lock_2.acquire(timeout=1, cancel_check=lambda: True) assert not lock_2.is_locked await lock_1.release() @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_cancel_check_not_called_when_lock_available(lock_type: type[BaseAsyncFileLock], tmp_path: Path) -> None: lock = lock_type(str(tmp_path / "a")) called = False def should_not_be_called() -> bool: # pragma: no cover # a free lock never consults cancel_check nonlocal called called = True return True await lock.acquire(cancel_check=should_not_be_called) assert lock.is_locked assert not called await lock.release() @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_cancel_check_log_message( lock_type: type[BaseAsyncFileLock], tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: caplog.set_level(logging.DEBUG) lock_path = tmp_path / "a" lock_1 = lock_type(str(lock_path)) lock_2 = lock_type(str(lock_path)) await lock_1.acquire() with pytest.raises(Timeout): await lock_2.acquire(timeout=1, cancel_check=lambda: True) assert any("Cancellation requested" in msg for msg in caplog.messages) await lock_1.release() @pytest.mark.parametrize( "lock_type", [pytest.param(AsyncFileLock, id="async"), pytest.param(AsyncSoftFileLock, id="soft")], ) def test_sync_with_raises_not_implemented_error(lock_type: type[BaseAsyncFileLock], tmp_path: Path) -> None: # __exit__ must exist so Python can call it after __enter__ raises; without it AttributeError hides the real error with pytest.raises(NotImplementedError, match=r"async with"), lock_type(str(tmp_path / "test.lock")): pass # pragma: no cover # __enter__ raises NotImplementedError, so the body never runs @pytest.mark.parametrize( "lock_type", [pytest.param(AsyncFileLock, id="async"), pytest.param(AsyncSoftFileLock, id="soft")], ) def test_del_after_loop_close_does_not_raise(lock_type: type[BaseAsyncFileLock], tmp_path: Path) -> None: # __del__ must not call get_running_loop(); it raises RuntimeError when no loop is running def _run() -> None: lock = lock_type(str(tmp_path / "test.lock")) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: loop.run_until_complete(lock.acquire()) loop.run_until_complete(lock.release(force=True)) finally: loop.close() asyncio.set_event_loop(None) del lock gc.collect() with ThreadPoolExecutor(max_workers=1) as pool: pool.submit(_run).result(timeout=10) @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.parametrize("timeout", [pytest.param(-1, id="unlimited"), pytest.param(float("inf"), id="infinite")]) @pytest.mark.asyncio async def test_same_task_different_instances_raises( tmp_path: Path, lock_type: type[BaseAsyncFileLock], timeout: float ) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) async with lock1: lock2 = lock_type(lock_path, timeout=timeout) with pytest.raises(RuntimeError, match="Deadlock"): await lock2.acquire() @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_finite_timeout_gives_timeout_not_deadlock(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) async with lock1: lock2 = lock_type(lock_path, timeout=0.1) with pytest.raises(Timeout): await lock2.acquire() @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.parametrize("from_default", [pytest.param(True, id="default"), pytest.param(False, id="argument")]) @pytest.mark.asyncio async def test_blocking_acquire_rejects_nan_timeout( lock_type: type[BaseAsyncFileLock], from_default: bool, tmp_path: Path ) -> None: lock = lock_type(tmp_path / "a", timeout=float("nan") if from_default else -1) with pytest.raises(ValueError, match="not nan"): await lock.acquire(timeout=None if from_default else float("nan")) assert not lock.is_locked @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_non_blocking_acquire_ignores_nan_timeout(lock_type: type[BaseAsyncFileLock], tmp_path: Path) -> None: lock = lock_type(tmp_path / "a") async with await lock.acquire(timeout=float("nan"), blocking=False): assert lock.is_locked @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_poll_wait_stays_within_timeout(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: lock_path = tmp_path / "test.lock" async with lock_type(lock_path): start = time.perf_counter() with pytest.raises(Timeout): await lock_type(lock_path).acquire(timeout=0.05, poll_interval=10) assert time.perf_counter() - start < 5 @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_non_blocking_gives_timeout_not_deadlock(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) async with lock1: lock2 = lock_type(lock_path, blocking=False) with pytest.raises(Timeout): await lock2.acquire() @pytest.mark.parametrize( "mode", [pytest.param("finite", id="finite"), pytest.param("nonblocking", id="nonblocking")], ) @pytest.mark.asyncio async def test_failed_acquire_keeps_holder_registered(tmp_path: Path, mode: Literal["finite", "nonblocking"]) -> None: lock_path = tmp_path / "test.lock" async with AsyncFileLock(lock_path, run_in_executor=False): with pytest.raises(Timeout): await _acquire_for_mode(AsyncFileLock(lock_path, run_in_executor=False), mode) with pytest.raises(RuntimeError, match="Deadlock"): await AsyncFileLock(lock_path, run_in_executor=False).acquire(cancel_check=lambda: True) @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_different_paths_no_conflict(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: lock1 = lock_type(tmp_path / "a.lock") lock2 = lock_type(tmp_path / "b.lock") async with lock1, lock2: assert lock1.is_locked assert lock2.is_locked @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_same_instance_reentrant_works(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: lock = lock_type(tmp_path / "test.lock") async with lock: async with lock: assert lock.is_locked assert lock.is_locked assert not lock.is_locked @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_same_instance_reentrant_acquire_skips_the_executor( tmp_path: Path, lock_type: type[BaseAsyncFileLock], mocker: MockerFixture ) -> None: executor: Final = ThreadPoolExecutor(max_workers=1) lock: Final = lock_type(tmp_path / "test.lock", executor=executor) async with lock: submit: Final = mocker.spy(executor, "submit") async with lock: assert (lock.lock_counter, submit.call_count) == (2, 0) executor.shutdown() @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_singleton_avoids_deadlock(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path, is_singleton=True) async with lock1: lock2 = lock_type(lock_path, is_singleton=True) assert lock1 is lock2 async with lock2: assert lock2.is_locked class _AsyncOptions(TypedDict, total=False): loop: asyncio.AbstractEventLoop executor: ThreadPoolExecutor run_in_executor: bool _ASYNC_SINGLETON_TYPES: Final = [ pytest.param(AsyncFileLock, id="native"), pytest.param(AsyncSoftFileLock, id="soft"), pytest.param(AsyncSoftFileLease, id="lease"), ] @pytest.fixture def async_options() -> Iterator[_AsyncOptions]: loop = asyncio.new_event_loop() with ThreadPoolExecutor() as executor: yield {"loop": loop, "executor": executor, "run_in_executor": False} loop.close() @pytest.mark.parametrize("lock_type", _ASYNC_SINGLETON_TYPES) def test_async_singleton_reuses_the_instance_for_the_same_options( tmp_path: Path, lock_type: type[BaseAsyncFileLock], async_options: _AsyncOptions ) -> None: lock = lock_type(tmp_path / "a.lock", is_singleton=True, **async_options) assert lock_type(tmp_path / "a.lock", is_singleton=True, **async_options) is lock @pytest.mark.parametrize("option", [pytest.param(name, id=name) for name in ("loop", "executor", "run_in_executor")]) @pytest.mark.parametrize("lock_type", _ASYNC_SINGLETON_TYPES) def test_async_singleton_rejects_reuse_with_another_option( tmp_path: Path, lock_type: type[BaseAsyncFileLock], async_options: _AsyncOptions, option: Literal["loop", "executor", "run_in_executor"], ) -> None: _held = lock_type(tmp_path / "a.lock", is_singleton=True, **async_options) # the cache only keeps it weakly del async_options[option] with pytest.raises(ValueError, match=rf"\n\t{option} \(existing lock has "): lock_type(tmp_path / "a.lock", is_singleton=True, **async_options) @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_async_relative_waiter_keeps_acquisition_directory( lock_type: type[BaseAsyncFileLock], working_directories: tuple[Path, Path] ) -> None: first, second = working_directories holder = lock_type(first / "resource.lock") await holder.acquire() polling = asyncio.Event() contender = lock_type("resource.lock", timeout=0.2, poll_interval=0.001) task = asyncio.create_task(contender.acquire(cancel_check=partial(_signal_poll, polling))) try: await asyncio.wait_for(polling.wait(), timeout=2) os.chdir(second) with pytest.raises(Timeout): await asyncio.wait_for(task, timeout=2) finally: await holder.release(force=True) assert not (second / "resource.lock").exists() def _signal_poll(polling: asyncio.Event) -> bool: polling.set() return False @pytest.mark.asyncio async def test_async_relative_release_uses_acquisition_directory(working_directories: tuple[Path, Path]) -> None: first, second = working_directories lock = AsyncSoftFileLock("resource.lock") await lock.acquire() os.chdir(second) await lock.release() assert not (first / "resource.lock").exists() @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_async_singleton_held_before_chdir_re_enters_by_absolute_path( lock_type: type[BaseAsyncFileLock], working_directories: tuple[Path, Path] ) -> None: first, second = working_directories held = lock_type("resource.lock", is_singleton=True) await held.acquire() os.chdir(second) async with lock_type(first / "resource.lock", is_singleton=True) as again: assert (again is held, held.lock_counter) == (True, 2) await held.release(force=True) @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_different_tasks_no_false_positive(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: # The registry is per-thread, so a second acquire from a different task must not look like a reentrant deadlock. lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path, timeout=0) await lock1.acquire() error: BaseException | None = None async def acquire_other() -> None: nonlocal error lock2 = lock_type(lock_path, timeout=0) try: await lock2.acquire() except BaseException as exc: error = exc await asyncio.create_task(acquire_other()) await lock1.release() assert not isinstance(error, RuntimeError), "Should not raise RuntimeError in a different task" @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_cross_task_blocking_acquire_queues(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: # A blocking acquire from a different task must queue behind the holder, not raise. Polling yields the event # loop on every iteration, so the holder keeps running and releases; only a same-task reentry can deadlock. # The waiter starts on an event the holder sets once it holds: yielding with sleep(0) instead would let the # waiter run its check before the holder registers, so an empty registry would pass even with the bug. lock_path = tmp_path / "test.lock" events: list[str] = [] holding = asyncio.Event() async def holder() -> None: async with lock_type(lock_path): events.append("holder:acquired") holding.set() await asyncio.sleep(0.05) # the waiter polls while the lock is held events.append("holder:released") async def waiter() -> None: await holding.wait() async with lock_type(lock_path): events.append("waiter:acquired") await asyncio.gather(holder(), waiter()) # propagates a false-positive Deadlock assert events == ["holder:acquired", "holder:released", "waiter:acquired"] @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_release_from_different_task_clears_registry(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: # The holder scope is pinned at commit time, so releasing from a different task still drops the registry entry. lock_path = tmp_path / "test.lock" lock = lock_type(lock_path) await lock.acquire() async def release() -> None: await lock.release() await asyncio.create_task(release()) async with lock_type(lock_path): # would raise Deadlock if the scoped entry had leaked pass @NEEDS_PARENT_SYMLINK_COLLAPSE @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio # pragma: win32 no cover async def test_symlink_same_canonical_path(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: # A symlinked parent directory resolves to the same canonical key; the final component stays literal. real_dir = tmp_path / "real" real_dir.mkdir() (tmp_path / "link").symlink_to(real_dir) lock1 = lock_type(str(real_dir / "test.lock")) async with lock1: lock2 = lock_type(str(tmp_path / "link" / "test.lock")) with pytest.raises(RuntimeError, match="Deadlock"): await lock2.acquire() @NEEDS_FCNTL @pytest.mark.parametrize( ("depth", "force"), [ pytest.param(1, False, id="direct"), pytest.param(2, False, id="nested"), pytest.param(2, True, id="forced"), ], ) @pytest.mark.asyncio # pragma: needs fcntl async def test_release_drops_acquisition_key_after_parent_retarget(tmp_path: Path, depth: int, force: bool) -> None: lock_path, original_path, replacement_path = _symlinked_lock_paths(tmp_path) lock = AsyncFileLock(lock_path, run_in_executor=False) for _depth in range(depth): await lock.acquire() _retarget_parent(lock_path, replacement_path) if force: await lock.release(force=True) else: for _depth in range(depth): await lock.release() async with AsyncFileLock(original_path, run_in_executor=False) as successor: assert successor.is_locked @NEEDS_FCNTL @pytest.mark.asyncio async def test_release_keeps_retargeted_parent_holder_registered(tmp_path: Path) -> None: # pragma: needs fcntl lock_path, _original_path, replacement_path = _symlinked_lock_paths(tmp_path) original = AsyncFileLock(lock_path, run_in_executor=False) await original.acquire() _retarget_parent(lock_path, replacement_path) async with AsyncFileLock(replacement_path, run_in_executor=False): await original.release() with pytest.raises(RuntimeError, match="Deadlock"): await AsyncFileLock(replacement_path, run_in_executor=False).acquire(cancel_check=lambda: True) @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_deadlock_registry_cleanup_on_release(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) await lock1.acquire() await lock1.release() lock2 = lock_type(lock_path) async with lock2: assert lock2.is_locked @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_force_release_clears_registry(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) async with lock1: await lock1.acquire() assert lock1.lock_counter == 2 await lock1.release(force=True) lock2 = lock_type(lock_path) async with lock2: assert lock2.is_locked def _symlinked_lock_paths(tmp_path: Path) -> tuple[Path, Path, Path]: # pragma: needs fcntl original = tmp_path / "original" replacement = tmp_path / "replacement" original.mkdir() replacement.mkdir() link = tmp_path / "link" link.symlink_to(original, target_is_directory=True) return link / "test.lock", original / "test.lock", replacement / "test.lock" def _retarget_parent(lock_path: Path, replacement_path: Path) -> None: # pragma: needs fcntl link = lock_path.parent link.unlink() link.symlink_to(replacement_path.parent, target_is_directory=True) async def _acquire_for_mode(lock: BaseAsyncFileLock, mode: Literal["finite", "nonblocking"]) -> None: if mode == "finite": await lock.acquire(timeout=0) else: await lock.acquire(blocking=False) @NEEDS_FCNTL @pytest.mark.asyncio # pragma: needs fcntl async def test_release_keeps_lock_held_when_unlock_fails(tmp_path: Path, mocker: MockerFixture) -> None: lock = AsyncFileLock(str(tmp_path / "a")) await lock.acquire() mocker.patch("filelock._unix.fcntl.flock", side_effect=[OSError(EIO, "unlock failed"), None]) with pytest.raises(OSError, match="unlock failed"): await lock.release() assert lock.is_locked assert lock.lock_counter == 1 await lock.release() assert not lock.is_locked @pytest.mark.asyncio async def test_async_zero_write_rolls_back_acquire(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._util.os.write", return_value=0) lock = AsyncSoftFileLock(str(tmp_path / "a")) with pytest.raises(OSError, match="0 bytes"): await lock.acquire() assert not lock.is_locked @pytest.mark.parametrize( ("policy", "surfaces"), [pytest.param("default", True, id="default"), pytest.param("suppress", False, id="suppress")], ) @pytest.mark.asyncio async def test_soft_close_error_policy_cleans_marker( tmp_path: Path, mocker: MockerFixture, policy: CloseErrorPolicy, *, surfaces: bool, ) -> None: lock_path = tmp_path / "a" lock = AsyncSoftFileLock(lock_path, close_error_policy=policy, run_in_executor=False) await lock.acquire() with _close_after_commit(mocker, lock) as (close_error, attempts): if surfaces: with pytest.raises(OSError, match="close failed") as info: await lock.release() assert info.value is close_error else: await lock.release() assert (len(attempts), lock.is_locked, lock.lock_counter, lock_path.exists()) == (1, False, 0, False) @pytest.mark.parametrize( ("depth", "force"), [pytest.param(2, False, id="nested"), pytest.param(2, True, id="forced")], ) @pytest.mark.asyncio async def test_soft_close_error_suppression_releases_once( tmp_path: Path, mocker: MockerFixture, depth: int, *, force: bool, ) -> None: lock_path = tmp_path / "a" lock = AsyncSoftFileLock(lock_path, close_error_policy="suppress", run_in_executor=False) for _acquisition in range(depth): await lock.acquire() with _close_after_commit(mocker, lock) as (_, attempts): if force: await lock.release(force=True) else: for _release in range(depth): await lock.release() assert (len(attempts), lock.is_locked, lock.lock_counter, lock_path.exists()) == (1, False, 0, False) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) @pytest.mark.asyncio async def test_soft_context_surfaces_close_error_after_cleanup( tmp_path: Path, mocker: MockerFixture, *, use_proxy: bool, ) -> None: lock_path = tmp_path / "a" lock = AsyncSoftFileLock(lock_path, run_in_executor=False) with ( _close_after_commit(mocker, lock) as (close_error, attempts), pytest.raises(OSError, match="close failed") as info, ): async with await lock.acquire() if use_proxy else lock: pass assert (info.value, len(attempts), lock.is_locked, lock_path.exists()) == (close_error, 1, False, False) @pytest.mark.asyncio async def test_soft_second_release_does_not_close_reused_descriptor( tmp_path: Path, mocker: MockerFixture, ) -> None: lock = AsyncSoftFileLock(tmp_path / "a", run_in_executor=False) await lock.acquire() with ( _close_after_commit(mocker, lock) as (close_error, attempts), pytest.raises(OSError, match="close failed") as info, ): await lock.release() assert info.value is close_error reused_fd = os.open(tmp_path / "reused", os.O_CREAT | os.O_WRONLY) assert reused_fd == attempts[0] try: await lock.release() os.fstat(reused_fd) finally: os.close(reused_fd) @contextmanager def _close_after_commit(mocker: MockerFixture, lock: AsyncSoftFileLock) -> Generator[tuple[OSError, list[int]]]: real_close = os.close close_error = OSError(EINTR, "close failed") attempts: list[int] = [] # close is an attribute of the shared os module, so this patch binds for every close in the process while the # window is open, including one a GC-run finalizer (a multiprocessing pipe from an earlier test, say) makes on this # very thread. Fail only the descriptor this lock lets go of: it clears its bookkeeping right before the one close # attempt, so record the descriptor as it does. released: list[int | None] = [] real_mark_released = lock._mark_descriptor_released def mark_released() -> None: released.append(lock._context.lock_file_fd) real_mark_released() def close(fd: int) -> None: real_close(fd) if fd not in released: return attempts.append(fd) raise close_error mocker.patch.object(lock, "_mark_descriptor_released", side_effect=mark_released) close_mock = mocker.patch("filelock._soft.os.close", side_effect=close) try: yield close_error, attempts finally: mocker.stop(close_mock) @pytest.mark.asyncio async def test_close_after_commit_ignores_other_descriptors(tmp_path: Path, mocker: MockerFixture) -> None: # The patch binds os.close process-wide, so a descriptor that is not the lock's, whether closed on another thread # or by a finalizer the collector happens to run on this one, must pass through cleanly and stay out of the # recorded attempts. lock = AsyncSoftFileLock(tmp_path / "a", run_in_executor=False) await lock.acquire() closed: list[int] = [] def close_a_pipe() -> None: read_fd, write_fd = os.pipe() os.close(read_fd) os.close(write_fd) closed.extend((read_fd, write_fd)) with _close_after_commit(mocker, lock) as (_close_error, attempts): close_a_pipe() worker = threading.Thread(target=close_a_pipe) worker.start() worker.join() with pytest.raises(OSError, match="close failed"): await lock.release() assert (len(closed), len(attempts), attempts[0] in closed) == (4, 1, False) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) @pytest.mark.asyncio async def test_context_group_detaches_release_context( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], *, use_proxy: bool, ) -> None: capture, release_error, release_cause = close_failure body_error = ValueError("body failed") body_cause = LookupError("body cause") lock = AsyncFileLock( str(tmp_path / "a"), thread_local=False, context_error_policy="group", close_error_policy="raise", on_acquired=capture, ) with pytest.raises(ExceptionGroup) as info: async with await lock.acquire() if use_proxy else lock: raise body_error from body_cause assert ( info.value.exceptions, body_error.__context__, release_error.__context__, body_error.__cause__, release_error.__cause__, body_error.__traceback__ is not None, release_error.__traceback__ is not None, ) == ((body_error, release_error), None, None, body_cause, release_cause, True, True) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) @pytest.mark.asyncio async def test_context_group_renders_independent_leaves( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], *, use_proxy: bool, ) -> None: capture, release_error, _ = close_failure release_error.__cause__ = None release_error.__suppress_context__ = False body_error = ValueError("body failed") lock = AsyncFileLock( str(tmp_path / "a"), thread_local=False, context_error_policy="group", close_error_policy="raise", on_acquired=capture, ) with pytest.raises(ExceptionGroup) as info: async with await lock.acquire() if use_proxy else lock: raise body_error group_rendering = "".join(traceback.format_exception(info.value)) release_rendering = "".join(traceback.format_exception(release_error)) assert ( group_rendering.count("ValueError: body failed"), release_rendering.count("ValueError: body failed"), release_rendering.count("RuntimeError: release cause"), release_rendering.count("OSError: release failed"), ) == (1, 0, 0, 1) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) @pytest.mark.asyncio async def test_context_chain_keeps_release_error_with_body_in_context( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], *, use_proxy: bool, ) -> None: capture, release_error, _ = close_failure body_error = ValueError("body failed") lock = AsyncFileLock( str(tmp_path / "a"), thread_local=False, context_error_policy="chain", close_error_policy="raise", on_acquired=capture, ) with pytest.raises(OSError, match="release failed") as info: async with await lock.acquire() if use_proxy else lock: raise body_error assert (info.value, release_error.__context__) == (release_error, body_error) @pytest.mark.parametrize( "policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")], ) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) @pytest.mark.asyncio async def test_context_body_only_failure_propagates_body( tmp_path: Path, policy: ContextErrorPolicy, *, use_proxy: bool ) -> None: lock = AsyncSoftFileLock(str(tmp_path / "a"), context_error_policy=policy) body = ValueError("body failed") with pytest.raises(ValueError, match="body failed") as info: async with await lock.acquire() if use_proxy else lock: raise body assert info.value is body @pytest.mark.parametrize( "policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")], ) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) @pytest.mark.asyncio async def test_context_release_only_failure_propagates_release( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], policy: ContextErrorPolicy, *, use_proxy: bool, ) -> None: capture, release_error, release_cause = close_failure lock = AsyncFileLock( str(tmp_path / "a"), thread_local=False, context_error_policy=policy, close_error_policy="raise", on_acquired=capture, ) with pytest.raises(OSError, match="release failed") as info: async with await lock.acquire() if use_proxy else lock: pass assert (info.value, release_error.__context__, release_error.__cause__) == (release_error, None, release_cause) @pytest.mark.asyncio async def test_context_group_base_exception_leaf_is_base_group( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError] ) -> None: capture, _, _ = close_failure lock = AsyncFileLock( str(tmp_path / "a"), thread_local=False, context_error_policy="group", close_error_policy="raise", on_acquired=capture, ) with pytest.raises(BaseExceptionGroup) as info: async with lock: raise KeyboardInterrupt assert not isinstance(info.value, ExceptionGroup) assert [type(leaf) for leaf in info.value.exceptions] == [KeyboardInterrupt, OSError] @pytest.mark.asyncio async def test_context_group_preserves_user_release_cancellation_group(tmp_path: Path) -> None: body_error = ValueError("body failed") backend_cancellation = asyncio.CancelledError("backend canceled") backend_error = OSError(EIO, "backend failed") release_group = BaseExceptionGroup( "lock release cancellation and backend release both failed", (backend_cancellation, backend_error), ) fail_release = True class GroupReleaseLock(BaseAsyncFileLock): def _acquire(self) -> None: self._context.lock_file_fd = 1 def _release(self) -> None: nonlocal fail_release if fail_release: fail_release = False raise release_group self._context.lock_file_fd = None lock = GroupReleaseLock(tmp_path / "a", run_in_executor=False, context_error_policy="group") with pytest.raises(BaseExceptionGroup) as info: async with lock: raise body_error assert info.value.exceptions == (body_error, release_group) assert info.value.exceptions[1] is release_group assert release_group.exceptions == (backend_cancellation, backend_error) await lock.release() def _fail_close_of(mocker: MockerFixture, lock: BaseAsyncFileLock, error: OSError) -> None: # pragma: needs fcntl # Fail os.close only for this lock's descriptor, so an unrelated lock's __del__ close during the test is untouched. fd = lock._context.lock_file_fd real_close = os.close def close(target: int) -> None: if target == fd: raise error real_close(target) # pragma: no cover # only an unrelated __del__ close during the mock window reaches here mocker.patch("filelock._api.os.close", side_effect=close) @NEEDS_FCNTL @pytest.mark.asyncio async def test_close_error_raise_propagates(tmp_path: Path, mocker: MockerFixture) -> None: # pragma: needs fcntl lock = AsyncFileLock(str(tmp_path / "a"), close_error_policy="raise") await lock.acquire() _fail_close_of(mocker, lock, OSError(EIO, "close failed")) with pytest.raises(OSError, match="close failed"): await lock.release() assert not lock.is_locked @NEEDS_FCNTL @pytest.mark.asyncio # pragma: needs fcntl async def test_close_error_default_suppressed_on_unix(tmp_path: Path, mocker: MockerFixture) -> None: lock = AsyncFileLock(str(tmp_path / "a")) # default policy await lock.acquire() _fail_close_of(mocker, lock, OSError(EIO, "close failed")) await lock.release() assert not lock.is_locked @NEEDS_FCNTL @pytest.mark.asyncio # pragma: needs fcntl async def test_fallback_to_soft_disabled_raises_enosys(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._unix.fcntl.flock", side_effect=OSError(ENOSYS, "no flock")) lock = AsyncFileLock(str(tmp_path / "a"), fallback_to_soft=False) with pytest.raises(OSError, match="no flock"): await lock.acquire() assert not lock.is_locked assert type(lock).__name__ == "AsyncUnixFileLock" # not swapped to the soft class def test_preserve_lock_file_async_soft_rejects(tmp_path: Path) -> None: with pytest.raises(ValueError, match="preserve_lock_file"): AsyncSoftFileLock(str(tmp_path / "a"), preserve_lock_file=True) @NEEDS_FCNTL @pytest.mark.asyncio async def test_preserve_lock_file_async_release_keeps_pathname(tmp_path: Path) -> None: # pragma: needs fcntl path = tmp_path / "a" lock = AsyncFileLock(str(path), preserve_lock_file=True) await lock.acquire() await lock.release() assert path.exists() # the native pathname survives an async release assert type(lock).__name__ == "AsyncUnixFileLock" def _failing_on_acquired(_fd: int) -> None: # pragma: needs fcntl msg = "hook failed" raise RuntimeError(msg) def test_on_acquired_async_soft_rejects(tmp_path: Path) -> None: with pytest.raises(ValueError, match="on_acquired"): AsyncSoftFileLock(str(tmp_path / "a"), on_acquired=lambda _fd: None) @NEEDS_FCNTL @pytest.mark.asyncio async def test_on_acquired_runs_in_backend_executor(tmp_path: Path) -> None: # pragma: needs fcntl hook_thread = -1 fd_while_held = -1 def hook(fd: int) -> None: nonlocal hook_thread, fd_while_held hook_thread = threading.get_ident() if lock.is_locked: # pragma: no branch # on_acquired only ever runs while the lock is held fd_while_held = fd lock = AsyncFileLock(str(tmp_path / "a"), thread_local=False, on_acquired=hook) await lock.acquire() try: assert fd_while_held >= 0 # the hook ran while the lock was held, with a real descriptor assert hook_thread != threading.get_ident() # in the backend executor, not the event-loop thread finally: await lock.release() @NEEDS_FCNTL @pytest.mark.asyncio async def test_on_acquired_async_failure_releases(tmp_path: Path) -> None: # pragma: needs fcntl lock = AsyncFileLock(str(tmp_path / "a"), thread_local=False, on_acquired=_failing_on_acquired) with pytest.raises(RuntimeError, match="hook failed"): await lock.acquire() assert not lock.is_locked assert lock.lock_counter == 0 @pytest.mark.asyncio async def test_on_acquired_rollback_group_detaches_release_context( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError] ) -> None: capture, release_error, release_cause = close_failure callback_error = RuntimeError("hook failed") callback_cause = LookupError("hook cause") def fail(fd: int) -> None: capture(fd) raise callback_error from callback_cause lock = AsyncFileLock(str(tmp_path / "a"), thread_local=False, close_error_policy="raise", on_acquired=fail) with pytest.raises(ExceptionGroup) as info: await lock.acquire() assert ( info.value.exceptions, callback_error.__context__, release_error.__context__, callback_error.__cause__, release_error.__cause__, callback_error.__traceback__ is not None, release_error.__traceback__ is not None, ) == ((callback_error, release_error), None, None, callback_cause, release_cause, True, True) def test_async_del_without_any_loop_returns(tmp_path: Path, mocker: MockerFixture) -> None: # GC can finalize a lock with no loop running and none remembered, where releasing is impossible. Raise the lookup # rather than rely on no loop running here, which the surrounding tests decide. get_running_loop = mocker.patch("filelock.asyncio.asyncio.get_running_loop", side_effect=RuntimeError) lock = AsyncSoftFileLock(str(tmp_path / "a")) release = mocker.patch.object(lock, "release", autospec=True) # Any lock another test dropped reaches the same patched lookup, so only calls from here on count. get_running_loop.reset_mock() lock.__del__() # ruff:ignore[unnecessary-dunder-call] # a finalizer test cannot ride on collection timing # An unlocked finalizer asserts nothing on its own; the lookup and the absent release pin the path it took. assert (get_running_loop.called, release.called, lock.is_locked) == (True, False, False) def test_asyncio_module_dir_lists_only_its_public_api() -> None: assert dir(filelock.asyncio) == sorted(filelock.asyncio.__all__) def test_async_del_without_any_loop_releases_a_sync_backend(tmp_path: Path) -> None: lock = AsyncSoftFileLock(str(tmp_path / "a")) asyncio.run(lock.acquire()) lock.__del__() # ruff:ignore[unnecessary-dunder-call] # a finalizer test cannot ride on collection timing assert (lock.is_locked, lock.lock_counter, (tmp_path / "a").exists()) == (False, 0, False) def test_async_del_schedules_release_on_a_stored_loop_in_another_thread(tmp_path: Path, mocker: MockerFixture) -> None: loop = asyncio.new_event_loop() runner = threading.Thread(target=loop.run_forever) runner.start() try: lock = AsyncSoftFileLock(str(tmp_path / "a"), loop=loop) asyncio.run_coroutine_threadsafe(lock.acquire(), loop).result(timeout=5) schedule = mocker.spy(asyncio, "run_coroutine_threadsafe") lock.__del__() # ruff:ignore[unnecessary-dunder-call] # a finalizer test cannot ride on collection timing schedule.spy_return.result(timeout=5) assert (lock.is_locked, (tmp_path / "a").exists()) == (False, False) finally: loop.call_soon_threadsafe(loop.stop) runner.join(timeout=5) loop.close() @pytest.mark.asyncio async def test_async_del_inside_a_running_loop_schedules_release(tmp_path: Path, schedule: MockType) -> None: lock = AsyncSoftFileLock(str(tmp_path / "a")) await lock.acquire() lock.__del__() # ruff:ignore[unnecessary-dunder-call] # a finalizer test cannot ride on collection timing await schedule.spy_return assert (lock.is_locked, (tmp_path / "a").exists()) == (False, False) @pytest.mark.parametrize("cancel", [pytest.param(False, id="release-fails"), pytest.param(True, id="release-canceled")]) @pytest.mark.asyncio async def test_async_del_inside_a_running_loop_falls_back_when_the_release_does_not_finish( tmp_path: Path, schedule: MockType, *, cancel: bool ) -> None: with ThreadPoolExecutor(max_workers=1) as executor: lock: Final = AsyncSoftFileLock(str(tmp_path / "a"), executor=executor) await lock.acquire() lock.__del__() # ruff:ignore[unnecessary-dunder-call] # a finalizer test cannot ride on collection timing # A loop shutting down cancels the release before it runs; a shut-down executor fails it. if cancel: schedule.spy_return.cancel() else: executor.shutdown() await asyncio.wait([schedule.spy_return]) await asyncio.sleep(0) # the done callbacks run on the next loop iteration assert (lock.is_locked, (tmp_path / "a").exists()) == (False, False) @NEEDS_PROMPT_FINALIZATION @pytest.mark.asyncio @pytest.mark.timeout(30) async def test_async_del_never_waits_on_the_singleton_cache_lock(tmp_path: Path, schedule: MockType) -> None: # A constructor holds the cache lock while it takes the fork locks, inside which the collector can finalize a lock. class StallingLock(AsyncSoftFileLock): def __init__(self, lock_file: Path, *, is_singleton: bool) -> None: super().__init__(lock_file, is_singleton=is_singleton, run_in_executor=False) if next(constructions): inside.set() leave.wait() constructions: Final = count() inside: Final = threading.Event() leave: Final = threading.Event() lock = StallingLock(tmp_path / "a", is_singleton=True) await lock.acquire() holder: Final = threading.Thread(target=StallingLock, args=(tmp_path / "b",), kwargs={"is_singleton": True}) holder.start() inside.wait() try: del lock finally: leave.set() holder.join() await asyncio.wait([schedule.spy_return]) assert not (tmp_path / "a").exists() @pytest.mark.asyncio async def test_async_thread_local_del_inside_a_running_loop_releases_another_threads_hold(tmp_path: Path) -> None: lock_path: Final = tmp_path / "a" lock: Final = AsyncSoftFileLock(lock_path, thread_local=True, run_in_executor=False) holder: Final = threading.Thread(target=asyncio.run, args=(lock.acquire(),)) holder.start() holder.join() lock.__del__() # ruff:ignore[unnecessary-dunder-call] # a finalizer test cannot ride on collection timing await asyncio.sleep(0) # a release the finalizer scheduled on this loop would run here assert not lock_path.exists() @NEEDS_PROMPT_FINALIZATION @pytest.mark.asyncio async def test_async_del_hands_the_singleton_slot_to_a_new_lock(tmp_path: Path, schedule: MockType) -> None: lock_path: Final = tmp_path / "a" # An executor worker keeps the call it ran until it picks up the next one, which would keep the lock alive. dying = AsyncSoftFileLock(lock_path, is_singleton=True, run_in_executor=False) await dying.acquire() del dying successor: Final = AsyncSoftFileLock(lock_path, is_singleton=True, run_in_executor=False) await successor.acquire() await asyncio.wait([schedule.spy_return]) assert (successor.is_locked, lock_path.exists()) == (True, True) await successor.release() @NEEDS_PROMPT_FINALIZATION @pytest.mark.parametrize("thread_local", [pytest.param(True, id="thread_local"), pytest.param(False, id="shared")]) def test_async_singleton_handed_out_while_finalizing_keeps_its_hold(tmp_path: Path, thread_local: bool) -> None: lock_path: Final = str(tmp_path / "a") handed_out: Final[list[AsyncFileLock]] = [] # The cache still finds a lock whose finalizer is starting, as another thread asking at that moment would. # Interpreters run a profile hook with tracing off, so coverage does not record its body. def take_from_cache(frame: FrameType, event: str, _arg: Unused) -> None: # pragma: no cover if event == "call" and frame.f_code is BaseFileLock.__del__.__code__ and not handed_out: handed_out.append( AsyncFileLock(lock_path, is_singleton=True, thread_local=thread_local, run_in_executor=False) ) asyncio.run(handed_out[0].acquire()) sys.setprofile(take_from_cache) try: AsyncFileLock(lock_path, is_singleton=True, thread_local=thread_local, run_in_executor=False) finally: sys.setprofile(None) with pytest.raises(Timeout): FileLock(lock_path, blocking=False).acquire() asyncio.run(handed_out[0].release()) @pytest_asyncio.fixture(loop_scope="function") async def schedule(mocker: MockerFixture) -> MockType: # ruff:ignore[unused-async] # runs on the test's loop return mocker.spy(asyncio.get_running_loop(), "create_task") @NEEDS_PROMPT_FINALIZATION @pytest.mark.parametrize("thread_local", [pytest.param(False, id="shared"), pytest.param(True, id="thread-local")]) def test_async_lock_on_a_daemon_loop_thread_held_at_interpreter_exit_releases_its_marker( tmp_path: Path, thread_local: bool ) -> None: script: Final = ( "import asyncio, sys, threading\n" "from filelock import AsyncSoftFileLock\n" "loop = asyncio.new_event_loop()\n" "threading.Thread(target=loop.run_forever, daemon=True).start()\n" f"lock = AsyncSoftFileLock(sys.argv[1], loop=loop, thread_local={thread_local}, " f"run_in_executor={not thread_local})\n" "asyncio.run_coroutine_threadsafe(lock.acquire(), loop).result()\n" ) subprocess.run([sys.executable, "-c", script, str(tmp_path / "a")], check=True, timeout=10) assert not (tmp_path / "a").exists() @pytest.mark.filterwarnings("ignore::filelock.SoftFileLockLifetimeWarning") @pytest.mark.asyncio @NEEDS_STALE_RECLAIM async def test_executor_acquire_keeps_filesystem_calls_off_the_loop(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "a" lock_path.touch() os.utime(lock_path, (0, 0)) lock = AsyncSoftFileLock(str(lock_path), lifetime=1) real_lstat = os.lstat lstat_threads: set[int] = set() def lstat(path: str, *, dir_fd: int | None = None) -> os.stat_result: if os.fspath(path) == lock.lock_file: lstat_threads.add(threading.get_ident()) return real_lstat(path, dir_fd=dir_fd) mocker.patch("os.lstat", side_effect=lstat) await lock.acquire() mocker.stopall() await lock.release() # The patch is process-wide, so only the lock's own path counts; unrelated loop-thread lstats are not ours. assert lstat_threads assert threading.get_ident() not in lstat_threads filelock-4.2.8/tests/test_async_filelock_acquire_cancellation.py0000644000000000000000000005151615272561277022256 0ustar00from __future__ import annotations import asyncio import logging import threading from concurrent.futures import ThreadPoolExecutor from errno import EIO from typing import TYPE_CHECKING import pytest from filelock import ( AsyncFileLock, BaseAsyncFileLock, ContextErrorPolicy, ) from tests.async_filelock_cancellation_helpers import assert_cancellation_message, assert_file_lock_state from tests.capability_marks import NEEDS_FCNTL, XFAIL_WITHOUT_COROUTINE_CANCELLATION if TYPE_CHECKING: from pathlib import Path from pytest_mock import MockerFixture @pytest.mark.asyncio async def test_backend_cancellation_after_acquire_rolls_back(tmp_path: Path) -> None: backend_cancellation = asyncio.CancelledError("backend canceled") released = False class CancellingFileLock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = 1 raise backend_cancellation async def _release(self) -> None: # ty: ignore[invalid-method-override] nonlocal released released = True self._context.lock_file_fd = None lock = CancellingFileLock(tmp_path / "a", run_in_executor=False) with pytest.raises(asyncio.CancelledError, match="backend canceled"): await lock.acquire() assert (released, lock.is_locked, lock.lock_counter) == (True, False, 0) @pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_backend_cancellation_rollback_failure_follows_policy(tmp_path: Path, policy: ContextErrorPolicy) -> None: backend_cancellation = asyncio.CancelledError("backend canceled") rollback_error = OSError(EIO, "rollback failed") prior_leaf = LookupError("prior rollback failure") prior_cause = RuntimeError("prior rollback cause") prior_rollback_error = ExceptionGroup("prior rollback failures", (prior_leaf,)) prior_rollback_error.__cause__ = prior_cause prior_cause.__context__ = prior_rollback_error fail_rollback = True class CancellingFileLock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = 1 raise backend_cancellation async def _release(self) -> None: # ty: ignore[invalid-method-override] if fail_rollback: try: raise prior_rollback_error except ExceptionGroup: raise rollback_error # ruff:ignore[raise-without-from-inside-except] # exercise implicit backend context preservation self._context.lock_file_fd = None lock = CancellingFileLock(tmp_path / "a", run_in_executor=False, context_error_policy=policy) if policy == "chain": with pytest.raises(OSError, match="rollback failed") as info: await lock.acquire() assert (info.value, rollback_error.__context__, backend_cancellation.__context__) == ( rollback_error, backend_cancellation, prior_rollback_error, ) else: with pytest.raises(BaseExceptionGroup) as info: await lock.acquire() assert (info.value.exceptions, rollback_error.__context__) == ( (backend_cancellation, rollback_error), prior_rollback_error, ) assert (lock.is_locked, lock.lock_counter) == (True, 1) fail_rollback = False await lock.release() assert (lock.is_locked, lock.lock_counter) == (False, 0) @pytest.mark.asyncio async def test_caller_and_backend_cancellation_both_surface(tmp_path: Path) -> None: acquire_started = asyncio.Event() finish_acquire = asyncio.Event() backend_cancellation = asyncio.CancelledError("backend canceled") class CancellingFileLock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = 1 acquire_started.set() await finish_acquire.wait() raise backend_cancellation async def _release(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = None lock = CancellingFileLock(tmp_path / "a", run_in_executor=False, context_error_policy="group") task = asyncio.create_task(lock.acquire()) await acquire_started.wait() task.cancel("caller canceled") finish_acquire.set() with pytest.raises(BaseExceptionGroup) as info: await task caller_cancellation, grouped_backend_cancellation = info.value.exceptions assert isinstance(caller_cancellation, asyncio.CancelledError) assert (caller_cancellation.args, grouped_backend_cancellation) == (("caller canceled",), backend_cancellation) assert (lock.is_locked, lock.lock_counter) == (False, 0) @pytest.mark.asyncio async def test_acquire_cancellation_before_executor_start_rolls_back(tmp_path: Path) -> None: executor_started = threading.Event() release_executor = threading.Event() acquired = threading.Event() with ThreadPoolExecutor(max_workers=1) as executor: blocker = executor.submit(_block_executor, executor_started, release_executor) assert executor_started.wait(timeout=5) lock = AsyncFileLock(tmp_path / "a", executor=executor, on_acquired=lambda _fd: acquired.set()) task = asyncio.create_task(lock.acquire()) await asyncio.sleep(0) # The acquire resolves the lock path on the executor first, then creates the directory there, so queue a second # blocker behind the first job and a third behind the directory job to hold the backend attempt after them. second_started = threading.Event() release_second = threading.Event() second_blocker = executor.submit(_block_executor, second_started, release_second) release_executor.set() assert await asyncio.to_thread(second_started.wait, 5) # The resolved path wakes the task, which queues the directory job behind the second blocker. await asyncio.sleep(0.05) third_started = threading.Event() release_third = threading.Event() third_blocker = executor.submit(_block_executor, third_started, release_third) release_second.set() assert await asyncio.to_thread(third_started.wait, 5) # The created directory wakes the task, which queues the attempt behind the third blocker. await asyncio.sleep(0.05) assert (lock.lock_counter, acquired.is_set()) == (1, False) task.cancel() release_third.set() with pytest.raises(asyncio.CancelledError): await task blocker.result(timeout=5) second_blocker.result(timeout=5) third_blocker.result(timeout=5) assert acquired.is_set() assert (lock.is_locked, lock.lock_counter) == (False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) @pytest.mark.parametrize("held", [pytest.param(False, id="acquire"), pytest.param(True, id="release")]) @pytest.mark.asyncio async def test_executor_shutdown_drops_queued_call_with_runtime_error(tmp_path: Path, *, held: bool) -> None: executor = ThreadPoolExecutor(max_workers=1) lock = AsyncFileLock(tmp_path / "a", executor=executor) if held: await lock.acquire() executor_started = threading.Event() release_executor = threading.Event() blocker = executor.submit(_block_executor, executor_started, release_executor) assert executor_started.wait(timeout=5) task = asyncio.create_task(lock.release() if held else lock.acquire()) await asyncio.sleep(0) executor.shutdown(wait=False, cancel_futures=True) release_executor.set() blocker.result(timeout=5) with pytest.raises(RuntimeError, match="executor shut down before running"): await task assert (lock.is_locked, lock.lock_counter) == (held, int(held)) assert_file_lock_state(str(tmp_path / "a"), available=not held) lock.executor = None await lock.release() assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.asyncio async def test_acquire_cancellation_does_not_release_later_claim(tmp_path: Path) -> None: # pragma: needs fcntl hook_started = asyncio.Event() finish_hook = threading.Event() loop = asyncio.get_running_loop() def block_hook(_fd: int) -> None: loop.call_soon_threadsafe(hook_started.set) assert finish_hook.wait(timeout=5) lock = AsyncFileLock(tmp_path / "a", on_acquired=block_hook) first_acquire = asyncio.create_task(lock.acquire()) await hook_started.wait() second_acquire = asyncio.create_task(lock.acquire()) first_acquire.cancel("cancel first acquire") finish_hook.set() with pytest.raises(asyncio.CancelledError) as info: await first_acquire assert_cancellation_message(info.value, "cancel first acquire") await second_acquire assert (lock.is_locked, lock.lock_counter) == (True, 1) assert_file_lock_state(str(tmp_path / "a"), available=False) await lock.release() assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_cancelled_queued_acquire_does_not_claim_transition(tmp_path: Path) -> None: # pragma: needs fcntl hook_started = asyncio.Event() finish_hook = threading.Event() loop = asyncio.get_running_loop() def block_hook(_fd: int) -> None: loop.call_soon_threadsafe(hook_started.set) assert finish_hook.wait(timeout=5) lock = AsyncFileLock(tmp_path / "a", on_acquired=block_hook) first_acquire = asyncio.create_task(lock.acquire()) await hook_started.wait() queued_acquire = asyncio.create_task(lock.acquire()) await asyncio.sleep(0) queued_acquire.cancel("cancel queued acquire") with pytest.raises(asyncio.CancelledError) as info: await queued_acquire assert_cancellation_message(info.value, "cancel queued acquire") assert lock.lock_counter == 1 finish_hook.set() await first_acquire await lock.acquire() assert lock.lock_counter == 2 await lock.release() await lock.release() assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.asyncio # pragma: needs fcntl async def test_acquire_repeated_cancellation_waits_for_rollback(tmp_path: Path, mocker: MockerFixture) -> None: hook_started = asyncio.Event() finish_hook = threading.Event() rollback_started = asyncio.Event() finish_rollback = threading.Event() loop = asyncio.get_running_loop() def block_hook(_fd: int) -> None: loop.call_soon_threadsafe(hook_started.set) assert finish_hook.wait(timeout=5) def block_rollback(_fd: int, _operation: int) -> None: loop.call_soon_threadsafe(rollback_started.set) assert finish_rollback.wait(timeout=5) lock = AsyncFileLock(tmp_path / "a", on_acquired=block_hook) task = asyncio.create_task(lock.acquire()) await hook_started.wait() mocker.patch("filelock._unix.fcntl.flock", side_effect=block_rollback) task.cancel("first cancellation") finish_hook.set() await rollback_started.wait() task.cancel("second cancellation") finish_rollback.set() with pytest.raises(asyncio.CancelledError) as info: await task assert_cancellation_message(info.value, "first cancellation") assert (lock.is_locked, lock.lock_counter) == (False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_surfaces_attempt_error_after_rollback( # pragma: needs fcntl tmp_path: Path, policy: ContextErrorPolicy ) -> None: hook_started = asyncio.Event() finish_hook = threading.Event() loop = asyncio.get_running_loop() callback_error = ValueError("hook failed") prior_context = LookupError("prior callback failure") callback_error.__context__ = prior_context def fail_hook(_fd: int) -> None: loop.call_soon_threadsafe(hook_started.set) assert finish_hook.wait(timeout=5) raise callback_error lock = AsyncFileLock(tmp_path / "a", context_error_policy=policy, on_acquired=fail_hook) task = asyncio.create_task(lock.acquire()) await hook_started.wait() task.cancel("cancel acquire") finish_hook.set() if policy == "chain": with pytest.raises(ValueError, match="hook failed") as info: await task cancellation = info.value.__context__ assert info.value is callback_error else: with pytest.raises(BaseExceptionGroup) as info: await task cancellation, grouped_callback_error = info.value.exceptions assert grouped_callback_error is callback_error assert (info.value.__cause__, info.value.__suppress_context__) == (None, True) assert isinstance(cancellation, asyncio.CancelledError) assert (cancellation.args, cancellation.__context__, callback_error.__cause__, callback_error.__context__) == ( ("cancel acquire",), prior_context if policy == "chain" else None, None, cancellation if policy == "chain" else prior_context, ) assert (lock.is_locked, lock.lock_counter) == (False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.parametrize( ("context_message", "preserved"), [pytest.param("unrelated", True, id="distinct"), pytest.param("attempt", False, id="equivalent")], ) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_group_reconciles_attempt_context( # pragma: needs fcntl tmp_path: Path, context_message: str, *, preserved: bool ) -> None: hook_started = asyncio.Event() finish_hook = threading.Event() loop = asyncio.get_running_loop() nested_leaf = LookupError("nested") callback_group = ExceptionGroup("attempt", (nested_leaf,)) prior_context = ExceptionGroup(context_message, (nested_leaf,)) callback_group.__context__ = prior_context def fail_hook(_fd: int) -> None: loop.call_soon_threadsafe(hook_started.set) assert finish_hook.wait(timeout=5) raise callback_group lock = AsyncFileLock(tmp_path / "a", context_error_policy="group", on_acquired=fail_hook) task = asyncio.create_task(lock.acquire()) await hook_started.wait() task.cancel("cancel acquire") finish_hook.set() with pytest.raises(BaseExceptionGroup) as info: await task cancellation, grouped_callback_error = info.value.exceptions assert isinstance(cancellation, asyncio.CancelledError) assert isinstance(grouped_callback_error, ExceptionGroup) assert grouped_callback_error is callback_group assert ( type(grouped_callback_error), grouped_callback_error.message, grouped_callback_error.exceptions, grouped_callback_error.__context__, callback_group.__context__, ) == ( ExceptionGroup, "attempt", (nested_leaf,), prior_context if preserved else None, prior_context if preserved else None, ) assert (lock.is_locked, lock.lock_counter) == (False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_surfaces_rollback_error( # pragma: needs fcntl tmp_path: Path, mocker: MockerFixture, caplog: pytest.LogCaptureFixture, policy: ContextErrorPolicy, ) -> None: hook_started = asyncio.Event() finish_hook = threading.Event() loop = asyncio.get_running_loop() def block_hook(_fd: int) -> None: loop.call_soon_threadsafe(hook_started.set) assert finish_hook.wait(timeout=5) rollback_error = OSError(EIO, "rollback failed") lock = AsyncFileLock(tmp_path / "a", context_error_policy=policy, on_acquired=block_hook) task = asyncio.create_task(lock.acquire()) await hook_started.wait() mocker.patch("filelock._unix.fcntl.flock", side_effect=[rollback_error, None]) task.cancel("cancel acquire") finish_hook.set() if policy == "chain": with pytest.raises(OSError, match="rollback failed") as info: await task cancellation = info.value.__context__ assert info.value is rollback_error else: with pytest.raises(BaseExceptionGroup) as info: await task cancellation, grouped_rollback_error = info.value.exceptions assert grouped_rollback_error is rollback_error assert (info.value.__cause__, info.value.__suppress_context__) == (None, True) assert isinstance(cancellation, asyncio.CancelledError) assert (cancellation.args, rollback_error.__cause__, rollback_error.__context__) == ( ("cancel acquire",), None, cancellation if policy == "chain" else None, ) assert (cancellation.__traceback__ is not None, rollback_error.__traceback__ is not None) == (True, True) assert not any(record.name == "asyncio" and record.levelno >= logging.ERROR for record in caplog.records) assert (lock.is_locked, lock.lock_counter) == (True, 1) assert_file_lock_state(str(tmp_path / "a"), available=False) await lock.release() @NEEDS_FCNTL @pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_surfaces_attempt_and_rollback_errors( # pragma: needs fcntl tmp_path: Path, mocker: MockerFixture, policy: ContextErrorPolicy ) -> None: hook_started = asyncio.Event() finish_hook = threading.Event() loop = asyncio.get_running_loop() callback_error = ValueError("hook failed") def fail_hook(_fd: int) -> None: loop.call_soon_threadsafe(hook_started.set) assert finish_hook.wait(timeout=5) raise callback_error first_rollback_error = OSError(EIO, "hook rollback failed") second_rollback_error = OSError(EIO, "cancellation rollback failed") lock = AsyncFileLock(tmp_path / "a", context_error_policy=policy, on_acquired=fail_hook) task = asyncio.create_task(lock.acquire()) await hook_started.wait() mocker.patch( "filelock._unix.fcntl.flock", side_effect=[first_rollback_error, second_rollback_error, None], ) task.cancel("cancel acquire") finish_hook.set() if policy == "chain": with pytest.raises(OSError, match="cancellation rollback failed") as info: await task attempt_error = info.value.__context__ cancellation = attempt_error.__context__ if attempt_error is not None else None assert info.value is second_rollback_error else: with pytest.raises(BaseExceptionGroup) as info: await task cancellation, attempt_error, grouped_rollback_error = info.value.exceptions assert grouped_rollback_error is second_rollback_error assert (info.value.__cause__, info.value.__suppress_context__) == (None, True) assert isinstance(attempt_error, BaseExceptionGroup) assert (attempt_error.exceptions[0], attempt_error.exceptions[1]) == (callback_error, first_rollback_error) assert isinstance(cancellation, asyncio.CancelledError) assert ( cancellation.args, attempt_error.__cause__, attempt_error.__context__, second_rollback_error.__cause__, second_rollback_error.__context__, ) == ( ("cancel acquire",), None, cancellation if policy == "chain" else None, None, attempt_error if policy == "chain" else None, ) assert (callback_error.__context__, first_rollback_error.__context__) == (None, None) assert ( cancellation.__traceback__ is not None, callback_error.__traceback__ is not None, first_rollback_error.__traceback__ is not None, attempt_error.__traceback__ is not None, second_rollback_error.__traceback__ is not None, ) == (True, True, True, True, True) assert (lock.is_locked, lock.lock_counter) == (True, 1) assert_file_lock_state(str(tmp_path / "a"), available=False) await lock.release() @pytest.mark.asyncio async def test_non_executor_immediate_acquire_precedes_scheduled_callback(tmp_path: Path) -> None: callbacks: list[str] = [] asyncio.get_running_loop().call_soon(callbacks.append, "callback") lock = AsyncFileLock(tmp_path / "a", run_in_executor=False) await lock.acquire() assert callbacks == [] await lock.release() await asyncio.sleep(0) assert callbacks == ["callback"] def _block_executor(executor_started: threading.Event, release_executor: threading.Event) -> None: executor_started.set() assert release_executor.wait(timeout=5) filelock-4.2.8/tests/test_async_filelock_release_cancellation.py0000644000000000000000000002003515272561277022235 0ustar00from __future__ import annotations import asyncio import logging import threading from errno import EIO from typing import TYPE_CHECKING import pytest from filelock import AsyncFileLock, BaseAsyncFileLock, ContextErrorPolicy from tests.async_filelock_cancellation_helpers import ( assert_cancellation_message, assert_file_lock_state, get_fcntl, start_file_lock_holder, ) from tests.capability_marks import NEEDS_FCNTL, XFAIL_WITHOUT_COROUTINE_CANCELLATION if TYPE_CHECKING: from pathlib import Path from pytest_mock import MockerFixture @NEEDS_FCNTL @pytest.mark.asyncio # pragma: needs fcntl async def test_release_completes_despite_cancellation(tmp_path: Path, mocker: MockerFixture) -> None: lock = AsyncFileLock(str(tmp_path / "a")) await lock.acquire() release_started = asyncio.Event() finish_release = threading.Event() loop = asyncio.get_running_loop() def block_unlock(_fd: int, _operation: int) -> None: loop.call_soon_threadsafe(release_started.set) assert finish_release.wait(timeout=5) mocker.patch("filelock._unix.fcntl.flock", side_effect=block_unlock) task = asyncio.create_task(lock.release()) await release_started.wait() task.cancel("cancel release") finish_release.set() with pytest.raises(asyncio.CancelledError): await task assert (lock.is_locked, lock.lock_counter) == (False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.asyncio # pragma: needs fcntl @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_proceeds_after_queued_release_is_canceled(tmp_path: Path, mocker: MockerFixture) -> None: lock = AsyncFileLock(tmp_path / "a") await lock.acquire() release_started = asyncio.Event() finish_release = threading.Event() loop = asyncio.get_running_loop() fcntl = get_fcntl() real_flock = fcntl.flock blocked = False def block_first_unlock(fd: int, operation: int) -> None: nonlocal blocked if operation & fcntl.LOCK_UN and not blocked: blocked = True loop.call_soon_threadsafe(release_started.set) assert finish_release.wait(timeout=5) real_flock(fd, operation) mocker.patch("filelock._unix.fcntl.flock", side_effect=block_first_unlock) first_release = asyncio.create_task(lock.release()) await release_started.wait() second_release = asyncio.create_task(lock.release()) await asyncio.sleep(0) second_release.cancel("abandon queued release") try: with pytest.raises(asyncio.CancelledError) as info: await second_release assert_cancellation_message(info.value, "abandon queued release") acquire = asyncio.create_task(lock.acquire()) finally: finish_release.set() await first_release await acquire assert (lock.is_locked, lock.lock_counter) == (True, 1) await lock.release() assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.asyncio async def test_release_waits_for_provisional_acquire(tmp_path: Path) -> None: # pragma: needs fcntl hook_started = asyncio.Event() finish_hook = threading.Event() loop = asyncio.get_running_loop() def block_hook(_fd: int) -> None: loop.call_soon_threadsafe(hook_started.set) assert finish_hook.wait(timeout=5) lock = AsyncFileLock(tmp_path / "a", on_acquired=block_hook) acquire_task = asyncio.create_task(lock.acquire()) await hook_started.wait() release_started = asyncio.Event() async def release() -> None: release_started.set() await lock.release() release_task = asyncio.create_task(release()) await release_started.wait() finish_hook.set() await acquire_task await release_task assert (lock.is_locked, lock.lock_counter) == (False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) @pytest.mark.asyncio async def test_release_returns_while_acquire_waits_for_external_holder(tmp_path: Path) -> None: holder, holder_started, finish_holder = start_file_lock_holder(str(tmp_path / "a")) assert await asyncio.to_thread(holder_started.wait, 5) first_polled = asyncio.Event() def observe_first_poll() -> bool: first_polled.set() return False lock = AsyncFileLock(tmp_path / "a") acquire_task = asyncio.create_task(lock.acquire(cancel_check=observe_first_poll, poll_interval=0.001)) try: await first_polled.wait() await lock.release() assert (acquire_task.done(), lock.is_locked) == (False, False) finally: finish_holder.set() await asyncio.to_thread(holder.join, 5) assert not holder.is_alive() await acquire_task await lock.release() assert (lock.is_locked, lock.lock_counter) == (False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_release_cancellation_surfaces_backend_error( # pragma: needs fcntl tmp_path: Path, mocker: MockerFixture, caplog: pytest.LogCaptureFixture, policy: ContextErrorPolicy ) -> None: lock = AsyncFileLock(tmp_path / "a", context_error_policy=policy) await lock.acquire() release_started = asyncio.Event() finish_release = threading.Event() loop = asyncio.get_running_loop() release_error = OSError(EIO, "release failed") release_count = 0 def fail_first_unlock(_fd: int, _operation: int) -> None: nonlocal release_count release_count += 1 if release_count == 1: loop.call_soon_threadsafe(release_started.set) assert finish_release.wait(timeout=5) raise release_error mocker.patch("filelock._unix.fcntl.flock", side_effect=fail_first_unlock) task = asyncio.create_task(lock.release()) await release_started.wait() task.cancel("cancel release") finish_release.set() if policy == "chain": with pytest.raises(OSError, match="release failed") as info: await task cancellation = info.value.__context__ assert info.value is release_error else: with pytest.raises(BaseExceptionGroup) as info: await task cancellation, grouped_release_error = info.value.exceptions assert grouped_release_error is release_error assert (info.value.__cause__, info.value.__suppress_context__) == (None, True) assert isinstance(cancellation, asyncio.CancelledError) assert (cancellation.args, release_error.__cause__, release_error.__context__) == ( ("cancel release",), None, cancellation if policy == "chain" else None, ) assert (cancellation.__traceback__ is not None, release_error.__traceback__ is not None) == (True, True) assert not any(record.name == "asyncio" and record.levelno >= logging.ERROR for record in caplog.records) assert (lock.is_locked, lock.lock_counter) == (True, 1) assert_file_lock_state(str(tmp_path / "a"), available=False) await lock.release() @pytest.mark.asyncio async def test_context_chain_does_not_duplicate_body_already_in_release_group(tmp_path: Path) -> None: body_error = ValueError("body failed") release_group = ExceptionGroup("release failed", (body_error,)) fail_release = True class GroupReleaseLock(BaseAsyncFileLock): def _acquire(self) -> None: self._context.lock_file_fd = 1 def _release(self) -> None: nonlocal fail_release if fail_release: fail_release = False raise release_group self._context.lock_file_fd = None lock = GroupReleaseLock(tmp_path / "a", run_in_executor=False, context_error_policy="chain") with pytest.raises(ExceptionGroup) as info: async with lock: raise body_error assert (info.value, release_group.__context__, release_group.exceptions) == ( release_group, body_error, (body_error,), ) await lock.release() filelock-4.2.8/tests/test_async_filelock_rollback_agnostic.py0000644000000000000000000002213015272561277021557 0ustar00from __future__ import annotations import asyncio from errno import EIO from typing import TYPE_CHECKING import pytest from filelock import AsyncFileLock, BaseAsyncFileLock from tests.capability_marks import XFAIL_WITHOUT_COROUTINE_CANCELLATION if TYPE_CHECKING: from pathlib import Path @pytest.mark.asyncio async def test_release_serialized_skips_when_peer_already_released(tmp_path: Path) -> None: release_started = asyncio.Event() finish_release = asyncio.Event() releases = 0 class Lock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = 1 async def _release(self) -> None: # ty: ignore[invalid-method-override] nonlocal releases releases += 1 release_started.set() await finish_release.wait() self._context.lock_file_fd = None lock = Lock(tmp_path / "a", run_in_executor=False) await lock.acquire() first = asyncio.create_task(lock.release()) await release_started.wait() second = asyncio.create_task(lock.release()) await asyncio.sleep(0) # let the queued release block on the transition gate before the holder unlocks finish_release.set() await first await second assert releases == 1 # the queued release found the lock already gone and did no backend work assert (lock.is_locked, lock.lock_counter) == (False, 0) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_release_cancellation_surfaces_backend_error(tmp_path: Path) -> None: backend_error = OSError(EIO, "backend release failed") release_started = asyncio.Event() finish_release = asyncio.Event() fail_release = True class Lock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = 1 async def _release(self) -> None: # ty: ignore[invalid-method-override] if fail_release: release_started.set() await finish_release.wait() raise backend_error self._context.lock_file_fd = None lock = Lock(tmp_path / "a", run_in_executor=False) await lock.acquire() task = asyncio.create_task(lock.release()) await release_started.wait() task.cancel("cancel release") finish_release.set() with pytest.raises(OSError, match="backend release failed") as info: await task assert info.value is backend_error assert isinstance(info.value.__context__, asyncio.CancelledError) assert (lock.is_locked, lock.lock_counter) == (True, 1) # backend failed before unlocking, so state is kept fail_release = False await lock.release() assert (lock.is_locked, lock.lock_counter) == (False, 0) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_rollback_failure_surfaces_backend_error(tmp_path: Path) -> None: rollback_error = OSError(EIO, "rollback release failed") acquire_started = asyncio.Event() finish_acquire = asyncio.Event() fail_release = True class Lock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] acquire_started.set() await finish_acquire.wait() self._context.lock_file_fd = 1 async def _release(self) -> None: # ty: ignore[invalid-method-override] if fail_release: raise rollback_error self._context.lock_file_fd = None lock = Lock(tmp_path / "a", run_in_executor=False) task = asyncio.create_task(lock.acquire()) await acquire_started.wait() task.cancel("cancel acquire") finish_acquire.set() # let the backend finish acquiring, so cancellation must roll the acquired lock back with pytest.raises(OSError, match="rollback release failed") as info: await task assert info.value is rollback_error assert isinstance(info.value.__context__, asyncio.CancelledError) assert (lock.is_locked, lock.lock_counter) == (True, 1) # rollback failed before unlocking, so state is kept fail_release = False await lock.release() assert (lock.is_locked, lock.lock_counter) == (False, 0) @pytest.mark.asyncio async def test_registration_failure_rolls_back_and_surfaces_cleanup(tmp_path: Path) -> None: registration_error = RuntimeError("registration failed") rollback_error = OSError(EIO, "rollback release failed") fail_release = True class Lock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = 1 async def _release(self) -> None: # ty: ignore[invalid-method-override] if fail_release: raise rollback_error self._context.lock_file_fd = None def _register_context_descriptor(self) -> None: # ruff:ignore[no-self-use] # overrides a base instance method raise registration_error lock = Lock(tmp_path / "a", run_in_executor=False) with pytest.raises(ExceptionGroup) as info: await lock.acquire() assert info.value.message == "descriptor registration cleanup failed" assert info.value.exceptions == (registration_error, rollback_error) assert (lock.is_locked, lock.lock_counter) == (True, 1) # rollback failed before unlocking, so state is kept fail_release = False await lock.release() assert (lock.is_locked, lock.lock_counter) == (False, 0) @pytest.mark.parametrize( "rollback_fails", [pytest.param(True, id="rollback-fails"), pytest.param(False, id="rollback-succeeds")], ) @pytest.mark.asyncio async def test_acquire_failure_with_registration_failure_rolls_back(tmp_path: Path, *, rollback_fails: bool) -> None: acquisition_error = RuntimeError("acquire failed") registration_error = LookupError("registration failed") rollback_error = OSError(EIO, "rollback release failed") fail_release = rollback_fails class Lock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = 1 raise acquisition_error async def _release(self) -> None: # ty: ignore[invalid-method-override] if fail_release: raise rollback_error self._context.lock_file_fd = None def _register_context_descriptor(self) -> None: # ruff:ignore[no-self-use] # overrides a base instance method raise registration_error lock = Lock(tmp_path / "a", run_in_executor=False) with pytest.raises(ExceptionGroup) as info: await lock.acquire() assert info.value.message == "lock acquisition cleanup failed" if rollback_fails: assert info.value.exceptions == (acquisition_error, registration_error, rollback_error) assert (lock.is_locked, lock.lock_counter) == (True, 1) # rollback failed before unlocking, so state is kept fail_release = False await lock.release() else: assert info.value.exceptions == (acquisition_error, registration_error) assert (lock.is_locked, lock.lock_counter) == (False, 0) @pytest.mark.asyncio async def test_context_group_reconciles_release_cancellation_with_backend_error(tmp_path: Path) -> None: body_error = ValueError("body failed") backend_error = OSError(EIO, "backend release failed") release_started = asyncio.Event() finish_release = asyncio.Event() fail_release = True class Lock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = 1 async def _release(self) -> None: # ty: ignore[invalid-method-override] if fail_release: release_started.set() await finish_release.wait() raise backend_error self._context.lock_file_fd = None lock = Lock(tmp_path / "a", run_in_executor=False, context_error_policy="group") async def run_context() -> None: async with lock: raise body_error task = asyncio.create_task(run_context()) await release_started.wait() task.cancel("cancel release") finish_release.set() with pytest.raises(BaseExceptionGroup) as info: await task grouped_body, grouped_cancellation, grouped_backend = info.value.exceptions assert info.value.message == "context body, release cancellation, and backend release failed" assert (grouped_body, grouped_backend) == (body_error, backend_error) assert isinstance(grouped_cancellation, asyncio.CancelledError) assert (lock.is_locked, lock.lock_counter) == (True, 1) # backend failed before unlocking, so state is kept fail_release = False await lock.release() assert (lock.is_locked, lock.lock_counter) == (False, 0) def test_sync_context_exit_is_unsupported(tmp_path: Path) -> None: # __enter__ already raises, so the with-statement never reaches __exit__; call the protocol method directly. lock = AsyncFileLock(str(tmp_path / "a")) with pytest.raises(NotImplementedError, match=r"async with"): lock.__exit__(None, None, None) filelock-4.2.8/tests/test_async_filelock_runner_shutdown.py0000644000000000000000000001464415272561277021356 0ustar00from __future__ import annotations import asyncio import threading from errno import EIO from queue import Queue from typing import TYPE_CHECKING, TypeVar import pytest from filelock import AsyncAcquireReturnProxy, AsyncFileLock, ContextErrorPolicy from tests.async_filelock_cancellation_helpers import assert_file_lock_state, get_fcntl from tests.capability_marks import NEEDS_FCNTL, XFAIL_WITHOUT_COROUTINE_CANCELLATION if TYPE_CHECKING: from collections.abc import Coroutine from pathlib import Path from pytest_mock import MockerFixture _T = TypeVar("_T") class _CancellationObservedTask(asyncio.Task[_T]): # pragma: needs fcntl def __init__( self, coroutine: Coroutine[None, None, _T], cancellation_seen: threading.Event, *, loop: asyncio.AbstractEventLoop, ) -> None: self._cancellation_seen = cancellation_seen super().__init__(coroutine, loop=loop) def cancel(self, msg: object | None = None) -> bool: # Task.cancel accepts arbitrary payloads; object is the narrowest accurate type for its public contract. self._cancellation_seen.set() return super().cancel(msg) @NEEDS_FCNTL def test_runner_shutdown_waits_for_executor_acquire_rollback(tmp_path: Path) -> None: # pragma: needs fcntl hook_started = threading.Event() finish_hook = threading.Event() cancellation_seen = threading.Event() def block_hook(_fd: int) -> None: hook_started.set() assert finish_hook.wait(timeout=5) lock = AsyncFileLock(tmp_path / "a", on_acquired=block_hook) tasks: Queue[asyncio.Task[AsyncAcquireReturnProxy]] = Queue() runner = threading.Thread( target=_run_unawaited_acquire, args=(lock, hook_started, cancellation_seen, tasks), ) runner.start() try: tasks.get(timeout=5) assert hook_started.wait(timeout=5) assert cancellation_seen.wait(timeout=5), "asyncio runner did not cancel the pending lock task" finally: finish_hook.set() runner.join(timeout=5) assert (runner.is_alive(), lock.is_locked, lock.lock_counter) == (False, False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) @XFAIL_WITHOUT_COROUTINE_CANCELLATION def test_runner_shutdown_preserves_body_cancellation_and_release_errors( # pragma: needs fcntl tmp_path: Path, mocker: MockerFixture, policy: ContextErrorPolicy ) -> None: body_error = ValueError("body failed") prior_error = LookupError("prior release failure") release_error = OSError(EIO, "release failed") release_error.__context__ = prior_error release_started = threading.Event() finish_release = threading.Event() failed = False fcntl = get_fcntl() real_flock = fcntl.flock def fail_first_unlock(fd: int, operation: int) -> None: nonlocal failed if operation & fcntl.LOCK_UN and not failed: failed = True release_started.set() assert finish_release.wait(timeout=5) raise release_error real_flock(fd, operation) mocker.patch("filelock._unix.fcntl.flock", side_effect=fail_first_unlock) lock = AsyncFileLock(tmp_path / "a", thread_local=False, context_error_policy=policy) runner, task, cancellation_seen = _start_unawaited_context_failure( lock, body_error, release_started, ) try: assert release_started.wait(timeout=5) assert cancellation_seen.wait(timeout=5), "asyncio runner did not cancel the pending lock task" finally: finish_release.set() runner.join(timeout=5) assert (runner.is_alive(), task.done()) == (False, True) error = task.exception() if policy == "chain": assert error is release_error cancellation = release_error.__context__ assert isinstance(cancellation, asyncio.CancelledError) assert (cancellation.__context__, prior_error.__context__) == (prior_error, body_error) else: assert isinstance(error, BaseExceptionGroup) cancellation = error.exceptions[1] assert isinstance(cancellation, asyncio.CancelledError) assert error.exceptions == (body_error, cancellation, release_error) assert (cancellation.__context__, release_error.__context__, prior_error.__context__) == ( None, prior_error, None, ) assert (lock.is_locked, lock.lock_counter) == (True, 1) assert_file_lock_state(str(tmp_path / "a"), available=False) asyncio.run(lock.release()) assert_file_lock_state(str(tmp_path / "a"), available=True) def _run_unawaited_acquire( # pragma: needs fcntl lock: AsyncFileLock, hook_started: threading.Event, cancellation_seen: threading.Event, tasks: Queue[asyncio.Task[AsyncAcquireReturnProxy]], ) -> None: async def start_acquire() -> None: tasks.put( _CancellationObservedTask( lock.acquire(), cancellation_seen, loop=asyncio.get_running_loop(), ) ) assert await asyncio.to_thread(hook_started.wait, 5) asyncio.run(start_acquire()) def _start_unawaited_context_failure( # pragma: needs fcntl lock: AsyncFileLock, body_error: BaseException, release_started: threading.Event, ) -> tuple[threading.Thread, asyncio.Task[None], threading.Event]: tasks: Queue[asyncio.Task[None]] = Queue() cancellation_seen = threading.Event() runner = threading.Thread( target=_run_unawaited_context_failure, args=(lock, body_error, release_started, cancellation_seen, tasks), ) runner.start() task = tasks.get(timeout=5) return runner, task, cancellation_seen def _run_unawaited_context_failure( # pragma: needs fcntl lock: AsyncFileLock, body_error: BaseException, release_started: threading.Event, cancellation_seen: threading.Event, tasks: Queue[asyncio.Task[None]], ) -> None: async def fail_in_context() -> None: async with lock: raise body_error async def start_context() -> None: tasks.put( _CancellationObservedTask( fail_in_context(), cancellation_seen, loop=asyncio.get_running_loop(), ) ) assert await asyncio.to_thread(release_started.wait, 5) asyncio.run(start_context()) filelock-4.2.8/tests/test_async_filelock_transition_admission.py0000644000000000000000000001672515272561277022354 0ustar00from __future__ import annotations import asyncio import sys import threading from typing import TYPE_CHECKING, Final import pytest from filelock import AsyncFileLock, AsyncSoftFileLock, BaseAsyncFileLock, Timeout from filelock._async import _AsyncTransitionGate from tests.async_filelock_cancellation_helpers import ( assert_cancellation_message, assert_file_lock_state, start_file_lock_holder, ) from tests.capability_marks import XFAIL_WITHOUT_COROUTINE_CANCELLATION if TYPE_CHECKING: from concurrent.futures import Future as ConcurrentFuture from pathlib import Path from typing import Literal from pytest_mock import MockerFixture _UNIX_FLOCK_ONLY: Final[pytest.MarkDecorator] = pytest.mark.skipif( sys.platform == "win32", reason="native flock semantics are Unix-only" ) @pytest.mark.parametrize( "admission", [ pytest.param("nonblocking", id="nonblocking"), pytest.param("deadline", id="finite-deadline"), pytest.param("cancel-check", id="cancel-check"), ], ) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_queued_acquire_honors_own_admission_policy( tmp_path: Path, admission: Literal["nonblocking", "deadline", "cancel-check"] ) -> None: first_polled = asyncio.Event() def observe_first_poll() -> bool: first_polled.set() return False holder, holder_started, finish_holder = start_file_lock_holder(str(tmp_path / "a")) assert await asyncio.to_thread(holder_started.wait, 5) lock = AsyncFileLock(tmp_path / "a") first_task = asyncio.create_task(lock.acquire(cancel_check=observe_first_poll, poll_interval=0.001)) try: await first_polled.wait() if admission == "nonblocking": with pytest.raises(Timeout): await lock.acquire(blocking=False) elif admission == "deadline": with pytest.raises(Timeout): await lock.acquire(timeout=0.01) else: cancel_second = asyncio.Event() second_started = asyncio.Event() async def acquire_second() -> None: second_started.set() await lock.acquire(cancel_check=cancel_second.is_set, poll_interval=0.001) second_task = asyncio.create_task(acquire_second()) await second_started.wait() cancel_second.set() with pytest.raises(Timeout): await second_task assert not first_task.done() finally: finish_holder.set() await asyncio.to_thread(holder.join, 5) assert not holder.is_alive() await first_task await lock.release() assert (lock.is_locked, lock.lock_counter) == (False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) @pytest.mark.parametrize( "lock_type", [pytest.param(AsyncFileLock, id="flock"), pytest.param(AsyncSoftFileLock, id="soft")] ) @pytest.mark.asyncio async def test_acquire_interrupted_while_joining_the_gate_leaves_the_lock_usable( tmp_path: Path, mocker: MockerFixture, lock_type: type[AsyncFileLock | AsyncSoftFileLock] ) -> None: real_join: Final = _AsyncTransitionGate._join interrupted = False def join_then_interrupt(instance: _AsyncTransitionGate, ticket: ConcurrentFuture[None]) -> None: nonlocal interrupted real_join(instance, ticket) if not interrupted: interrupted = True raise _Interrupt mocker.patch.object(_AsyncTransitionGate, "_join", autospec=True, side_effect=join_then_interrupt) lock = lock_type(tmp_path / "a") with pytest.raises(_Interrupt): await lock.acquire() await asyncio.wait_for(lock.acquire(), 5) assert lock.is_locked await lock.release() @pytest.mark.asyncio async def test_acquires_giving_up_behind_a_waiter_leave_the_lock_releasable(tmp_path: Path) -> None: first_polled = asyncio.Event() def observe_first_poll() -> bool: first_polled.set() return False holder, holder_started, finish_holder = start_file_lock_holder(str(tmp_path / "a")) assert await asyncio.to_thread(holder_started.wait, 5) lock = AsyncFileLock(tmp_path / "a") waiter = asyncio.create_task(lock.acquire(cancel_check=observe_first_poll, poll_interval=0.001)) try: await first_polled.wait() # More callers than the recursion limit allows frames for, should they be settled one inside the other. for _ in range(sys.getrecursionlimit()): with pytest.raises(Timeout): await lock.acquire(blocking=False) finally: finish_holder.set() await asyncio.to_thread(holder.join, 5) await waiter await asyncio.wait_for(lock.release(), 5) assert (lock.is_locked, lock.lock_counter) == (False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) @_UNIX_FLOCK_ONLY @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_queued_acquire_proceeds_after_prior_waiter_cancels(tmp_path: Path) -> None: # pragma: win32 no cover hook_started = asyncio.Event() finish_hook = threading.Event() loop = asyncio.get_running_loop() def block_hook(_fd: int) -> None: # pragma: win32 no cover loop.call_soon_threadsafe(hook_started.set) assert finish_hook.wait(timeout=5) lock = AsyncFileLock(tmp_path / "a", on_acquired=block_hook) first_task = asyncio.create_task(lock.acquire()) await hook_started.wait() second_task, second_started = _start_signaled_acquire(lock) await second_started.wait() third_task, third_started = _start_signaled_acquire(lock) await third_started.wait() second_task.cancel("abandon queued acquire") try: # pragma: win32 no cover with pytest.raises(asyncio.CancelledError) as info: # pragma: win32 no cover await second_task assert_cancellation_message(info.value, "abandon queued acquire") finally: finish_hook.set() await first_task await third_task assert (lock.is_locked, lock.lock_counter) == (True, 2) await lock.release() await lock.release() assert (lock.is_locked, lock.lock_counter) == (False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) def _start_signaled_acquire(lock: AsyncFileLock) -> tuple[asyncio.Task[None], asyncio.Event]: # pragma: win32 no cover started = asyncio.Event() async def acquire() -> None: # pragma: win32 no cover started.set() await lock.acquire() return asyncio.create_task(acquire()), started @pytest.mark.parametrize( "lock_type", [pytest.param(AsyncFileLock, id="flock"), pytest.param(AsyncSoftFileLock, id="soft")] ) @pytest.mark.asyncio async def test_thread_local_lock_releases_while_another_thread_waits_for_it( tmp_path: Path, lock_type: type[BaseAsyncFileLock] ) -> None: lock = lock_type(tmp_path / "a", thread_local=True, run_in_executor=False) await lock.acquire() polling = threading.Event() def note_poll() -> bool: polling.set() return False async def acquire_and_release() -> None: await lock.acquire(timeout=5, cancel_check=note_poll, poll_interval=0.01) await lock.release() waiter = asyncio.create_task(asyncio.to_thread(asyncio.run, acquire_and_release())) assert await asyncio.to_thread(polling.wait, 5) await asyncio.wait_for(lock.release(), 5) await waiter assert not lock.is_locked class _Interrupt(BaseException): """Stands in for an exception a signal handler raises, without pytest treating it as a KeyboardInterrupt.""" filelock-4.2.8/tests/test_async_read_write.py0000644000000000000000000010112715272561277016360 0ustar00from __future__ import annotations import asyncio import gc import re import threading import time from concurrent.futures import ThreadPoolExecutor from contextlib import AsyncExitStack from typing import TYPE_CHECKING, Final, Literal import pytest import pytest_asyncio from tests.capability_marks import NEEDS_COLLECTED_FINALIZATION, XFAIL_WITHOUT_COROUTINE_CANCELLATION from tests.read_write_helpers import ACQUIRE_SETTINGS, INVALID_TIMEOUTS, Interrupt, interrupt_once pytest.importorskip("sqlite3") import sqlite3 from filelock import AsyncReadWriteLock, ReadWriteLock, Timeout if TYPE_CHECKING: from collections.abc import AsyncIterator, Generator from pathlib import Path from pytest_mock import MockerFixture # Bounds every wait on another task, so a lock that never admits it fails the test instead of hanging the suite. _TASK_WAIT: Final[float] = 10 @pytest.fixture(autouse=True) def _clear_singleton_cache() -> Generator[None]: ReadWriteLock._instances.clear() yield for ref in list(ReadWriteLock._instances.valuerefs()): if (lock := ref()) is not None: # pragma: no cover # cache is normally emptied before teardown lock.close() ReadWriteLock._instances.clear() @pytest.fixture def lock_file(tmp_path: Path) -> str: return str(tmp_path / "test_lock.db") @pytest.mark.asyncio async def test_async_release_leaves_a_sync_reader_of_the_same_singleton_holding(lock_file: str) -> None: async_lock: Final = AsyncReadWriteLock(lock_file) with ReadWriteLock(lock_file).read_lock(): async with async_lock.read_lock(): pass with pytest.raises(Timeout): ReadWriteLock(lock_file, is_singleton=False).acquire_write(blocking=False) await async_lock.close() @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio async def test_acquire_release(lock_file: str, mode: Literal["read", "write"]) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) proxy = await (lock.acquire_read() if mode == "read" else lock.acquire_write()) async with proxy as held: assert held is lock with pytest.raises(RuntimeError, match="not held"): await lock.release() await lock.close() @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio async def test_lock_context_manager(lock_file: str, mode: Literal["read", "write"]) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) ctx = lock.read_lock() if mode == "read" else lock.write_lock() async with ctx: assert_mode_held(lock_file, mode) with pytest.raises(RuntimeError, match="not held"): await lock.release() await lock.close() @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio async def test_reentrant(lock_file: str, mode: Literal["read", "write"]) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) acquire = lock.acquire_read if mode == "read" else lock.acquire_write await acquire() await acquire() await lock.release() assert_mode_held(lock_file, mode) await lock.release() with pytest.raises(RuntimeError, match="not held"): await lock.release() await lock.close() @pytest.mark.parametrize( ("held", "requested", "match"), [ pytest.param("read", "write", r"already holding a read lock.*upgrade not allowed", id="upgrade"), pytest.param("write", "read", r"already holding a write lock.*downgrade not allowed", id="downgrade"), ], ) @pytest.mark.asyncio async def test_mode_change_prohibited( lock_file: str, held: Literal["read", "write"], requested: Literal["read", "write"], match: str, ) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) await (lock.acquire_read() if held == "read" else lock.acquire_write()) with pytest.raises(RuntimeError, match=match): await (lock.acquire_read() if requested == "read" else lock.acquire_write()) await lock.release() await lock.close() @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio async def test_non_blocking_conflict(lock_file: str, mode: Literal["read", "write"]) -> None: holder = ReadWriteLock(lock_file, is_singleton=False) (holder.acquire_write if mode == "read" else holder.acquire_read)() try: lock = AsyncReadWriteLock(lock_file, is_singleton=False) with pytest.raises(Timeout): await (lock.acquire_read if mode == "read" else lock.acquire_write)(blocking=False) await lock.close() finally: holder.release() @pytest.mark.asyncio async def test_timeout_expires(lock_file: str) -> None: holder = ReadWriteLock(lock_file, is_singleton=False) holder.acquire_write() try: lock = AsyncReadWriteLock(lock_file, is_singleton=False) with pytest.raises(Timeout): await lock.acquire_read(timeout=0.2) await lock.close() finally: holder.release() @pytest.mark.asyncio async def test_release_unheld_raises(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) with pytest.raises(RuntimeError, match="not held"): await lock.release() await lock.close() @pytest.mark.asyncio async def test_release_force(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) await lock.acquire_write() await lock.acquire_write() await lock.release(force=True) with pytest.raises(RuntimeError, match="not held"): await lock.release() await lock.close() @pytest.mark.asyncio async def test_release_force_unheld_is_noop(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) await lock.release(force=True) await lock.close() @pytest.mark.parametrize( ("held", "supplied_executor"), [ pytest.param(False, False, id="idle-owned-executor"), pytest.param(True, False, id="held-owned-executor"), pytest.param(False, True, id="idle-supplied-executor"), pytest.param(True, True, id="held-supplied-executor"), ], ) @pytest.mark.asyncio async def test_close_rejects_later_acquisition(lock_file: str, held: bool, supplied_executor: bool) -> None: executor = ThreadPoolExecutor(max_workers=1) if supplied_executor else None lock = AsyncReadWriteLock(lock_file, is_singleton=False, executor=executor) if held: await lock.acquire_write() await lock.close() with pytest.raises(sqlite3.ProgrammingError, match="Cannot operate on a closed database"): await lock.acquire_read() if executor is not None: executor.shutdown(wait=False) def test_properties(lock_file: str) -> None: executor = ThreadPoolExecutor(max_workers=1) lock = AsyncReadWriteLock(lock_file, timeout=5.0, blocking=False, is_singleton=False, executor=executor) assert lock.lock_file == lock_file assert lock.timeout == pytest.approx(5.0) assert lock.blocking is False assert lock.loop is None assert lock.executor is executor executor.shutdown(wait=False) @pytest.mark.asyncio async def test_custom_executor(lock_file: str) -> None: executor = ThreadPoolExecutor(max_workers=1) lock = AsyncReadWriteLock(lock_file, is_singleton=False, executor=executor) async with lock.read_lock(): assert_mode_held(lock_file, "read") with pytest.raises(RuntimeError, match="not held"): await lock.release() executor.shutdown(wait=False) @pytest.mark.asyncio async def test_write_release_lands_on_another_executor_thread(lock_file: str) -> None: executor = ThreadPoolExecutor(max_workers=2) lock = AsyncReadWriteLock(lock_file, is_singleton=False, executor=executor) await lock.acquire_write() started, gate = threading.Event(), threading.Event() def park() -> None: started.set() gate.wait(_TASK_WAIT) # The pool's only thread ran the acquire; parking it there forces the release onto a second worker. parked = asyncio.get_running_loop().run_in_executor(executor, park) await asyncio.to_thread(started.wait, _TASK_WAIT) try: await lock.release() finally: gate.set() await parked async with lock.write_lock(timeout=_TASK_WAIT): pass await lock.close() executor.shutdown() @pytest.mark.asyncio async def test_close_shuts_down_owned_executor(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) executor = lock.executor await lock.close() await lock.close() with pytest.raises(RuntimeError): executor.submit(int) @pytest.mark.asyncio async def test_close_keeps_provided_executor_open(lock_file: str) -> None: executor = ThreadPoolExecutor(max_workers=1) lock = AsyncReadWriteLock(lock_file, is_singleton=False, executor=executor) await lock.close() assert executor.submit(int).result(timeout=5) == 0 executor.shutdown(wait=False) @NEEDS_COLLECTED_FINALIZATION def test_del_shuts_down_owned_executor(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) executor = lock.executor del lock gc.collect() with pytest.raises(RuntimeError): executor.submit(int) def test_del_keeps_provided_executor_open(lock_file: str) -> None: executor = ThreadPoolExecutor(max_workers=1) lock = AsyncReadWriteLock(lock_file, is_singleton=False, executor=executor) del lock gc.collect() assert executor.submit(int).result(timeout=5) == 0 executor.shutdown(wait=False) @pytest.mark.asyncio async def test_acquire_return_proxy_context_manager(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) async with await lock.acquire_read() as ctx: assert ctx is lock assert_mode_held(lock_file, "read") with pytest.raises(RuntimeError, match="not held"): await lock.release() await lock.close() @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio async def test_nested_context_managers(lock_file: str, mode: Literal["read", "write"]) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) make_ctx = lock.read_lock if mode == "read" else lock.write_lock async with make_ctx(): assert_mode_held(lock_file, mode) async with make_ctx(): assert_mode_held(lock_file, mode) assert_mode_held(lock_file, mode) with pytest.raises(RuntimeError, match="not held"): await lock.release() await lock.close() @pytest.mark.asyncio async def test_context_manager_uses_instance_defaults(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, timeout=3.0, blocking=True, is_singleton=False) async with lock.read_lock(): assert_mode_held(lock_file, "read") async with lock.write_lock(): assert_mode_held(lock_file, "write") await lock.close() @pytest.mark.asyncio async def test_context_manager_overrides_defaults(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, timeout=10.0, blocking=False, is_singleton=False) async with lock.read_lock(timeout=5.0, blocking=True): assert_mode_held(lock_file, "read") async with lock.write_lock(timeout=5.0, blocking=True): assert_mode_held(lock_file, "write") await lock.close() @pytest.mark.asyncio async def test_sequential_mode_switch(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) async with lock.read_lock(): pass async with lock.write_lock(): pass async with lock.read_lock(): pass await lock.close() @pytest.mark.parametrize("workers", [pytest.param(None, id="owned-executor"), pytest.param(4, id="four-workers")]) @pytest.mark.asyncio async def test_tasks_sharing_a_lock_take_the_write_lock_one_at_a_time(lock_file: str, workers: int | None) -> None: executor = None if workers is None else ThreadPoolExecutor(max_workers=workers) lock = AsyncReadWriteLock(lock_file, is_singleton=False, executor=executor) inside = peak = 0 async def write() -> None: nonlocal inside, peak async with lock.write_lock(timeout=_TASK_WAIT): inside += 1 peak = max(peak, inside) await asyncio.sleep(0.001) inside -= 1 await asyncio.gather(*(write() for _ in range(20))) await lock.close() if executor is not None: executor.shutdown() assert peak == 1 @pytest.mark.asyncio async def test_tasks_sharing_a_lock_hold_the_read_lock_together(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) readers = 0 both_inside = asyncio.Event() async def read() -> None: nonlocal readers async with lock.read_lock(timeout=_TASK_WAIT): readers += 1 if readers == 2: both_inside.set() await asyncio.wait_for(both_inside.wait(), _TASK_WAIT) await asyncio.gather(read(), read()) await lock.close() assert readers == 2 @pytest.mark.asyncio async def test_another_task_waits_for_the_last_release_of_a_nested_write(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) await lock.acquire_write() await lock.acquire_write() await lock.release() entered_while_nested = await asyncio.create_task(_try_write(lock)) await lock.release() entered_after_release = await asyncio.create_task(_try_write(lock)) await lock.close() assert (entered_while_nested, entered_after_release) == (False, True) @pytest.mark.asyncio async def test_force_release_drops_the_callers_whole_nest(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) await lock.acquire_write() await lock.acquire_write() await lock.release(force=True) entered = await asyncio.create_task(_try_write(lock)) await lock.close() assert entered async def _try_write(lock: AsyncReadWriteLock) -> bool: try: await lock.acquire_write(blocking=False) except Timeout: return False await lock.release() return True @pytest.mark.asyncio async def test_release_from_a_task_that_does_not_hold_raises(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) await lock.acquire_write() with pytest.raises(RuntimeError, match="not held by this task"): await asyncio.create_task(lock.release()) await lock.release() await lock.close() @pytest.mark.asyncio async def test_a_waiting_writer_goes_ahead_of_new_readers(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) await lock.acquire_read() writer = asyncio.create_task(_write_once(lock)) await asyncio.sleep(0) # the writer registers as waiting before its first suspension with pytest.raises(Timeout): await asyncio.create_task(lock.acquire_read(blocking=False)) await lock.release() await asyncio.wait_for(writer, _TASK_WAIT) await lock.close() @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_a_canceled_waiting_writer_lets_readers_in(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) await lock.acquire_read() writer = asyncio.create_task(_write_once(lock)) await asyncio.sleep(0) writer.cancel() with pytest.raises(asyncio.CancelledError): await writer await asyncio.create_task(_read_without_waiting(lock)) await lock.release() await lock.close() @pytest.mark.asyncio async def test_a_writer_times_out_waiting_for_another_task(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) await lock.acquire_write() with pytest.raises(Timeout): await asyncio.create_task(lock.acquire_write(timeout=0.05)) await lock.release() await lock.close() async def _write_once(lock: AsyncReadWriteLock) -> None: async with lock.write_lock(timeout=_TASK_WAIT): pass async def _read_without_waiting(lock: AsyncReadWriteLock) -> None: async with lock.read_lock(blocking=False): pass @pytest.mark.asyncio async def test_acquire_outside_a_task_raises(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) loop = asyncio.get_running_loop() outcome: asyncio.Future[BaseException] = loop.create_future() def drive_outside_any_task() -> None: try: lock.acquire_write().send(None) except RuntimeError as error: outcome.set_result(error) loop.call_soon(drive_outside_any_task) error = await asyncio.wait_for(outcome, _TASK_WAIT) await lock.close() assert "inside an asyncio task" in str(error) def test_tasks_on_different_event_loops_exclude_each_other(lock_file: str) -> None: guard = threading.Lock() inside = peak = 0 async def write_repeatedly() -> None: nonlocal inside, peak lock = AsyncReadWriteLock(lock_file) for _ in range(20): async with lock.write_lock(timeout=_TASK_WAIT): with guard: inside += 1 peak = max(peak, inside) await asyncio.sleep(0.001) with guard: inside -= 1 threads = [threading.Thread(target=asyncio.run, args=(write_repeatedly(),), daemon=True) for _ in range(2)] for thread in threads: thread.start() for thread in threads: thread.join(_TASK_WAIT) assert ([thread.is_alive() for thread in threads], peak) == ([False, False], 1) def assert_mode_held(lock_file: str, mode: Literal["read", "write"]) -> None: contender = ReadWriteLock(lock_file, is_singleton=False) acquire = contender.acquire_write if mode == "read" else contender.acquire_read with pytest.raises(Timeout): acquire(blocking=False) contender.close() @pytest.mark.asyncio @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.parametrize("shared", [pytest.param(False, id="sqlite"), pytest.param(True, id="task-owner")]) @ACQUIRE_SETTINGS async def test_async_acquire_respects_settings( async_locks: tuple[AsyncReadWriteLock, AsyncReadWriteLock], mode: Literal["read", "write"], call_timeout: float | None, minimum: float, *, call_blocking: bool | None, omitted: bool, ) -> None: contender: Final[AsyncReadWriteLock] = async_locks[1] acquire: Final = contender.acquire_read if mode == "read" else contender.acquire_write async with async_locks[0].write_lock(): started: Final[float] = time.perf_counter() with pytest.raises(Timeout): await asyncio.create_task(acquire() if omitted else acquire(timeout=call_timeout, blocking=call_blocking)) assert minimum <= time.perf_counter() - started < 5 @pytest.mark.asyncio @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @INVALID_TIMEOUTS @pytest.mark.parametrize("configured", [pytest.param(True, id="instance"), pytest.param(False, id="call")]) @pytest.mark.parametrize("contended", [pytest.param(True, id="contended"), pytest.param(False, id="free")]) async def test_async_acquire_rejects_invalid_timeout( lock_file: str, mode: Literal["read", "write"], timeout: float, message: str, *, configured: bool, contended: bool ) -> None: lock: Final = AsyncReadWriteLock(lock_file, timeout=timeout if configured else -1, is_singleton=False) acquire: Final = lock.acquire_read if mode == "read" else lock.acquire_write try: async with AsyncExitStack() as stack: if contended: await stack.enter_async_context(lock.write_lock(timeout=0)) with pytest.raises(ValueError, match=f"^{re.escape(message)}$"): await asyncio.create_task(acquire() if configured else acquire(timeout=timeout)) finally: await lock.close() @pytest.mark.asyncio @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.parametrize( "timeout", [pytest.param(-2, id="integer"), pytest.param(float("inf"), id="infinite"), pytest.param(float("nan"), id="nan")], ) @pytest.mark.parametrize("configured", [pytest.param(True, id="instance"), pytest.param(False, id="call")]) async def test_async_nonblocking_ignores_invalid_timeout( lock_file: str, mode: Literal["read", "write"], timeout: float, *, configured: bool ) -> None: lock: Final = AsyncReadWriteLock(lock_file, timeout=timeout, blocking=not configured, is_singleton=False) acquire: Final = lock.acquire_read if mode == "read" else lock.acquire_write try: async with await (acquire() if configured else acquire(timeout=timeout, blocking=False)): with pytest.raises(Timeout): await asyncio.create_task(lock.acquire_write(timeout=timeout, blocking=False)) finally: await lock.close() @pytest.mark.asyncio @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) async def test_async_acquire_accepts_infinite_timeout(lock_file: str, mode: Literal["read", "write"]) -> None: lock: Final = AsyncReadWriteLock(lock_file, is_singleton=False) acquire: Final = lock.acquire_read if mode == "read" else lock.acquire_write try: async with await acquire(timeout=float("inf")): with pytest.raises(Timeout): await asyncio.create_task(lock.acquire_write(blocking=False)) finally: await lock.close() @pytest_asyncio.fixture(loop_scope="function") # Join shutdown workers before later fork tests. async def async_locks( tmp_path: Path, instance_timeout: float, *, instance_blocking: bool, shared: bool ) -> AsyncIterator[tuple[AsyncReadWriteLock, AsyncReadWriteLock]]: holder: Final[AsyncReadWriteLock] = AsyncReadWriteLock( tmp_path / "a", timeout=instance_timeout, blocking=instance_blocking, is_singleton=False ) contender: Final[AsyncReadWriteLock] = ( holder if shared else AsyncReadWriteLock( tmp_path / "a", timeout=instance_timeout, blocking=instance_blocking, is_singleton=False ) ) try: yield holder, contender finally: await holder.close() await contender.close() @pytest.mark.asyncio async def test_async_read_after_a_sync_close_of_the_shared_singleton_raises(lock_file: str) -> None: lock: Final = AsyncReadWriteLock(lock_file) await lock.acquire_read() ReadWriteLock(lock_file).close() with pytest.raises(sqlite3.ProgrammingError, match="closed database"): await asyncio.create_task(lock.acquire_read()) with pytest.raises(RuntimeError, match="not held by this task"): await lock.release() await lock.close() @pytest.mark.parametrize("sync_end", [pytest.param("release", id="release"), pytest.param("close", id="close")]) @pytest.mark.asyncio async def test_async_close_after_a_sync_end_of_the_shared_singleton_returns(lock_file: str, sync_end: str) -> None: lock: Final = AsyncReadWriteLock(lock_file) await lock.acquire_read() sync: Final = ReadWriteLock(lock_file) sync.close() if sync_end == "close" else sync.release(force=True) await lock.close() assert _writer_gets_in(lock_file) @pytest.mark.asyncio async def test_async_release_after_a_sync_forced_release_raises_not_held_by_this_task(lock_file: str) -> None: lock: Final = AsyncReadWriteLock(lock_file) await lock.acquire_read() ReadWriteLock(lock_file).release(force=True) with pytest.raises(RuntimeError, match="not held by this task"): await lock.release() await lock.close() @pytest.mark.asyncio async def test_async_read_after_a_sync_forced_release_takes_the_lock_again(lock_file: str) -> None: lock: Final = AsyncReadWriteLock(lock_file) await lock.acquire_read() ReadWriteLock(lock_file).release(force=True) writer_got_in = await asyncio.create_task(_read_and_probe(lock)) assert not writer_got_in await lock.close() @pytest.mark.parametrize("sync_end", [pytest.param("release", id="release"), pytest.param("close", id="close")]) @pytest.mark.asyncio async def test_async_read_after_an_interrupted_sync_forced_release_takes_the_lock_again( lock_file: str, mocker: MockerFixture, sync_end: str ) -> None: lock: Final = AsyncReadWriteLock(lock_file) await lock.acquire_read() mocker.patch.object( ReadWriteLock, "_clear_lock_state", new=interrupt_once(ReadWriteLock._clear_lock_state, before=True) ) sync: Final = ReadWriteLock(lock_file) with pytest.raises(Interrupt): sync.close() if sync_end == "close" else sync.release(force=True) writer_got_in = await asyncio.create_task(_read_and_probe(lock)) assert not writer_got_in await lock.close() @pytest.mark.asyncio async def test_async_read_treats_a_level_whose_connection_closed_meanwhile_as_gone( lock_file: str, mocker: MockerFixture ) -> None: lock: Final = AsyncReadWriteLock(lock_file) await lock.acquire_read() real_ended: Final = ReadWriteLock._transaction_ended closed = False def close_between_checks(instance: ReadWriteLock) -> bool: nonlocal closed if not closed: closed = True raise sqlite3.ProgrammingError return real_ended(instance) mocker.patch.object(ReadWriteLock, "_transaction_ended", autospec=True, side_effect=close_between_checks) writer_got_in = await asyncio.create_task(_read_and_probe(lock)) assert not writer_got_in with pytest.raises(RuntimeError, match="not held by this task"): await lock.release() await lock.close() @pytest.mark.parametrize("is_singleton", [pytest.param(True, id="singleton"), pytest.param(False, id="owned")]) @pytest.mark.asyncio async def test_async_read_arriving_during_close_is_refused(lock_file: str, is_singleton: bool) -> None: lock: Final = AsyncReadWriteLock(lock_file, is_singleton=is_singleton) await lock.acquire_read() arriving = asyncio.create_task(_read_and_probe(lock)) # first runs while close() waits for its backend call await lock.close() with pytest.raises(sqlite3.ProgrammingError, match="closed database"): await arriving @pytest.mark.asyncio async def test_async_read_through_another_wrapper_during_close_holds_the_lock(lock_file: str) -> None: closing, other = AsyncReadWriteLock(lock_file), AsyncReadWriteLock(lock_file) await closing.acquire_read() arriving = asyncio.create_task(_read_and_probe(other)) await closing.close() assert not await arriving await other.close() @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio async def test_async_close_keeps_the_hold_taken_through_another_wrapper( lock_file: str, mode: Literal["read", "write"] ) -> None: holder, closing = AsyncReadWriteLock(lock_file), AsyncReadWriteLock(lock_file) await (holder.acquire_read() if mode == "read" else holder.acquire_write()) await asyncio.create_task(closing.close()) assert not _writer_gets_in(lock_file) await holder.release() assert _writer_gets_in(lock_file) await holder.close() @pytest.mark.asyncio async def test_async_close_of_the_entering_wrapper_keeps_a_level_taken_through_another(lock_file: str) -> None: entering, nested = AsyncReadWriteLock(lock_file), AsyncReadWriteLock(lock_file) await entering.acquire_read() await nested.acquire_read() await entering.close() assert not _writer_gets_in(lock_file) await nested.release() assert _writer_gets_in(lock_file) await nested.close() @pytest.mark.asyncio async def test_async_close_of_the_nested_wrapper_ends_only_its_own_level(lock_file: str) -> None: entering, nested = AsyncReadWriteLock(lock_file), AsyncReadWriteLock(lock_file) await entering.acquire_read() await nested.acquire_read() await nested.close() await entering.release() assert _writer_gets_in(lock_file) await entering.close() @pytest.mark.asyncio async def test_async_release_through_each_wrapper_ends_the_hold_after_the_last(lock_file: str) -> None: entering, nested = AsyncReadWriteLock(lock_file), AsyncReadWriteLock(lock_file) await entering.acquire_read() await nested.acquire_read() await nested.release() assert not _writer_gets_in(lock_file) await entering.release() assert _writer_gets_in(lock_file) await entering.close() await nested.close() @pytest.mark.asyncio async def test_async_forced_release_through_a_wrapper_without_a_level_releases_nothing(lock_file: str) -> None: holder, other = AsyncReadWriteLock(lock_file), AsyncReadWriteLock(lock_file) await holder.acquire_read() await other.release(force=True) assert not _writer_gets_in(lock_file) await holder.release() assert _writer_gets_in(lock_file) await holder.close() await other.close() @pytest.mark.asyncio async def test_async_release_through_a_wrapper_without_a_level_raises(lock_file: str) -> None: holder, other = AsyncReadWriteLock(lock_file), AsyncReadWriteLock(lock_file) await holder.acquire_read() with pytest.raises(RuntimeError, match="through this wrapper"): await other.release() assert not _writer_gets_in(lock_file) await holder.release() await holder.close() await other.close() @pytest.mark.asyncio async def test_async_close_keeps_a_sync_reader_of_the_shared_singleton(lock_file: str) -> None: lock: Final = AsyncReadWriteLock(lock_file) with ReadWriteLock(lock_file).read_lock(): async with lock.read_lock(): pass await lock.close() assert not _writer_gets_in(lock_file) @pytest.mark.asyncio async def test_async_close_of_a_singleton_wrapper_ends_the_holds_taken_through_it(lock_file: str) -> None: lock: Final = AsyncReadWriteLock(lock_file) await lock.acquire_write() await lock.close() assert _writer_gets_in(lock_file) with pytest.raises(sqlite3.ProgrammingError, match="closed database"): await lock.acquire_read() @pytest.mark.asyncio async def test_async_close_waits_out_an_acquire_in_flight_even_when_canceled(lock_file: str) -> None: holder: Final = ReadWriteLock(lock_file, is_singleton=False) holder.acquire_write() lock: Final = AsyncReadWriteLock(lock_file, is_singleton=False) acquiring = asyncio.create_task(lock.acquire_write(timeout=_TASK_WAIT)) await asyncio.sleep(0) # the acquire is now waiting in the backend closing = asyncio.create_task(lock.close()) await asyncio.sleep(0) closing.cancel() await asyncio.sleep(0) holder.release() await acquiring with pytest.raises(asyncio.CancelledError): await closing assert _writer_gets_in(lock_file) holder.close() @pytest.mark.asyncio async def test_async_close_that_fails_before_any_acquire_leaves_the_wrapper_closed( lock_file: str, mocker: MockerFixture ) -> None: lock: Final = AsyncReadWriteLock(lock_file, is_singleton=False) mocker.patch.object(ReadWriteLock, "close", autospec=True, side_effect=sqlite3.OperationalError("close failed")) with pytest.raises(sqlite3.OperationalError, match="close failed"): await lock.close() with pytest.raises(sqlite3.ProgrammingError, match="closed database"): await lock.acquire_read() def _writer_gets_in(lock_file: str) -> bool: contender: Final = ReadWriteLock(lock_file, is_singleton=False) try: contender.acquire_write(blocking=False) except Timeout: return False finally: contender.close() return True async def _read_and_probe(lock: AsyncReadWriteLock) -> bool: async with lock.read_lock(timeout=_TASK_WAIT): return _writer_gets_in(lock.lock_file) @pytest.mark.asyncio async def test_async_reads_giving_up_behind_a_writer_leave_nothing_behind(lock_file: str) -> None: lock: Final = AsyncReadWriteLock(lock_file, is_singleton=False) await lock.acquire_write() await asyncio.create_task(_give_up_reading(lock)) before: Final = _live_asyncio_futures() give_ups: Final = 200 for _ in range(give_ups): await asyncio.create_task(_give_up_reading(lock)) # The interpreter and the test session keep a handful of futures that come and go. A leak keeps one per give-up, # so it shows as growth near give_ups, far past that noise. assert _live_asyncio_futures() - before < give_ups // 4 await lock.release() await lock.close() async def _give_up_reading(lock: AsyncReadWriteLock) -> None: with pytest.raises(Timeout): await lock.acquire_read(timeout=0.001) def _live_asyncio_futures() -> int: gc.collect() return sum(issubclass(type(candidate), asyncio.Future) for candidate in gc.get_objects()) filelock-4.2.8/tests/test_async_read_write_cancel_wait.py0000644000000000000000000001256515272561277020720 0ustar00from __future__ import annotations import asyncio import threading import time from typing import TYPE_CHECKING, Final, Literal import pytest pytest.importorskip("sqlite3") from collections.abc import Callable from filelock import AsyncReadWriteLock, ReadWriteLock, Timeout from filelock._soft_rw import AsyncSoftReadWriteLock, SoftReadWriteLock from tests.capability_marks import XFAIL_WITHOUT_COROUTINE_CANCELLATION if TYPE_CHECKING: from collections.abc import Generator from pathlib import Path from pytest_mock import MockerFixture # The other process's hold ends this long after the wait starts, so a wait that ignores its deadline ends then. _HOLD: Final[float] = 3 # The caller's own deadline, far below _HOLD. _DEADLINE: Final[float] = 0.2 pytestmark = XFAIL_WITHOUT_COROUTINE_CANCELLATION @pytest.fixture(autouse=True) def _clear_singleton_caches() -> Generator[None]: caches: Final = (ReadWriteLock._instances, SoftReadWriteLock._instances) for cache in caches: cache.clear() yield # One loop for both caches, so a platform where only one kind of lock runs still closes through it. for lock in filter(None, [ref() for cache in caches for ref in list(cache.valuerefs())]): lock.close() for cache in caches: cache.clear() def _sqlite(path: Path, *, is_singleton: bool) -> tuple[ReadWriteLock, AsyncReadWriteLock]: return ReadWriteLock(path / "lock.db", is_singleton=is_singleton), AsyncReadWriteLock(path / "lock.db") def _soft( # pragma: needs hard-link path: Path, *, is_singleton: bool ) -> tuple[SoftReadWriteLock, AsyncSoftReadWriteLock]: return ( SoftReadWriteLock( path / "lock", is_singleton=is_singleton, heartbeat_interval=0.1, stale_threshold=0.5, poll_interval=0.02 ), AsyncSoftReadWriteLock(path / "lock", heartbeat_interval=0.1, stale_threshold=0.5, poll_interval=0.02), ) _Make = Callable[..., tuple[ReadWriteLock | SoftReadWriteLock, AsyncReadWriteLock | AsyncSoftReadWriteLock]] _FAMILIES: Final = pytest.mark.parametrize( "make", [pytest.param(_sqlite, id="sqlite"), pytest.param(_soft, id="soft", marks=pytest.mark.requires_hard_links)] ) _MODES: Final = pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) async def _acquire_within_deadline( lock: AsyncReadWriteLock | AsyncSoftReadWriteLock, mode: Literal["read", "write"], timeout: float ) -> float: start = time.perf_counter() with pytest.raises(TimeoutError) as info: async with asyncio.timeout(_DEADLINE): await (lock.acquire_read if mode == "read" else lock.acquire_write)(timeout) assert not isinstance(info.value, Timeout) return time.perf_counter() - start @_FAMILIES @_MODES @pytest.mark.parametrize( "timeout", [pytest.param(-1, id="no-lock-timeout"), pytest.param(_HOLD * 2, id="lock-timeout")] ) @pytest.mark.asyncio async def test_async_acquire_behind_another_process_honors_the_callers_deadline( tmp_path: Path, make: _Make, mode: Literal["read", "write"], timeout: float ) -> None: holder, lock = make(tmp_path, is_singleton=False) holder.acquire_write() ending = asyncio.get_running_loop().call_later(_HOLD, holder.release) elapsed = await _acquire_within_deadline(lock, mode, timeout) assert elapsed < _HOLD ending.cancel() holder.release() await (lock.acquire_read if mode == "read" else lock.acquire_write)(_HOLD * 2) await lock.release() await lock.close() @_FAMILIES @pytest.mark.asyncio async def test_async_acquire_behind_a_sync_acquire_of_the_same_instance_honors_the_callers_deadline( tmp_path: Path, make: _Make ) -> None: holder, lock = make(tmp_path, is_singleton=False) shared, _ = make(tmp_path, is_singleton=True) holder.acquire_write() # A thread waiting in the shared instance's acquire keeps that instance busy for the task too. def write_once() -> None: with shared.write_lock(timeout=_HOLD * 2): pass waiting = threading.Thread(target=write_once) waiting.start() ending = asyncio.get_running_loop().call_later(_HOLD, holder.release) elapsed = await _acquire_within_deadline(lock, "write", -1) assert elapsed < _HOLD ending.cancel() holder.release() await asyncio.to_thread(waiting.join, _HOLD * 2) await lock.acquire_write(timeout=_HOLD) await lock.release() await lock.close() @_FAMILIES @pytest.mark.asyncio async def test_async_acquire_behind_a_sync_acquire_of_the_same_instance_times_out_at_its_own_deadline( tmp_path: Path, make: _Make, mocker: MockerFixture ) -> None: # With one thread alive the wait is not sliced, so the thread lock is polled across several intervals. mocker.patch.object(threading, "active_count", return_value=1) holder, lock = make(tmp_path, is_singleton=False) shared, _ = make(tmp_path, is_singleton=True) holder.acquire_write() def write_once() -> None: with shared.write_lock(timeout=_HOLD * 2): pass waiting = threading.Thread(target=write_once) waiting.start() ending = asyncio.get_running_loop().call_later(_HOLD, holder.release) start = time.perf_counter() with pytest.raises(Timeout): await lock.acquire_write(_DEADLINE) assert time.perf_counter() - start < _HOLD ending.cancel() holder.release() await asyncio.to_thread(waiting.join, _HOLD * 2) await lock.close() filelock-4.2.8/tests/test_async_read_write_cancellation.py0000644000000000000000000005633315272561277021104 0ustar00from __future__ import annotations import asyncio import functools import multiprocessing import sqlite3 import threading from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING, Final, Literal, cast import pytest from filelock import AsyncReadWriteLock, ReadWriteLock from tests.async_filelock_cancellation_helpers import assert_cancellation_message from tests.capability_marks import XFAIL_WITHOUT_COROUTINE_CANCELLATION from tests.read_write_helpers import assert_read_write_lock_state if TYPE_CHECKING: from collections.abc import Callable from multiprocessing.sharedctypes import Synchronized from pathlib import Path from pytest_mock import MockerFixture # Three probes can each spend 25 seconds on startup, acquisition and shutdown, before executor and GC cleanup. pytestmark = pytest.mark.timeout(120) @pytest.fixture def lock_file(tmp_path: Path) -> str: return str(tmp_path / "test_lock.db") @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio async def test_acquire_cancellation_before_executor_start_rolls_back( lock_file: str, mocker: MockerFixture, mode: Literal["read", "write"] ) -> None: executor_started = threading.Event() release_executor = threading.Event() rollback_started = asyncio.Event() finish_rollback = threading.Event() loop = asyncio.get_running_loop() _patch_async_rollback(mocker, loop, rollback_started, finish_rollback) with ThreadPoolExecutor(max_workers=1) as executor: blocker = executor.submit(_block_executor, executor_started, release_executor) assert executor_started.wait(timeout=5) lock = AsyncReadWriteLock(lock_file, is_singleton=False, executor=executor) acquire = lock.acquire_read if mode == "read" else lock.acquire_write task = asyncio.create_task(acquire()) await asyncio.sleep(0) task.cancel("first cancellation") release_executor.set() await rollback_started.wait() try: assert_read_write_lock_state(lock_file, "write" if mode == "read" else "read", available=False) task.cancel("second cancellation") finally: finish_rollback.set() with pytest.raises(asyncio.CancelledError) as info: await task blocker.result(timeout=5) assert_cancellation_message(info.value, "first cancellation") assert_read_write_lock_state(lock_file, "write" if mode == "read" else "read", available=True) with pytest.raises(RuntimeError, match="not held"): await lock.release() await lock.close() @pytest.mark.parametrize( "cancel_caller", [pytest.param(False, id="executor"), pytest.param(True, id="caller-and-executor")], ) @XFAIL_WITHOUT_COROUTINE_CANCELLATION def test_executor_cancels_queued_acquire_without_compensation(lock_file: str, *, cancel_caller: bool) -> None: context = multiprocessing.get_context("spawn") canceled = context.Value("b", False) process = context.Process(target=_cancel_queued_read_write_acquire, args=(lock_file, cancel_caller, canceled)) process.start() try: process.join(timeout=10) assert (process.exitcode, canceled.value) == (0, True) finally: if process.is_alive(): # pragma: no cover - cleanup for a hung child after the assertion fails process.terminate() process.join(timeout=5) process.close() @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_caller_cancellation_preserves_cancelled_executor_acquire(lock_file: str) -> None: executor_started = threading.Event() release_executor = threading.Event() executor = ThreadPoolExecutor(max_workers=1) blocker = executor.submit(_block_executor, executor_started, release_executor) assert executor_started.wait(timeout=5) lock = AsyncReadWriteLock(lock_file, executor=executor) try: task = asyncio.create_task(lock.acquire_write()) await asyncio.sleep(0) task.cancel("caller canceled") await asyncio.sleep(0) executor.shutdown(wait=False, cancel_futures=True) release_executor.set() blocker.result(timeout=5) with pytest.raises(asyncio.CancelledError) as info: await task assert isinstance(info.value.__context__, asyncio.CancelledError) assert_cancellation_message(info.value.__context__, "caller canceled") contender = ReadWriteLock(lock_file, is_singleton=False) try: with contender.write_lock(blocking=False): pass finally: contender.close() finally: release_executor.set() executor.shutdown(wait=True, cancel_futures=True) ReadWriteLock(lock_file).close() @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_surfaces_compensation_failure(lock_file: str, mocker: MockerFixture) -> None: executor_started = threading.Event() release_executor = threading.Event() rollback_started = asyncio.Event() finish_rollback = threading.Event() rollback_error = _patch_async_rollback_failure( mocker, asyncio.get_running_loop(), rollback_started, finish_rollback ) with ThreadPoolExecutor(max_workers=1) as executor: blocker = executor.submit(_block_executor, executor_started, release_executor) assert executor_started.wait(timeout=5) lock = AsyncReadWriteLock(lock_file, is_singleton=False, executor=executor) task = asyncio.create_task(lock.acquire_write()) await asyncio.sleep(0) task.cancel("cancel acquire") release_executor.set() await rollback_started.wait() finish_rollback.set() with pytest.raises(sqlite3.OperationalError, match="rollback failed") as info: await task blocker.result(timeout=5) assert info.value is rollback_error cancellation = rollback_error.__context__ assert isinstance(cancellation, asyncio.CancelledError) assert cancellation.args == ("cancel acquire",) assert_read_write_lock_state(lock_file, "read", available=False) await lock.close() assert_read_write_lock_state(lock_file, "read", available=True) @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio async def test_acquire_cancellation_while_sqlite_waits_rolls_back( lock_file: str, mocker: MockerFixture, mode: Literal["read", "write"] ) -> None: holder: Final = ReadWriteLock(lock_file, is_singleton=False) try: (holder.acquire_write if mode == "read" else holder.acquire_read)(blocking=False) execute_started: Final = asyncio.Event() _patch_async_execute_signal(mocker, asyncio.get_running_loop(), execute_started) lock: Final = AsyncReadWriteLock(lock_file, is_singleton=False) task: Final = asyncio.create_task((lock.acquire_read if mode == "read" else lock.acquire_write)()) await execute_started.wait() task.cancel("cancel blocked acquire") holder.release() with pytest.raises(asyncio.CancelledError) as info: await task assert_cancellation_message(info.value, "cancel blocked acquire") assert_read_write_lock_state(lock_file, "write" if mode == "read" else "read", available=True) with pytest.raises(RuntimeError, match="not held"): await lock.release() await lock.close() finally: holder.close() @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_surfaces_acquire_and_rollback_errors( lock_file: str, mocker: MockerFixture, mode: Literal["read", "write"] ) -> None: acquire_started = asyncio.Event() finish_acquire = threading.Event() loop = asyncio.get_running_loop() acquire_error, rollback_error, prior_rollback_error = _patch_async_acquire_and_rollback_failure( mocker, loop, acquire_started, finish_acquire, mode ) lock = AsyncReadWriteLock(lock_file, is_singleton=False) task = asyncio.create_task((lock.acquire_read if mode == "read" else lock.acquire_write)()) await acquire_started.wait() task.cancel("cancel acquire") finish_acquire.set() with pytest.raises(sqlite3.OperationalError, match="rollback failed") as info: await task assert info.value is rollback_error cancellation = rollback_error.__context__ assert isinstance(cancellation, asyncio.CancelledError) assert (cancellation.args, cancellation.__context__, acquire_error.__context__) == ( ("cancel acquire",), acquire_error, prior_rollback_error, ) assert_read_write_lock_state(lock_file, "write" if mode == "read" else "read", available=False) await (lock.acquire_read if mode == "read" else lock.acquire_write)() assert_read_write_lock_state(lock_file, "write" if mode == "read" else "read", available=False) await lock.release() assert_read_write_lock_state(lock_file, "write" if mode == "read" else "read", available=True) await lock.close() @pytest.mark.parametrize("supplied", [pytest.param(False, id="owned"), pytest.param(True, id="supplied")]) @pytest.mark.asyncio async def test_close_cancellation_shuts_down_only_an_owned_executor( lock_file: str, mocker: MockerFixture, supplied: bool ) -> None: rollback_started = asyncio.Event() finish_rollback = threading.Event() _patch_async_rollback(mocker, asyncio.get_running_loop(), rollback_started, finish_rollback) lock = AsyncReadWriteLock( lock_file, is_singleton=False, executor=ThreadPoolExecutor(max_workers=1) if supplied else None ) await lock.acquire_write() executor = lock.executor task = asyncio.create_task(lock.close()) await rollback_started.wait() task.cancel("cancel close") finish_rollback.set() with pytest.raises(asyncio.CancelledError) as info: await task assert_cancellation_message(info.value, "cancel close") assert_read_write_lock_state(lock_file, "read", available=True) if supplied: assert executor.submit(int).result(timeout=5) == 0 executor.shutdown(wait=True) else: with pytest.raises(RuntimeError): executor.submit(int) @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_release_cancellation_hands_the_lock_to_the_next_task( lock_file: str, mocker: MockerFixture, mode: Literal["read", "write"] ) -> None: rollback_started = asyncio.Event() finish_rollback = threading.Event() _patch_async_rollback(mocker, asyncio.get_running_loop(), rollback_started, finish_rollback) lock = AsyncReadWriteLock(lock_file, is_singleton=False) async def acquire_then_release() -> None: await (lock.acquire_read if mode == "read" else lock.acquire_write)() await lock.release() task = asyncio.create_task(acquire_then_release()) await rollback_started.wait() task.cancel("cancel release") finish_rollback.set() with pytest.raises(asyncio.CancelledError) as info: await task assert_cancellation_message(info.value, "cancel release") await lock.acquire_write(timeout=2) assert_read_write_lock_state(lock_file, "read", available=False) await lock.release() await lock.close() @pytest.mark.parametrize("operation", [pytest.param("release", id="release"), pytest.param("close", id="close")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_cancellation_surfaces_rollback_error( lock_file: str, mocker: MockerFixture, operation: Literal["release", "close"] ) -> None: rollback_started = asyncio.Event() finish_rollback = threading.Event() rollback_error = _patch_async_rollback_failure( mocker, asyncio.get_running_loop(), rollback_started, finish_rollback ) lock = AsyncReadWriteLock(lock_file, is_singleton=False) executor = lock.executor async def acquire_then_finish() -> None: await lock.acquire_write() await (lock.release() if operation == "release" else lock.close()) task = asyncio.create_task(acquire_then_finish()) await rollback_started.wait() task.cancel(f"cancel {operation}") finish_rollback.set() with pytest.raises(sqlite3.OperationalError, match="rollback failed") as info: await task assert info.value is rollback_error cancellation = rollback_error.__context__ assert isinstance(cancellation, asyncio.CancelledError) assert cancellation.args == (f"cancel {operation}",) assert_read_write_lock_state(lock_file, "read", available=False) assert executor.submit(int).result(timeout=5) == 0 await lock.close() assert_read_write_lock_state(lock_file, "read", available=True) with pytest.raises(RuntimeError): executor.submit(int) @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_context_cancellation_preserves_body_and_rollback_contexts( lock_file: str, mocker: MockerFixture, mode: Literal["read", "write"] ) -> None: rollback_started = asyncio.Event() finish_rollback = threading.Event() body_error = ValueError("body failed") prior_error = LookupError("prior rollback failure") rollback_error = _patch_async_rollback_failure( mocker, asyncio.get_running_loop(), rollback_started, finish_rollback, prior_context=prior_error, ) lock = AsyncReadWriteLock(lock_file, is_singleton=False) async def fail_in_context() -> None: async with lock.read_lock() if mode == "read" else lock.write_lock(): raise body_error task = asyncio.create_task(fail_in_context()) await rollback_started.wait() task.cancel("cancel release") finish_rollback.set() with pytest.raises(sqlite3.OperationalError, match="rollback failed") as info: await task cancellation = rollback_error.__context__ assert isinstance(cancellation, asyncio.CancelledError) assert (info.value, cancellation.args, cancellation.__context__, prior_error.__context__) == ( rollback_error, ("cancel release",), prior_error, body_error, ) probe_mode: Literal["read", "write"] = "write" if mode == "read" else "read" assert_read_write_lock_state(lock_file, probe_mode, available=False) await lock.close() assert_read_write_lock_state(lock_file, probe_mode, available=True) @pytest.mark.asyncio async def test_read_retried_after_its_select_waited_starts_a_fresh_transaction( lock_file: str, mocker: MockerFixture ) -> None: busy_once = True seen_in_transaction: list[bool] = [] def executescript(real_connection: sqlite3.Connection, statement: str) -> sqlite3.Cursor: nonlocal busy_once if "SELECT name" in statement: if busy_once: # A writer can take the lock between the deferred BEGIN and the SELECT that needs the shared lock. busy_once = False real_connection.execute("BEGIN TRANSACTION") msg = "database is locked" raise sqlite3.OperationalError(msg) seen_in_transaction.append(real_connection.in_transaction) return real_connection.executescript(statement) _patch_async_connection(mocker, executescript=executescript) # With other threads alive the acquire waits in short slices that never reach the in-place retry under test. mocker.patch.object(threading, "active_count", return_value=1) lock = AsyncReadWriteLock(lock_file, is_singleton=False) async with lock.read_lock(): assert_read_write_lock_state(lock_file, "write", available=False) await lock.close() assert seen_in_transaction == [False] @pytest.mark.asyncio async def test_write_retried_after_its_begin_waited_is_acquired(lock_file: str, mocker: MockerFixture) -> None: busy_once = True def executescript(real_connection: sqlite3.Connection, statement: str) -> sqlite3.Cursor: nonlocal busy_once if "BEGIN EXCLUSIVE" in statement and busy_once: busy_once = False msg = "database is locked" raise sqlite3.OperationalError(msg) return real_connection.executescript(statement) _patch_async_connection(mocker, executescript=executescript) mocker.patch.object(threading, "active_count", return_value=1) lock = AsyncReadWriteLock(lock_file, is_singleton=False) async with lock.write_lock(): assert_read_write_lock_state(lock_file, "read", available=False) await lock.close() assert not busy_once def _patch_async_rollback( mocker: MockerFixture, loop: asyncio.AbstractEventLoop, rollback_started: asyncio.Event, finish_rollback: threading.Event, ) -> None: def rollback(real_connection: sqlite3.Connection) -> None: loop.call_soon_threadsafe(rollback_started.set) finish_rollback.wait() real_connection.rollback() _patch_async_connection(mocker, rollback=rollback) def _cancel_queued_read_write_acquire(lock_file: str, cancel_caller: bool, canceled: Synchronized[bool]) -> None: async def cancel_queued_acquire() -> None: executor_started = threading.Event() release_executor = threading.Event() executor = ThreadPoolExecutor(max_workers=1) blocker = executor.submit(_block_executor, executor_started, release_executor) assert executor_started.wait(timeout=5) lock = AsyncReadWriteLock(lock_file, is_singleton=False, executor=executor) task = asyncio.create_task(lock.acquire_write()) await asyncio.sleep(0) if cancel_caller: task.cancel("caller canceled") await asyncio.sleep(0) executor.shutdown(wait=False, cancel_futures=True) release_executor.set() blocker.result(timeout=5) try: await task except asyncio.CancelledError as error: context = error.__context__ if cancel_caller: canceled.value = isinstance(context, asyncio.CancelledError) and context.args == ("caller canceled",) else: canceled.value = True contender = ReadWriteLock(lock_file, is_singleton=False) with contender.write_lock(blocking=False): pass contender.close() asyncio.run(cancel_queued_acquire()) def _patch_async_execute_signal( mocker: MockerFixture, loop: asyncio.AbstractEventLoop, execute_started: asyncio.Event, ) -> None: def executescript(real_connection: sqlite3.Connection, statement: str) -> sqlite3.Cursor: if "PRAGMA journal_mode" in statement: loop.call_soon_threadsafe(execute_started.set) return real_connection.executescript(statement) _patch_async_connection(mocker, executescript=executescript) def _patch_async_rollback_failure( mocker: MockerFixture, loop: asyncio.AbstractEventLoop, rollback_started: asyncio.Event, finish_rollback: threading.Event, *, prior_context: BaseException | None = None, ) -> sqlite3.OperationalError: rollback_error = sqlite3.OperationalError("rollback failed") rollback_error.__context__ = prior_context rollback_failed = False def rollback(real_connection: sqlite3.Connection) -> None: nonlocal rollback_failed if rollback_failed: real_connection.rollback() return rollback_failed = True loop.call_soon_threadsafe(rollback_started.set) assert finish_rollback.wait(timeout=5) raise rollback_error _patch_async_connection(mocker, rollback=rollback) return rollback_error def _patch_async_acquire_and_rollback_failure( mocker: MockerFixture, loop: asyncio.AbstractEventLoop, acquire_started: asyncio.Event, finish_acquire: threading.Event, mode: Literal["read", "write"], ) -> tuple[sqlite3.OperationalError, sqlite3.OperationalError, LookupError]: acquire_error = sqlite3.OperationalError("acquire failed") rollback_error = sqlite3.OperationalError("rollback failed") prior_rollback_error = LookupError("prior rollback failure") rollback_error.__context__ = prior_rollback_error acquire_failed = False rollback_failed = False def executescript(real_connection: sqlite3.Connection, statement: str) -> sqlite3.Cursor: nonlocal acquire_failed cursor = real_connection.executescript(statement) target = "SELECT name" in statement if mode == "read" else "BEGIN EXCLUSIVE" in statement if target and not acquire_failed: acquire_failed = True loop.call_soon_threadsafe(acquire_started.set) assert finish_acquire.wait(timeout=5) cursor.close() raise acquire_error return cursor def rollback(real_connection: sqlite3.Connection) -> None: nonlocal rollback_failed if not rollback_failed: rollback_failed = True try: raise prior_rollback_error except LookupError: raise rollback_error # ruff:ignore[raise-without-from-inside-except] # exercise implicit backend context preservation real_connection.rollback() _patch_async_connection(mocker, executescript=executescript, rollback=rollback) return acquire_error, rollback_error, prior_rollback_error def _patch_async_connection( mocker: MockerFixture, *, executescript: Callable[[sqlite3.Connection, str], sqlite3.Cursor] | None = None, rollback: Callable[[sqlite3.Connection], None] | None = None, ) -> None: real_connect = sqlite3.connect connection_count = 0 def connect( database: str, *, factory: type[sqlite3.Connection], timeout: float, ) -> sqlite3.Connection: nonlocal connection_count real_connection = real_connect( database, check_same_thread=False, factory=factory, cached_statements=0, timeout=timeout, ) connection_count += 1 if connection_count < 2: return real_connection configuration: dict[str, Callable[[str], sqlite3.Cursor] | Callable[[], None]] = {} if executescript is not None: configuration["executescript.side_effect"] = functools.partial(executescript, real_connection) if rollback is not None: configuration["rollback.side_effect"] = functools.partial(rollback, real_connection) connection = mocker.MagicMock( spec_set=type(real_connection), wraps=real_connection, database_identity=None, **configuration, ) mocker.patch.object( type(connection), "in_transaction", new_callable=mocker.PropertyMock, create=True, side_effect=lambda: real_connection.in_transaction, ) return cast("sqlite3.Connection", connection) mocker.patch("filelock._read_write._connect", side_effect=connect) def _block_executor(executor_started: threading.Event, release_executor: threading.Event) -> None: executor_started.set() assert release_executor.wait(timeout=5) filelock-4.2.8/tests/test_async_read_write_worker_ownership.py0000644000000000000000000000723715272561277022056 0ustar00from __future__ import annotations import asyncio from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING, Final, Literal import pytest pytest.importorskip("sqlite3") from filelock import AsyncReadWriteLock, ReadWriteLock from filelock._soft_rw import AsyncSoftReadWriteLock, SoftReadWriteLock if TYPE_CHECKING: from collections.abc import Callable, Generator from pathlib import Path @pytest.fixture(autouse=True) def _clear_singleton_caches() -> Generator[None]: caches: Final = (ReadWriteLock._instances, SoftReadWriteLock._instances) for cache in caches: cache.clear() yield # One loop for both caches, so a platform where only one kind of lock runs still closes through it. for lock in filter(None, [ref() for cache in caches for ref in list(cache.valuerefs())]): lock.close() for cache in caches: cache.clear() @pytest.fixture def worker() -> Generator[ThreadPoolExecutor]: with ThreadPoolExecutor(max_workers=1) as executor: yield executor def _sqlite(path: Path, worker: ThreadPoolExecutor) -> tuple[AsyncReadWriteLock, ReadWriteLock]: return AsyncReadWriteLock(path / "lock.db", executor=worker), ReadWriteLock(path / "lock.db") def _soft( # pragma: needs hard-link path: Path, worker: ThreadPoolExecutor ) -> tuple[AsyncSoftReadWriteLock, SoftReadWriteLock]: return AsyncSoftReadWriteLock(path / "lock", executor=worker), SoftReadWriteLock(path / "lock") _FAMILIES: Final = pytest.mark.parametrize( "make", [pytest.param(_sqlite, id="sqlite"), pytest.param(_soft, id="soft", marks=pytest.mark.requires_hard_links)], ) @_FAMILIES @pytest.mark.asyncio async def test_async_write_hold_refuses_a_sync_writer_on_the_entering_worker( tmp_path: Path, worker: ThreadPoolExecutor, make: Callable[ [Path, ThreadPoolExecutor], tuple[AsyncReadWriteLock | AsyncSoftReadWriteLock, ReadWriteLock | SoftReadWriteLock], ], ) -> None: lock, sync = make(tmp_path, worker) await lock.acquire_write() with pytest.raises(RuntimeError, match="while it is held by thread"): await asyncio.get_running_loop().run_in_executor(worker, lambda: sync.acquire_write(blocking=False)) await lock.release() await lock.close() @_FAMILIES @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio async def test_async_hold_refuses_a_sync_release_on_the_entering_worker( tmp_path: Path, worker: ThreadPoolExecutor, make: Callable[ [Path, ThreadPoolExecutor], tuple[AsyncReadWriteLock | AsyncSoftReadWriteLock, ReadWriteLock | SoftReadWriteLock], ], mode: Literal["read", "write"], ) -> None: lock, sync = make(tmp_path, worker) await (lock.acquire_read() if mode == "read" else lock.acquire_write()) with pytest.raises(RuntimeError, match="Cannot release"): await asyncio.get_running_loop().run_in_executor(worker, sync.release) await lock.release() await lock.close() @_FAMILIES @pytest.mark.asyncio async def test_async_write_acquire_on_a_worker_holding_the_sync_write_lock_raises( tmp_path: Path, worker: ThreadPoolExecutor, make: Callable[ [Path, ThreadPoolExecutor], tuple[AsyncReadWriteLock | AsyncSoftReadWriteLock, ReadWriteLock | SoftReadWriteLock], ], ) -> None: lock, sync = make(tmp_path, worker) loop = asyncio.get_running_loop() await loop.run_in_executor(worker, sync.acquire_write) with pytest.raises(RuntimeError, match="while it is held by thread"): await lock.acquire_write(blocking=False) await loop.run_in_executor(worker, sync.release) await lock.close() filelock-4.2.8/tests/test_async_soft_contracts.py0000644000000000000000000000536615272561277017276 0ustar00from __future__ import annotations import asyncio import os import sys from contextlib import suppress from typing import TYPE_CHECKING import pytest from filelock import AsyncSoftFileLease, AsyncStrictSoftFileLock, LeaseCompromise, Timeout if TYPE_CHECKING: from pathlib import Path _DURATION: float = 0.9 _HEARTBEAT: float = 0.1 @pytest.fixture def marker(tmp_path: Path) -> Path: return tmp_path / "a.lock" @pytest.mark.requires_hard_links @pytest.mark.asyncio async def test_async_strict_treats_a_foreign_marker_as_contention(marker: Path) -> None: # pragma: needs hard-link await asyncio.to_thread(marker.write_text, "filelock/2\npid=999999\nhost=nowhere\nmode=strict\n", encoding="utf-8") lock = AsyncStrictSoftFileLock(str(marker), timeout=0.2) with pytest.raises(Timeout): await lock.acquire() assert await asyncio.to_thread(marker.exists) @pytest.mark.requires_hard_links @pytest.mark.asyncio async def test_async_strict_publishes_a_held_claim(marker: Path) -> None: # pragma: needs hard-link lock = AsyncStrictSoftFileLock(str(marker)) async with lock: held = lock.claims assert tuple(claim.state for claim in held) == ("held", "intent") assert held[0].pid == os.getpid() @pytest.mark.asyncio async def test_async_lease_heartbeat_keeps_a_live_claim(marker: Path) -> None: lease = AsyncSoftFileLease(str(marker), lease_duration=_DURATION, heartbeat_interval=_HEARTBEAT) async with lease: await asyncio.sleep(_DURATION * 1.5) # only a refreshing heartbeat keeps the claim past this assert lease.compromise is None assert lease.token is not None @pytest.mark.skipif( sys.platform == "win32", reason="Windows keeps an open marker undeletable, so no peer can take it from a live holder", ) @pytest.mark.asyncio async def test_async_lease_reports_compromise_when_the_marker_vanishes(marker: Path) -> None: # pragma: win32 no cover seen: list[LeaseCompromise] = [] reported = asyncio.Event() loop = asyncio.get_running_loop() def report(compromise: LeaseCompromise) -> None: # The heartbeat thread reports, so the event is set on the loop's own thread. seen.append(compromise) loop.call_soon_threadsafe(reported.set) lease = AsyncSoftFileLease( str(marker), lease_duration=_DURATION, heartbeat_interval=_HEARTBEAT, on_compromise=report ) async with lease: # pragma: win32 no cover await asyncio.to_thread(marker.unlink) # The first miss may be a peer's break in flight, so the report comes a refresh later; bounded, it fails below. with suppress(asyncio.TimeoutError): await asyncio.wait_for(reported.wait(), _DURATION * 20) assert [c.reason for c in seen] == ["marker-missing"] filelock-4.2.8/tests/test_async_transition_gate.py0000644000000000000000000001574215272561277017434 0ustar00from __future__ import annotations import asyncio import contextlib import time from concurrent.futures import Future as ConcurrentFuture from typing import TYPE_CHECKING, Final import pytest from filelock._async import _AsyncTransitionGate, _AsyncTransitionUnavailableError from tests.capability_marks import XFAIL_WITHOUT_COROUTINE_CANCELLATION if TYPE_CHECKING: from pytest_mock import MockerFixture @pytest.mark.asyncio async def test_hold_waits_for_a_predecessor_before_entering() -> None: gate = _AsyncTransitionGate() order: list[str] = [] first_holding = asyncio.Event() release_first = asyncio.Event() async def first() -> None: async with gate.hold(): first_holding.set() order.append("first-in") await release_first.wait() order.append("first-out") async def second() -> None: async with gate.hold(): order.append("second-in") first_task = asyncio.create_task(first()) await first_holding.wait() second_task = asyncio.create_task(second()) await asyncio.sleep(0.05) # second parks on the predecessor instead of entering assert order == ["first-in"] release_first.set() await asyncio.gather(first_task, second_task) assert order == ["first-in", "first-out", "second-in"] @pytest.mark.asyncio async def test_hold_for_acquire_deadline_preempts_long_poll() -> None: gate = _AsyncTransitionGate() async def wait_for_turn() -> None: async with contextlib.AsyncExitStack() as stack: await stack.enter_async_context( gate.hold_for_acquire( blocking=True, cancel_check=None, deadline=time.perf_counter() + 0.05, poll_interval=1 ) ) async with gate.hold(): with pytest.raises(_AsyncTransitionUnavailableError): await asyncio.wait_for(wait_for_turn(), timeout=0.2) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_hold_canceled_while_waiting_takes_its_ticket_out() -> None: gate = _AsyncTransitionGate() first_holding = asyncio.Event() release_first = asyncio.Event() async def first() -> None: async with gate.hold(): first_holding.set() await release_first.wait() async def second() -> None: async with gate.hold(): # never reached: canceled while waiting on first pass # pragma: no cover # the hold body never runs: the waiter is canceled while parked in hold() first_task = asyncio.create_task(first()) await first_holding.wait() second_task = asyncio.create_task(second()) await asyncio.sleep(0.05) # second is parked on the predecessor second_task.cancel() with pytest.raises(asyncio.CancelledError): await second_task release_first.set() await first_task # The canceled waiter took its ticket out of the queue; a fresh holder must then enter without waiting on a ticket # that no coroutine will ever leave. entered = asyncio.Event() async def third() -> None: async with gate.hold(): entered.set() await asyncio.wait_for(third(), timeout=1) assert entered.is_set() @pytest.mark.asyncio async def test_hold_interrupted_while_waiting_takes_its_ticket_out() -> None: gate = _AsyncTransitionGate() first_holding = asyncio.Event() release_first = asyncio.Event() async def first() -> None: async with gate.hold(): first_holding.set() await release_first.wait() first_task = asyncio.create_task(first()) await first_holding.wait() parked = gate.hold().__aenter__() # ruff:ignore[unnecessary-dunder-call] # step the entry by hand to throw into it parked.send(None) # runs up to the wait on the predecessor with pytest.raises(_Interrupt): parked.throw(_Interrupt()) release_first.set() await first_task async def third() -> None: async with gate.hold(): pass await asyncio.wait_for(third(), timeout=1) @pytest.mark.parametrize("joined", [pytest.param(True, id="after-join"), pytest.param(False, id="before-join")]) @pytest.mark.parametrize("entry", [pytest.param("hold", id="hold"), pytest.param("hold_for_acquire", id="acquire")]) @pytest.mark.asyncio async def test_hold_interrupted_while_joining_leaves_the_gate_free( mocker: MockerFixture, entry: str, *, joined: bool ) -> None: gate: Final = _AsyncTransitionGate() real_join: Final = _AsyncTransitionGate._join interrupted = False def join_then_interrupt(instance: _AsyncTransitionGate, ticket: ConcurrentFuture[None]) -> None: nonlocal interrupted if not interrupted: interrupted = True if joined: real_join(instance, ticket) raise _Interrupt real_join(instance, ticket) mocker.patch.object(_AsyncTransitionGate, "_join", autospec=True, side_effect=join_then_interrupt) def hold() -> contextlib.AbstractAsyncContextManager[None]: if entry == "hold": return gate.hold() return gate.hold_for_acquire(blocking=True, cancel_check=None, deadline=None, poll_interval=0.01) with pytest.raises(_Interrupt): await contextlib.AsyncExitStack().enter_async_context(hold()) entered = False async with asyncio.timeout(5), hold(): entered = True assert entered @pytest.mark.asyncio async def test_parked_caller_settles_a_head_whose_hand_off_was_lost(mocker: MockerFixture) -> None: gate: Final = _AsyncTransitionGate() real_settle: Final = _AsyncTransitionGate._settle_head_locked calls = 0 def lose_first_hand_off(instance: _AsyncTransitionGate) -> None: nonlocal calls calls += 1 if calls > 1: real_settle(instance) first_holding, release_first, second_entered = asyncio.Event(), asyncio.Event(), asyncio.Event() async def first() -> None: async with gate.hold(): first_holding.set() await release_first.wait() async def second() -> None: async with gate.hold(): second_entered.set() first_task = asyncio.create_task(first()) await first_holding.wait() second_task = asyncio.create_task(second()) await asyncio.sleep(0) mocker.patch.object(_AsyncTransitionGate, "_settle_head_locked", autospec=True, side_effect=lose_first_hand_off) release_first.set() async with asyncio.timeout(1): await second_entered.wait() await asyncio.gather(first_task, second_task) @pytest.mark.asyncio @pytest.mark.timeout(5) async def test_wait_for_turn_returns_immediately_when_already_done() -> None: ticket: ConcurrentFuture[None] = ConcurrentFuture() ticket.set_result(None) await _AsyncTransitionGate()._wait_for_turn( ticket, blocking=True, cancel_check=None, deadline=None, poll_interval=0.01 ) assert ticket.done() class _Interrupt(BaseException): """Stands in for a KeyboardInterrupt delivered while a waiter is parked, without pytest treating it as one.""" filelock-4.2.8/tests/test_default_mode.py0000644000000000000000000001761515272561277015476 0ustar00from __future__ import annotations import os import sys from stat import filemode from typing import TYPE_CHECKING import pytest from filelock import AsyncFileLock, FileLock, SoftFileLock, StrictSoftFileLock from filelock._api import _UNSET_FILE_MODE, BaseFileLock if TYPE_CHECKING: from pathlib import Path from unittest.mock import MagicMock from pytest_mock import MockerFixture @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.parametrize( ("mode", "expected"), [ pytest.param(_UNSET_FILE_MODE, 0o644, id="default"), pytest.param(0o600, 0o600, id="explicit"), ], ) def test_mode_property(lock_type: type[BaseFileLock], mode: int, expected: int, tmp_path: Path) -> None: assert lock_type(tmp_path / "a.lock", mode=mode).mode == expected @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.parametrize( ("mode", "expected"), [ pytest.param(_UNSET_FILE_MODE, False, id="default"), pytest.param(0o644, True, id="explicit"), ], ) def test_has_explicit_mode(lock_type: type[BaseFileLock], mode: int, expected: bool, tmp_path: Path) -> None: assert lock_type(tmp_path / "a.lock", mode=mode).has_explicit_mode is expected @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.parametrize( ("mode", "expected"), [ pytest.param(_UNSET_FILE_MODE, 0o666, id="default"), pytest.param(0o600, 0o600, id="explicit"), ], ) def test_open_mode(lock_type: type[BaseFileLock], mode: int, expected: int, tmp_path: Path) -> None: assert lock_type(tmp_path / "a.lock", mode=mode)._open_mode() == expected @pytest.mark.skipif(sys.platform == "win32", reason="Windows does not apply umask to file permissions") @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_default_mode_respects_umask(lock_type: type[BaseFileLock], tmp_path: Path) -> None: # pragma: win32 no cover lock_path = tmp_path / "a.lock" lock = lock_type(str(lock_path)) initial_umask = os.umask(0o022) try: # pragma: win32 no cover lock.acquire() assert lock.is_locked assert filemode(lock_path.stat().st_mode) == "-rw-r--r--" finally: os.umask(initial_umask) lock.release() @pytest.mark.skipif(sys.platform == "win32", reason="fchmod only on Unix") def test_default_mode_skips_fchmod(tmp_path: Path, mocker: MagicMock) -> None: # pragma: win32 no cover lock = FileLock(str(tmp_path / "a.lock")) fchmod_spy = mocker.patch("os.fchmod") lock.acquire() assert lock.is_locked fchmod_spy.assert_not_called() lock.release() @pytest.mark.skipif(sys.platform == "win32", reason="fchmod only on Unix") def test_explicit_mode_calls_fchmod(tmp_path: Path, mocker: MagicMock) -> None: # pragma: win32 no cover lock = FileLock(str(tmp_path / "a.lock"), mode=0o644) fchmod_spy = mocker.spy(os, "fchmod") lock.acquire() assert lock.is_locked fchmod_spy.assert_called_once() assert fchmod_spy.call_args[0][1] == 0o644 lock.release() @pytest.mark.skipif(sys.platform == "win32", reason="fchmod only on Unix") def test_explicit_mode_overrides_umask(tmp_path: Path) -> None: # pragma: win32 no cover lock_path = tmp_path / "a.lock" lock = FileLock(str(lock_path), mode=0o666) initial_umask = os.umask(0o022) try: # pragma: win32 no cover lock.acquire() assert lock.is_locked assert filemode(lock_path.stat().st_mode) == "-rw-rw-rw-" finally: os.umask(initial_umask) lock.release() @pytest.mark.requires_hard_links @pytest.mark.skipif(sys.platform == "win32", reason="Windows does not apply POSIX modes to directories") @pytest.mark.parametrize( ("mode", "expected"), [ pytest.param(_UNSET_FILE_MODE, "drwxrwxrwt", id="default"), pytest.param(0o600, "drwx-----T", id="owner"), pytest.param(0o640, "drwxr-x--T", id="group-read"), pytest.param(0o660, "drwxrwx--T", id="group-write"), ], ) def test_strict_protocol_directories_follow_mode( # pragma: win32 no cover # pragma: needs hard-link mode: int, expected: str, tmp_path: Path ) -> None: initial_umask = os.umask(0) try: # pragma: win32 no cover with StrictSoftFileLock(tmp_path / "a.lock", mode=mode): pass finally: os.umask(initial_umask) coordination = tmp_path / "a.lock.filelock" assert [filemode(directory.stat().st_mode) for directory in (coordination, coordination / "claims")] == [ expected, expected, ] @pytest.mark.requires_hard_links @pytest.mark.skipif(sys.platform == "win32", reason="Windows does not apply POSIX modes to directories") @pytest.mark.parametrize( ("owned", "expected"), [pytest.param(True, "drwx-----T", id="owned"), pytest.param(False, "drwxrwxrwx", id="another-account")], ) def test_strict_wide_directories_are_narrowed_only_when_owned( # pragma: win32 no cover # pragma: needs hard-link tmp_path: Path, mocker: MockerFixture, *, owned: bool, expected: str ) -> None: # An earlier release, or a wider umask, may have left the directories open; ones of another account are not ours. coordination = tmp_path / "a.lock.filelock" (coordination / "claims").mkdir(parents=True) for directory in (coordination, coordination / "claims"): directory.chmod(0o777) if not owned and sys.platform != "win32": # the skip above keeps Windows out; the test narrows os.geteuid for ty mocker.patch("filelock._strict.os.geteuid", autospec=True, return_value=os.geteuid() + 1) with StrictSoftFileLock(tmp_path / "a.lock", mode=0o600): pass assert [filemode(directory.stat().st_mode) for directory in (coordination, coordination / "claims")] == [ expected, expected, ] @pytest.mark.parametrize( "mode", [ pytest.param(_UNSET_FILE_MODE, id="default"), pytest.param(0o600, id="explicit"), ], ) def test_singleton_same_mode_matches(mode: int, tmp_path: Path) -> None: lock_path = str(tmp_path / "a.lock") assert FileLock(lock_path, mode=mode, is_singleton=True) is FileLock(lock_path, mode=mode, is_singleton=True) def test_singleton_default_vs_explicit_mode_differ(tmp_path: Path) -> None: lock_path = str(tmp_path / "a.lock") lock = FileLock(lock_path, is_singleton=True) with pytest.raises(ValueError, match="mode"): FileLock(lock_path, mode=0o644, is_singleton=True) del lock def test_unset_file_mode_sentinel_value() -> None: assert _UNSET_FILE_MODE == -1 @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock, StrictSoftFileLock, AsyncFileLock]) @pytest.mark.parametrize( "mode", [pytest.param(0o444, id="no-write"), pytest.param(0o200, id="no-read"), pytest.param(0, id="none")], ) def test_mode_without_owner_read_write_raises(lock_type: type[BaseFileLock], mode: int, tmp_path: Path) -> None: with pytest.raises(ValueError, match="must grant the owner read and write"): lock_type(tmp_path / "a.lock", mode=mode) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock, AsyncFileLock]) @pytest.mark.parametrize( "mode", [ pytest.param(-2, id="negative"), pytest.param(0o4600, id="setuid"), pytest.param(0o2600, id="setgid"), pytest.param(0o1600, id="sticky"), pytest.param(0o7777, id="all-special-bits"), ], ) def test_mode_outside_permission_bits_raises(lock_type: type[BaseFileLock], mode: int, tmp_path: Path) -> None: with pytest.raises(ValueError, match=r"must be within 0o000-0o777"): lock_type(tmp_path / "a.lock", mode=mode) @pytest.mark.parametrize("mode", [pytest.param("0o644", id="str"), pytest.param(True, id="bool")]) def test_mode_of_wrong_type_raises(mode: str | bool, tmp_path: Path) -> None: with pytest.raises(TypeError, match="mode must be an int"): FileLock(tmp_path / "a.lock", mode=mode) # ty: ignore[invalid-argument-type] # the rejected type is the contract filelock-4.2.8/tests/test_descriptor_helpers.py0000644000000000000000000000176615272561277016746 0ustar00from __future__ import annotations import os from typing import TYPE_CHECKING import pytest from capabilities import CAPABILITIES from tests.descriptor_helpers import descriptors_on if TYPE_CHECKING: from pathlib import Path from pytest_mock import MockerFixture @pytest.mark.skipif(not CAPABILITIES["fd-directory"], reason="counting open descriptors needs a /dev/fd view") def test_descriptors_on_probes_every_number_behind_an_incomplete_directory( # pragma: needs fd-directory tmp_path: Path, mocker: MockerFixture ) -> None: mocker.patch.dict(CAPABILITIES, {"complete-fd-directory": False}) with (tmp_path / "held").open("w+b") as first: node = os.fstat(first.fileno()) second = os.dup(first.fileno()) mocker.patch.object(os, "sysconf", autospec=True, return_value=second + 1) try: assert descriptors_on(node) == 2 finally: os.close(second) assert descriptors_on(node) == 1 assert descriptors_on(node) == 0 filelock-4.2.8/tests/test_error.py0000644000000000000000000001233515272561277014171 0ustar00from __future__ import annotations import copy import pickle # ruff:ignore[suspicious-pickle-import] # round-trips exceptions created in these tests from functools import partial from typing import TYPE_CHECKING, Final import pytest from filelock import SoftFileLockProtocolError, Timeout if TYPE_CHECKING: from collections.abc import Callable @pytest.mark.parametrize( ("extract", "expected"), [ pytest.param(str, "The file lock '/path/to/lock' could not be acquired.", id="str"), pytest.param(repr, "Timeout('/path/to/lock')", id="repr"), pytest.param(lambda t: t.lock_file, "/path/to/lock", id="lock_file"), ], ) def test_timeout_attribute(extract: Callable[[Timeout], str], expected: str) -> None: assert extract(Timeout("/path/to/lock")) == expected def test_timeout_names_a_holder_it_cannot_probe() -> None: assert str(Timeout("/path/to/lock", "pid 7 on build-host")) == ( "The file lock '/path/to/lock' could not be acquired. It is held by pid 7 on build-host, which this host cannot" " probe." ) def test_timeout_subclass_taking_only_the_lock_file_pickles() -> None: restored: Final = _pickle_round_trip(_LockFileOnlyTimeout("/path/to/lock"), protocol=pickle.DEFAULT_PROTOCOL) assert (type(restored), restored.lock_file) == (_LockFileOnlyTimeout, "/path/to/lock") class _LockFileOnlyTimeout(Timeout): def __init__(self, lock_file: str) -> None: super().__init__(lock_file) def test_exception_serialization_preserves_diagnostics( error: Timeout | SoftFileLockProtocolError, clone: Callable[[Timeout | SoftFileLockProtocolError], Timeout | SoftFileLockProtocolError], ) -> None: error.add_note("while acquiring the build cache") vars(error)["request_id"] = "request-1" restored: Final = clone(error) assert ( type(restored), str(restored), repr(restored), restored.args, restored.lock_file, restored.__notes__, vars(restored)["request_id"], ) == ( type(error), str(error), repr(error), error.args, error.lock_file, ["while acquiring the build cache"], "request-1", ) def test_exception_serialization_creates_distinct_instance( error: Timeout | SoftFileLockProtocolError, clone: Callable[[Timeout | SoftFileLockProtocolError], Timeout | SoftFileLockProtocolError], ) -> None: assert clone(error) is not error def test_exception_serialization_preserves_note_alias( error: Timeout | SoftFileLockProtocolError, clone: Callable[[Timeout | SoftFileLockProtocolError], Timeout | SoftFileLockProtocolError], ) -> None: error.__notes__ = ["original"] vars(error)["diagnostics"] = error.__notes__ restored: Final = clone(error) assert vars(restored)["diagnostics"] is restored.__notes__ def test_exception_copy_shares_notes(error: Timeout | SoftFileLockProtocolError) -> None: error.__notes__ = ["original"] copy.copy(error).__notes__.append("copy") assert error.__notes__ == ["original", "copy"] def test_exception_deepcopy_isolates_notes(error: Timeout | SoftFileLockProtocolError) -> None: error.__notes__ = ["original"] copy.deepcopy(error).__notes__.append("copy") assert error.__notes__ == ["original"] def test_exception_serialization_preserves_cycle( error: Timeout | SoftFileLockProtocolError, clone: Callable[[Timeout | SoftFileLockProtocolError], Timeout | SoftFileLockProtocolError], ) -> None: vars(error)["related"] = error restored: Final = clone(error) assert vars(restored)["related"] is (error if clone is copy.copy else restored) def test_protocol_error_serialization_preserves_claim( clone: Callable[[Timeout | SoftFileLockProtocolError], Timeout | SoftFileLockProtocolError], ) -> None: restored: Final = clone(SoftFileLockProtocolError("/lock", "claim", "invalid marker")) assert isinstance(restored, SoftFileLockProtocolError) assert (restored.claim_name, restored.reason) == ("claim", "invalid marker") @pytest.fixture( params=[ pytest.param("timeout", id="timeout"), pytest.param("holder", id="timeout-holder"), pytest.param("claim", id="claim"), pytest.param(None, id="no-claim"), ] ) def error(request: pytest.FixtureRequest) -> Timeout | SoftFileLockProtocolError: if request.param == "timeout": return Timeout("/path/to/lock") if request.param == "holder": return Timeout("/path/to/lock", "pid 7 on build-host") return SoftFileLockProtocolError("/path/to/lock", request.param, "invalid marker") def _pickle_round_trip( error: Timeout | SoftFileLockProtocolError, *, protocol: int ) -> Timeout | SoftFileLockProtocolError: return pickle.loads(pickle.dumps(error, protocol=protocol)) # ruff:ignore[suspicious-pickle-usage] # input is created by these tests @pytest.fixture( params=[pytest.param(copy.copy, id="copy"), pytest.param(copy.deepcopy, id="deepcopy")] + [ pytest.param(partial(_pickle_round_trip, protocol=p), id=f"pickle-{p}") for p in range(pickle.HIGHEST_PROTOCOL + 1) ], ) def clone( request: pytest.FixtureRequest, ) -> Callable[[Timeout | SoftFileLockProtocolError], Timeout | SoftFileLockProtocolError]: return request.param filelock-4.2.8/tests/test_filelock.py0000644000000000000000000046472015272561277014641 0ustar00from __future__ import annotations import errno import inspect import logging import math import os import runpy import signal import subprocess # ruff:ignore[suspicious-subprocess-import] # only fixed-argument, no-shell runs below import sys import textwrap import threading import time import traceback from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager, suppress from decimal import Decimal from errno import EAGAIN, EINTR, EIO, ENOSPC, ENOSYS, EWOULDBLOCK from fractions import Fraction from functools import partial from inspect import getframeinfo, stack from itertools import count from pathlib import Path, PurePath from pkgutil import resolve_name from queue import Queue from stat import S_IMODE, S_IWGRP, S_IWOTH, S_IWUSR, filemode from types import CodeType, FrameType, SimpleNamespace, TracebackType from typing import TYPE_CHECKING, Any, Final, Literal, cast, get_args from uuid import uuid4 from weakref import WeakValueDictionary import pytest from capabilities import CAPABILITIES import filelock import filelock.version from filelock import ( BaseFileLock, ContextErrorPolicy, FileLock, MarkerSoftFileLock, SoftFileLease, SoftFileLock, SoftFileLockProtocolError, StrictSoftFileLock, Timeout, UnixFileLock, WindowsFileLock, lock_descriptor, unlock_descriptor, ) from filelock._api import _append_exception_context, _raise_grouped_errors, _registry from tests.capability_marks import ( NEEDS_FCNTL, NEEDS_FILE_MODE, NEEDS_FILE_PERMISSIONS, NEEDS_PARENT_SYMLINK_COLLAPSE, NEEDS_PROMPT_FINALIZATION, NEEDS_PROMPT_SIGNAL_DELIVERY, NEEDS_SYMLINK, ) from tests.process_helpers import call_within if TYPE_CHECKING: from collections.abc import Callable, Generator, Iterator from unittest.mock import Mock from _typeshed import Unused from pytest_mock import MockerFixture @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.parametrize("path_type", [str, PurePath, Path]) @pytest.mark.parametrize("filename", ["a", "new/b", "new2/new3/c"]) def test_simple( lock_type: type[BaseFileLock], path_type: type[str | Path], filename: str, tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: caplog.set_level(logging.DEBUG) lock_path = tmp_path / filename lock = lock_type(path_type(lock_path)) with lock as locked: assert lock.is_locked assert lock is locked assert not lock.is_locked # A runtime with a lazy collector may finalize, mid-test, a lock an earlier test dropped. assert [(r.name, r.levelno, r.getMessage()) for r in caplog.records if str(lock_path) in r.getMessage()] == [ ("filelock", logging.DEBUG, f"Attempting to acquire lock {id(lock)} on {lock_path}"), ("filelock", logging.DEBUG, f"Lock {id(lock)} acquired on {lock_path}"), ("filelock", logging.DEBUG, f"Attempting to release lock {id(lock)} on {lock_path}"), ("filelock", logging.DEBUG, f"Lock {id(lock)} released on {lock_path}"), ] assert logging.getLogger("filelock").level == logging.NOTSET @pytest.mark.parametrize( "key", [pytest.param("/short-key", id="absolute"), pytest.param("\udcff", id="undecodable-filename")] ) def test_documented_cache_lock_name(tmp_path: Path, key: str) -> None: source: Final = (Path(__file__).parents[1] / "docs" / "how-to.rst").read_text(encoding="utf-8") snippet: Final = source.split(".. code-block:: python\n", 1)[1].split("\n``datasets``", 1)[0] recipe: Final = tmp_path / "cache_recipe.py" recipe.write_text(textwrap.dedent(snippet), encoding="utf-8") lock_for: Final = cast("Callable[[str, str], FileLock]", runpy.run_path(str(recipe))["lock_for"]) cache_dir: Final = tmp_path / "cache" with (lock := lock_for(str(cache_dir), key)): lock_path: Final = Path(lock.lock_file) assert (lock_path.parent, len(lock_path.name), lock_path.name.isascii()) == (cache_dir, 69, True) @contextmanager def make_ro(path: Path) -> Generator[None]: write = S_IWUSR | S_IWGRP | S_IWOTH path.chmod(path.stat().st_mode & ~write) try: yield finally: path.chmod(path.stat().st_mode | write) @pytest.fixture def tmp_path_ro(tmp_path: Path) -> Iterator[Path]: # pragma: needs file-permissions with make_ro(tmp_path): yield tmp_path @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @NEEDS_FILE_PERMISSIONS # pragma: needs file-permissions def test_ro_folder(lock_type: type[BaseFileLock], tmp_path_ro: Path) -> None: lock = lock_type(str(tmp_path_ro / "a")) with pytest.raises(PermissionError, match="Permission denied"): lock.acquire() @pytest.fixture def tmp_file_ro(tmp_path: Path) -> Iterator[Path]: filename = tmp_path / "a" filename.write_text("") with make_ro(filename): yield filename @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.skipif( sys.platform != "win32" and os.geteuid() == 0, reason="Cannot make a read only file (that the current user: root can't read)", ) def test_ro_file(lock_type: type[BaseFileLock], tmp_file_ro: Path) -> None: lock = lock_type(str(tmp_file_ro)) with pytest.raises(PermissionError, match="Permission denied"): lock.acquire() _WINDOWS_ONLY: Final[pytest.MarkDecorator] = pytest.mark.skipif(sys.platform != "win32", reason="Windows only") _INVALID_DESCRIPTOR_POLL_INTERVALS: Final = ( pytest.param(0.0, id="zero"), pytest.param(-0.01, id="negative"), pytest.param(float("nan"), id="nan"), pytest.param(float("inf"), id="positive-infinity"), pytest.param(float("-inf"), id="negative-infinity"), pytest.param(threading.TIMEOUT_MAX * 2, id="above-timeout-max"), ) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.parametrize( ("expected_error", "expected_errno", "bad_lock_file"), [ # Win32 error messages depend on the system locale. pytest.param( OSError, (errno.ENOENT, errno.EINVAL, errno.EACCES), "", id="blank_filename", ), ( pytest.param(PermissionError, (errno.EACCES,), ".", id="current_directory") if sys.platform == "win32" else ( pytest.param(IsADirectoryError, (errno.EISDIR,), ".", id="current_directory") if sys.platform in {"darwin", "linux"} else pytest.param(OSError, None, ".", id="current_directory") ) ), ] + [pytest.param(OSError, (errno.EINVAL,), i, id=f"invalid_{i}", marks=_WINDOWS_ONLY) for i in '<>:"|?*\a'] + [pytest.param(PermissionError, (errno.EACCES,), i, id=f"permission_{i}", marks=_WINDOWS_ONLY) for i in "/\\"], ) @pytest.mark.timeout(5) # timeout in case of infinite loop def test_bad_lock_file( lock_type: type[BaseFileLock], expected_error: type[OSError], expected_errno: tuple[int, ...] | None, bad_lock_file: str, ) -> None: lock: Final = lock_type(bad_lock_file) with pytest.raises(expected_error) as raised: lock.acquire() assert expected_errno is None or raised.value.errno in expected_errno @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.timeout(5) def test_bad_lock_file_null_byte(lock_type: type[BaseFileLock]) -> None: lock: Final = lock_type("\0") with pytest.raises(ValueError, match=r"embedded null (byte|character)"): lock.acquire() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_nested_context_manager(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) with lock as lock_1: assert lock.is_locked assert lock is lock_1 with lock as lock_2: assert lock.is_locked assert lock is lock_2 with lock as lock_3: assert lock.is_locked assert lock is lock_3 assert lock.is_locked assert lock.is_locked assert not lock.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_nested_acquire(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) with lock.acquire() as lock_1: assert lock.is_locked assert lock is lock_1 with lock.acquire() as lock_2: assert lock.is_locked assert lock is lock_2 with lock.acquire() as lock_3: assert lock.is_locked assert lock is lock_3 assert lock.is_locked assert lock.is_locked assert not lock.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_nested_forced_release(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) with lock: assert lock.is_locked lock.acquire() assert lock.is_locked lock.release(force=True) assert not lock.is_locked assert not lock.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_nested_contruct(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" with lock_type(str(lock_path), is_singleton=True, timeout=2) as lock_1: assert lock_1.is_locked with lock_type(str(lock_path), is_singleton=True, timeout=2) as lock_2: assert lock_2 is lock_1 assert lock_2.is_locked assert lock_1.is_locked assert not lock_1.is_locked _ExcInfoType = tuple[type[BaseException], BaseException, TracebackType] | tuple[None, None, None] class ExThread(threading.Thread): def __init__(self, target: Callable[[], None], name: str) -> None: super().__init__(target=target, name=name, daemon=True) self.ex: _ExcInfoType | None = None def run(self) -> None: try: super().run() except Exception: # pragma: no cover # a worker that raises fails the test; a healthy run never lands here self.ex = sys.exc_info() def join(self, timeout: float | None = None) -> None: super().join(timeout=timeout) if self.ex is not None: raise RuntimeError from self.ex[1] # pragma: no cover # only a worker that raised gets re-raised here # A kill switch, not a budget: the run takes 1 to 2 s untraced, but under the coverage tracer 100 threads convoy on its # data lock, and on a two-vCPU Windows runner that ran twenty times slower than on a desktop. A stuck worker must fail # the run with its stack instead of hiding behind a larger number. @pytest.mark.timeout(60) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_threaded_shared_lock_obj(lock_type: type[BaseFileLock], tmp_path: Path) -> None: if sys.platform == "win32" and lock_type.__name__ == "SoftFileLock": # pragma: win32 cover pytest.skip( "SoftFileLock uses file-existence locking — on Windows, unlink can silently fail under heavy " "thread contention (EACCES from antivirus/indexer), orphaning the lock file with no recovery path" ) lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) holders: list[str] = [] finished: list[None] = [] def thread_work() -> None: name = threading.current_thread().name for _ in range(10): with lock: holders.append(name) time.sleep(0) # give up the GIL so an overlapping holder has a chance to show in the slot assert holders == [name] assert lock.is_locked holders.pop() finished.append(None) threads = [ExThread(target=thread_work, name=f"t{i}") for i in range(100)] for thread in threads: thread.start() for thread in threads: thread.join() assert len(finished) == 100 * 10 # A kill switch, not a budget: see test_threaded_shared_lock_obj. @pytest.mark.timeout(60) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_threaded_lock_different_lock_obj(lock_type: type[BaseFileLock], tmp_path: Path) -> None: if sys.platform == "win32" and (hasattr(sys, "pypy_version_info") or lock_type.__name__ == "SoftFileLock"): pytest.skip("SoftFileLock on Windows has race conditions under heavy threading") # pragma: win32 cover holders: list[str] = [] finished: list[None] = [] def work(lock: BaseFileLock) -> None: name = threading.current_thread().name for _ in range(100): with lock: holders.append(name) time.sleep(0) # give up the GIL so an overlapping holder has a chance to show in the slot assert holders == [name] assert lock.is_locked holders.pop() finished.append(None) lock_path = tmp_path / "a" lock_1, lock_2 = lock_type(str(lock_path)), lock_type(str(lock_path)) threads = [ (ExThread(partial(work, lock_1), f"t1_{i}"), ExThread(partial(work, lock_2), f"t2_{i}")) for i in range(10) ] for thread_1, thread_2 in threads: thread_1.start() thread_2.start() for thread_1, thread_2 in threads: thread_1.join() thread_2.join() assert len(finished) == 20 * 100 @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_timeout(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_1, lock_2 = lock_type(str(lock_path)), lock_type(str(lock_path)) lock_1.acquire() assert lock_1.is_locked assert not lock_2.is_locked 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 lock_1.release() assert not lock_1.is_locked assert not lock_2.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_non_blocking(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_1, lock_2 = lock_type(str(lock_path)), lock_type(str(lock_path)) lock_3 = lock_type(str(lock_path), blocking=False) lock_4 = lock_type(str(lock_path), timeout=0) lock_5 = lock_type(str(lock_path), blocking=False, timeout=-1) lock_1.acquire() assert lock_1.is_locked assert not lock_2.is_locked assert not lock_3.is_locked assert not lock_4.is_locked assert not lock_5.is_locked 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 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 with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."), lock_3: pass # pragma: no cover # __enter__ raises Timeout, so the body never runs assert not lock_3.is_locked assert lock_1.is_locked 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 with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."), lock_4: pass # pragma: no cover # __enter__ raises Timeout, so the body never runs assert not lock_4.is_locked assert lock_1.is_locked # blocking precedence over timeout 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 with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."), lock_5: pass # pragma: no cover # __enter__ raises Timeout, so the body never runs assert not lock_5.is_locked assert lock_1.is_locked lock_1.release() assert not lock_1.is_locked assert not lock_2.is_locked assert not lock_3.is_locked assert not lock_4.is_locked assert not lock_5.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_default_timeout(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_1, lock_2 = lock_type(str(lock_path)), lock_type(str(lock_path), timeout=0.1) assert lock_2.timeout == pytest.approx(0.1) lock_1.acquire() assert lock_1.is_locked assert not lock_2.is_locked 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 lock_2.timeout = 0 assert lock_2.timeout == 0 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 lock_1.release() assert not lock_1.is_locked assert not lock_2.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_context_release_on_exc(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) try: with lock as lock_1: assert lock is lock_1 assert lock.is_locked raise ValueError # ruff:ignore[raise-within-try] # raises inside the block to assert release on exception except ValueError: assert not lock.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_acquire_release_on_exc(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) try: with lock.acquire() as lock_1: assert lock is lock_1 assert lock.is_locked raise ValueError # ruff:ignore[raise-within-try] # raises inside the block to assert release on exception except ValueError: assert not lock.is_locked @NEEDS_PROMPT_FINALIZATION @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_del(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_1, lock_2 = lock_type(str(lock_path)), lock_type(str(lock_path)) lock_1.acquire() assert lock_1.is_locked assert not lock_2.is_locked with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): lock_2.acquire(timeout=0.1) del lock_1 lock_2.acquire() assert lock_2.is_locked lock_2.release() def _acquire_in_exited_thread(lock: BaseFileLock) -> None: worker: Final = threading.Thread(target=lock.acquire) worker.start() worker.join() @contextmanager def _held_by_live_thread(box: list[BaseFileLock]) -> Generator[None]: acquired, done = threading.Event(), threading.Event() def hold() -> None: # Popping leaves the worker without a reference, so only the test's own name keeps the lock alive. box.pop().acquire() acquired.set() done.wait() worker: Final = threading.Thread(target=hold) worker.start() acquired.wait() try: yield finally: done.set() worker.join() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.parametrize( "drop", [ pytest.param(list.clear, id="del", marks=NEEDS_PROMPT_FINALIZATION), pytest.param(lambda box: box[0].release(force=True), id="force-release"), ], ) def test_releases_hold_of_exited_thread( lock_type: type[BaseFileLock], drop: Callable[[list[BaseFileLock]], None], tmp_path: Path ) -> None: lock_path: Final = str(tmp_path / "a") box: Final = [lock_type(lock_path)] _acquire_in_exited_thread(box[0]) drop(box) with lock_type(lock_path, blocking=False) as other: assert other.is_locked @NEEDS_PROMPT_FINALIZATION @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_del_releases_hold_of_live_thread(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path: Final = str(tmp_path / "a") lock = lock_type(lock_path) with _held_by_live_thread([lock]): del lock with lock_type(lock_path, blocking=False) as other: assert other.is_locked @NEEDS_PROMPT_FINALIZATION @pytest.mark.parametrize("thread_local", [pytest.param(True, id="thread_local"), pytest.param(False, id="shared")]) def test_singleton_handed_out_while_finalizing_keeps_its_hold(tmp_path: Path, thread_local: bool) -> None: lock_path: Final = str(tmp_path / "a") handed_out: Final = _singleton_handed_out_while_finalizing(lock_path, thread_local=thread_local) with pytest.raises(Timeout): FileLock(lock_path, blocking=False).acquire() handed_out.release() @NEEDS_PROMPT_FINALIZATION @pytest.mark.parametrize("thread_local", [pytest.param(True, id="thread_local"), pytest.param(False, id="shared")]) def test_singleton_handed_out_while_finalizing_releases_once_dropped(tmp_path: Path, thread_local: bool) -> None: lock_path: Final = str(tmp_path / "a") handed_out = _singleton_handed_out_while_finalizing(lock_path, thread_local=thread_local) del handed_out with FileLock(lock_path, blocking=False) as other: assert other.is_locked def _singleton_handed_out_while_finalizing(lock_path: str, *, thread_local: bool) -> FileLock: # The cache still finds a lock whose finalizer is starting, as another thread asking at that moment would. handed_out: Final[list[FileLock]] = [] # Interpreters run a profile hook with tracing off, so coverage does not record its body. def take_from_cache(frame: FrameType, event: str, _arg: Unused) -> None: # pragma: no cover if event == "call" and frame.f_code is BaseFileLock.__del__.__code__ and not handed_out: handed_out.append(FileLock(lock_path, is_singleton=True, thread_local=thread_local)) handed_out[0].acquire() sys.setprofile(take_from_cache) try: FileLock(lock_path, is_singleton=True, thread_local=thread_local) finally: sys.setprofile(None) return handed_out.pop() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_concurrent_force_releases_free_an_exited_threads_hold_once( lock_type: type[BaseFileLock], tmp_path: Path, mocker: MockerFixture, caplog: pytest.LogCaptureFixture ) -> None: lock: Final = lock_type(str(tmp_path / "a")) _acquire_in_exited_thread(lock) first_inside: Final = threading.Event() def stall_the_first_release(record: logging.LogRecord) -> bool: # A slow log handler holds the first releaser inside the release while the second one arrives. if record.getMessage().startswith("Attempting to release") and not first_inside.is_set(): first_inside.set() time.sleep(0.2) return True def record_close(fd: int) -> None: # Finalizers of other tests' leftovers close unrelated descriptors here; the inode picks out this lock's. inode: int | None = None with suppress(OSError): inode = os.fstat(fd).st_ino closes.append((fd, inode)) real_close(fd) lock_inode: Final = (tmp_path / "a").stat().st_ino closes: Final[list[tuple[int, int | None]]] = [] real_close: Final = os.close caplog.set_level(logging.DEBUG, logger="filelock") logging.getLogger("filelock").addFilter(stall_the_first_release) mocker.patch("os.close", autospec=True, side_effect=record_close) try: with ThreadPoolExecutor(max_workers=2) as pool: first: Final = pool.submit(lock.release, force=True) first_inside.wait(_SHARED_WAIT) second: Final = pool.submit(lock.release, force=True) finally: logging.getLogger("filelock").removeFilter(stall_the_first_release) [lock_fd] = {fd for fd, inode in closes if inode == lock_inode} assert (first.exception(), second.exception(), [fd for fd, _ in closes if fd == lock_fd]) == (None, None, [lock_fd]) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_force_release_keeps_hold_of_live_thread(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path: Final = str(tmp_path / "a") lock: Final = lock_type(lock_path) with _held_by_live_thread([lock]): lock.release(force=True) with pytest.raises(Timeout): lock_type(lock_path, blocking=False).acquire() def test_cleanup_soft_lock(tmp_path: Path) -> None: lock_path = tmp_path / "a" with SoftFileLock(lock_path): assert lock_path.exists() assert not lock_path.exists() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_poll_intervall_deprecated(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) with pytest.deprecated_call(match="use poll_interval instead of poll_intervall") as checker: lock.acquire(poll_intervall=0.05) frame_info = getframeinfo(stack()[0][0]) # lineno here is one past the acquire() call above for warning in checker: if warning.filename == frame_info.filename and warning.lineno + 1 == frame_info.lineno: # pragma: no branch break else: # pragma: no cover # the stacklevel=2 warning is always found, so the failure arm never runs pytest.fail("No warnings of stacklevel=2 matching.") # A held lock left to the garbage collector releases at an arbitrary later point on a deferred collector, and its # release logs then land in whichever test is running. lock.release() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_default_poll_interval(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) assert lock.poll_interval == pytest.approx(0.05) lock_2 = lock_type(str(lock_path), poll_interval=0.1) assert lock_2.poll_interval == pytest.approx(0.1) lock_2.poll_interval = 0.2 assert lock_2.poll_interval == pytest.approx(0.2) lock_3 = lock_type(str(lock_path), poll_interval=0) assert lock_3.poll_interval == 0 lock_3.poll_interval = 0.0 assert lock_3.poll_interval == pytest.approx(0.0) @pytest.fixture def steady_clock(mocker: MockerFixture) -> Mock: # These tests assert the sleep the lock asked for. The lock caps that sleep at the time left before its timeout, so # a stall on a loaded machine between the start and the first poll would shorten it. A clock that advances 10 ms # per reading keeps the first poll inside the timeout regardless of load. sleep_mock: Final = mocker.create_autospec(time.sleep) mocker.patch( "filelock._api.time", new=SimpleNamespace( perf_counter=mocker.create_autospec(time.perf_counter, side_effect=(reading / 100 for reading in count())), sleep=sleep_mock, ), ) return sleep_mock @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_poll_interval_used_by_context_manager( lock_type: type[BaseFileLock], tmp_path: Path, steady_clock: Mock ) -> None: lock_path = tmp_path / "a" lock_1 = lock_type(str(lock_path)) lock_2 = lock_type(str(lock_path), timeout=0.2, poll_interval=0.05) lock_1.acquire() with pytest.raises(Timeout): lock_2.acquire() steady_clock.assert_any_call(0.05) steady_clock.reset_mock() lock_2.poll_interval = 0.1 with pytest.raises(Timeout): lock_2.acquire() steady_clock.assert_any_call(0.1) lock_1.release() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_poll_interval_acquire_override(lock_type: type[BaseFileLock], tmp_path: Path, steady_clock: Mock) -> None: lock_path = tmp_path / "a" lock_1 = lock_type(str(lock_path)) lock_2 = lock_type(str(lock_path), timeout=0.2, poll_interval=0.05) lock_1.acquire() with pytest.raises(Timeout): lock_2.acquire(poll_interval=0.15) steady_clock.assert_any_call(0.15) lock_1.release() def _set_poll_interval( lock_type: type[BaseFileLock], entry_point: Literal["constructor", "setter", "acquire"], lock: BaseFileLock, value: object, ) -> None: value = cast("float", value) if entry_point == "constructor": lock_type(lock.lock_file, poll_interval=value) elif entry_point == "setter": lock.poll_interval = value else: lock.acquire(poll_interval=value, timeout=0) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.parametrize("entry_point", ["constructor", "setter", "acquire"]) @pytest.mark.parametrize( ("bad_value", "error_type", "message"), [ pytest.param(-1, ValueError, "finite and non-negative", id="negative-int"), pytest.param(-0.5, ValueError, "finite and non-negative", id="negative-float"), pytest.param(float("nan"), ValueError, "finite and non-negative", id="nan"), pytest.param(float("inf"), ValueError, "finite and non-negative", id="positive-infinity"), pytest.param(float("-inf"), ValueError, "finite and non-negative", id="negative-infinity"), pytest.param(threading.TIMEOUT_MAX * 2, ValueError, "TIMEOUT_MAX", id="above-timeout-max"), pytest.param(True, TypeError, "poll_interval must be", id="true"), pytest.param(False, TypeError, "poll_interval must be", id="false"), pytest.param("5", TypeError, "poll_interval must be", id="string"), ], ) def test_poll_interval_rejects_invalid_value( lock_type: type[BaseFileLock], entry_point: Literal["constructor", "setter", "acquire"], bad_value: object, error_type: type[ValueError | TypeError], message: str, tmp_path: Path, ) -> None: lock = lock_type(str(tmp_path / "a")) with pytest.raises(error_type, match=message): _set_poll_interval(lock_type, entry_point, lock, bad_value) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_context_decorator(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) @lock def decorated_method() -> None: assert lock.is_locked assert not lock.is_locked decorated_method() assert not lock.is_locked def test_lock_mode(tmp_path: Path) -> None: lock_path = tmp_path / "a.lock" lock = FileLock(str(lock_path), mode=0o666) initial_umask = os.umask(0o022) # pin umask so the resulting permissions are predictable try: lock.acquire() assert lock.is_locked assert filemode(lock_path.stat().st_mode) == "-rw-rw-rw-" finally: os.umask(initial_umask) lock.release() @NEEDS_FILE_MODE @pytest.mark.parametrize( ("mode", "umask"), [ pytest.param(0o666, 0o022, id="explicit-mode"), pytest.param(None, 0, id="default-mode-open-umask"), ], ) def test_lock_mode_soft_lets_only_the_owner_write_the_marker( # pragma: needs file-mode mode: int | None, umask: int, tmp_path: Path ) -> None: # Contenders act on the record inside, so no other account may rewrite it. lock_path = tmp_path / "a.lock" lock = SoftFileLock(str(lock_path)) if mode is None else SoftFileLock(str(lock_path), mode=mode) initial_umask = os.umask(umask) try: with lock: assert filemode(lock_path.stat().st_mode) == "-rw-r--r--" finally: os.umask(initial_umask) def test_umask(tmp_path: Path) -> None: lock_path = tmp_path / "a.lock" lock = FileLock(str(lock_path), mode=0o666) initial_umask = os.umask(0) os.umask(initial_umask) lock.acquire() assert lock.is_locked current_umask = os.umask(0) os.umask(current_umask) assert initial_umask == current_umask lock.release() def test_umask_soft(tmp_path: Path) -> None: lock_path = tmp_path / "a.lock" lock = SoftFileLock(str(lock_path), mode=0o666) initial_umask = os.umask(0) os.umask(initial_umask) lock.acquire() assert lock.is_locked current_umask = os.umask(0) os.umask(current_umask) assert initial_umask == current_umask lock.release() def test_wrong_platform(tmp_path: Path) -> None: assert not inspect.isabstract(UnixFileLock) assert not inspect.isabstract(WindowsFileLock) assert inspect.isabstract(BaseFileLock) lock_type = UnixFileLock if sys.platform == "win32" else WindowsFileLock lock = lock_type(tmp_path / "lockfile") with pytest.raises(NotImplementedError): lock.acquire() with pytest.raises(NotImplementedError): lock._release() @NEEDS_FCNTL # pragma: needs fcntl @pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning") def test_flock_not_implemented_unix(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("fcntl.flock", side_effect=OSError(ENOSYS, "mock error")) lock = FileLock(tmp_path / "a.lock") with lock: assert lock.is_locked assert isinstance(lock, SoftFileLock) def test_soft_errors(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("os.open", side_effect=OSError(ENOSYS, "mock error")) with pytest.raises(OSError, match="mock error"): SoftFileLock(tmp_path / "a.lock").acquire() def _check_file_read_write(txt_file: Path) -> None: for _ in range(3): uuid = str(uuid4()) txt_file.write_text(uuid, encoding="utf-8") assert txt_file.read_text(encoding="utf-8") == uuid @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_thrashing_with_thread_pool_passing_lock_to_threads(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: def mess_with_file(lock_: BaseFileLock) -> None: with lock_: _check_file_read_write(txt_file) lock_file, txt_file = tmp_path / "test.txt.lock", tmp_path / "test.txt" lock = lock_type(lock_file) with ThreadPoolExecutor() as executor: results = [executor.submit(mess_with_file, lock) for _ in range(100)] assert all(r.result() is None for r in results) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_thrashing_with_thread_pool_global_lock(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: def mess_with_file() -> None: with lock: _check_file_read_write(txt_file) lock_file, txt_file = tmp_path / "test.txt.lock", tmp_path / "test.txt" lock = lock_type(lock_file) with ThreadPoolExecutor() as executor: results = [executor.submit(mess_with_file) for _ in range(100)] assert all(r.result() is None for r in results) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_thrashing_with_thread_pool_lock_recreated_in_each_thread( tmp_path: Path, lock_type: type[BaseFileLock], ) -> None: def mess_with_file() -> None: with lock_type(lock_file): _check_file_read_write(txt_file) lock_file, txt_file = tmp_path / "test.txt.lock", tmp_path / "test.txt" with ThreadPoolExecutor() as executor: results = [executor.submit(mess_with_file) for _ in range(100)] assert all(r.result() is None for r in results) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_lock_can_be_non_thread_local( tmp_path: Path, lock_type: type[BaseFileLock], ) -> None: lock = lock_type(tmp_path / "test.lock", thread_local=False) for _ in range(2): thread = threading.Thread(target=lock.acquire, kwargs={"timeout": 2}) thread.start() thread.join() assert lock.lock_counter == 2 lock.release(force=True) _SHARED_THREADS: Final[int] = 8 # Every wait is bounded so a regression fails as an assertion. A hang would reach pytest-timeout instead, which on # Windows can only end the whole session. _SHARED_WAIT: Final[float] = 10 def _run_shared_threads(target: Callable[[int], None]) -> None: # An exception escaping a worker surfaces as PytestUnhandledThreadExceptionWarning, which filterwarnings fails. threads = [threading.Thread(target=target, args=(idx,), daemon=True) for idx in range(_SHARED_THREADS)] for thread in threads: thread.start() for thread in threads: thread.join(_SHARED_WAIT) assert not [thread for thread in threads if thread.is_alive()] @pytest.mark.parametrize( "lock_type", [ pytest.param(FileLock, id="native"), pytest.param(SoftFileLock, id="soft"), pytest.param(SoftFileLease, id="lease"), pytest.param(StrictSoftFileLock, id="strict", marks=pytest.mark.requires_hard_links), ], ) @pytest.mark.timeout(0) # Progress and join waits bound stalls; batch runtime is not a lock guarantee. def test_shared_instance_concurrent_acquire_release_leaves_lock_free( tmp_path: Path, lock_type: type[BaseFileLock] ) -> None: lock: Final = lock_type(tmp_path / "a.lock", thread_local=False) barrier: Final = threading.Barrier(_SHARED_THREADS, timeout=_SHARED_WAIT) progress: Final[Queue[None]] = Queue() def work() -> None: barrier.wait() for _ in range(500): lock.acquire(timeout=_SHARED_WAIT) assert lock.is_locked lock.release() progress.put(None) threads: Final = [threading.Thread(target=work, daemon=True) for _ in range(_SHARED_THREADS)] for thread in threads: thread.start() # Require progress without timing all 500 iterations against one thread-join deadline. for _ in range(_SHARED_THREADS * 500): progress.get(timeout=_SHARED_WAIT) for thread in threads: thread.join(_SHARED_WAIT) assert not [thread for thread in threads if thread.is_alive()] assert (lock.lock_counter, lock.is_locked) == (0, False) with lock_type(tmp_path / "a.lock", timeout=0): pass def test_shared_lease_marker_names_the_lease_token(tmp_path: Path) -> None: marker = tmp_path / "a.lock" lease = SoftFileLease(marker, thread_local=False) barrier = threading.Barrier(_SHARED_THREADS, timeout=_SHARED_WAIT) published: list[tuple[str | None, str]] = [] def work(idx: int) -> None: for _ in range(50): barrier.wait() lease.acquire(timeout=_SHARED_WAIT) barrier.wait() if idx == 0: published.append((lease.token, marker.read_text())) lease.release(force=True) barrier.wait() _run_shared_threads(work) assert [(token, content) for token, content in published if f"token={token}\n" not in content] == [] def test_shared_instance_released_on_another_thread_frees_the_acquirer(tmp_path: Path) -> None: shared = FileLock(tmp_path / "a.lock", thread_local=False) def acquire_fresh() -> None: with FileLock(tmp_path / "a.lock"): pass with ThreadPoolExecutor(max_workers=1) as acquirer: acquirer.submit(shared.acquire).result(_SHARED_WAIT) shared.release() acquirer.submit(acquire_fresh).result(_SHARED_WAIT) def test_shared_instance_release_behind_a_failed_acquire_is_a_no_op(tmp_path: Path) -> None: releasing = threading.Event() def release_from_another_thread() -> None: releasing.set() lock.release() releaser = threading.Thread(target=release_from_another_thread, daemon=True) def fail_after_starting_release(_: int) -> None: releaser.start() releasing.wait(_SHARED_WAIT) # The releaser saw the lock held and now waits on the transition this acquire still holds; give it time to # get there before the rollback frees the lock under it. releaser.join(0.2) msg = "hook failed" raise RuntimeError(msg) lock = FileLock(tmp_path / "a.lock", thread_local=False, on_acquired=fail_after_starting_release) with pytest.raises(RuntimeError, match="hook failed"): lock.acquire(timeout=_SHARED_WAIT) releaser.join(_SHARED_WAIT) assert (releaser.is_alive(), lock.lock_counter, lock.is_locked) == (False, 0, False) @pytest.fixture def paused_in_transition(tmp_path: Path) -> Generator[FileLock]: inside, resume = threading.Event(), threading.Event() def pause(_: int) -> None: inside.set() resume.wait(_SHARED_WAIT) lock = FileLock(tmp_path / "a.lock", thread_local=False, on_acquired=pause) holder = threading.Thread(target=lock.acquire, kwargs={"timeout": _SHARED_WAIT}, daemon=True) holder.start() assert inside.wait(_SHARED_WAIT) yield lock resume.set() holder.join(_SHARED_WAIT) assert not holder.is_alive() lock.release(force=True) @pytest.mark.parametrize("admission", ["timeout", "blocking", "cancel"]) def test_shared_instance_refuses_entry_during_another_threads_transition( paused_in_transition: FileLock, admission: Literal["timeout", "blocking", "cancel"] ) -> None: with pytest.raises(Timeout): _acquire_shared_with(paused_in_transition, admission) assert paused_in_transition.lock_counter == 1 def _acquire_shared_with(lock: FileLock, admission: Literal["timeout", "blocking", "cancel"]) -> None: if admission == "timeout": lock.acquire(timeout=0) elif admission == "blocking": lock.acquire(blocking=False) else: lock.acquire(cancel_check=lambda: True) def test_shared_instance_waiter_enters_once_the_transition_finishes(tmp_path: Path) -> None: inside, resume = threading.Event(), threading.Event() def pause(_: int) -> None: inside.set() resume.wait(_SHARED_WAIT) lock = FileLock(tmp_path / "a.lock", thread_local=False, on_acquired=pause) holder = threading.Thread(target=lock.acquire, kwargs={"timeout": _SHARED_WAIT}, daemon=True) holder.start() assert inside.wait(_SHARED_WAIT) # Let the waiter spin through several gate slices before the holder's transition ends. threading.Timer(0.1, resume.set).start() lock.acquire(timeout=_SHARED_WAIT, poll_interval=0.01) holder.join(_SHARED_WAIT) assert (holder.is_alive(), lock.lock_counter) == (False, 2) lock.release(force=True) def test_shared_instance_admission_wait_respects_the_caller_timeout(paused_in_transition: FileLock) -> None: start: Final = time.perf_counter() with pytest.raises(Timeout): paused_in_transition.acquire(timeout=0.05, poll_interval=_SHARED_WAIT) assert time.perf_counter() - start < _SHARED_WAIT / 2 @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_thread_local_setter_visibility(lock_type: type[BaseFileLock], tmp_path: Path) -> None: """Property setters stay per-thread when thread_local=True. A ``poll_interval`` set on the constructing thread stays invisible to other threads: ``threading.local`` re-applies the constructor arguments the first time each new thread touches the context, so the reader sees the default. """ lock = lock_type(tmp_path / "x.lock", thread_local=True, poll_interval=0.05) lock.poll_interval = 0.5 observed: list[float] = [] def read_from_thread() -> None: observed.append(lock.poll_interval) t = threading.Thread(target=read_from_thread) t.start() t.join() assert observed == [pytest.approx(0.05)] @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_non_thread_local_setter_visibility(lock_type: type[BaseFileLock], tmp_path: Path) -> None: """With thread_local=False, property setters are visible across threads.""" lock = lock_type(tmp_path / "x.lock", thread_local=False, poll_interval=0.05) lock.poll_interval = 0.5 observed: list[float] = [] t = threading.Thread(target=lambda: observed.append(lock.poll_interval)) t.start() t.join() assert observed == [pytest.approx(0.5)] @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_mode_is_read_only(lock_type: type[BaseFileLock], tmp_path: Path) -> None: """``mode`` has no setter, unlike the other per-thread configuration values. Reading it back afterwards separates a missing setter from a missing attribute; no ``match`` because the message differs across interpreters. """ lock = lock_type(tmp_path / "x.lock", mode=0o644) with pytest.raises(AttributeError): lock.mode = 0o600 # ty: ignore[invalid-assignment] # the absent setter is the contract under test assert lock.mode == 0o644 def test_subclass_compatibility(tmp_path: Path) -> None: class MyFileLock(FileLock): def __init__( self, lock_file: str | os.PathLike[str], timeout: float = -1, mode: int = 0o644, thread_local: bool = True, my_param: int = 0, **kwargs: dict[str, Any], # ruff:ignore[unused-method-argument] # matches the base constructor signature; extras are dropped ) -> None: 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__( self, lock_file: str | os.PathLike[str], timeout: float = -1, mode: int = 0o644, thread_local: bool = True, my_param: int = 0, **kwargs: dict[str, Any], # ruff:ignore[unused-method-argument] # matches the base constructor signature; extras are dropped ) -> None: super().__init__(lock_file, timeout, mode, thread_local, blocking=True, is_singleton=True) self.my_param = my_param MySoftFileLock(str(lock_path), my_param=1) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_singleton_and_non_singleton_locks_are_distinct(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_1 = lock_type(str(lock_path), is_singleton=False) assert lock_1.is_singleton is False lock_2 = lock_type(str(lock_path), is_singleton=True) assert lock_2.is_singleton is True assert lock_2 is not lock_1 @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_singleton_locks_are_the_same(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_1 = lock_type(str(lock_path), is_singleton=True) lock_2 = lock_type(str(lock_path), is_singleton=True) assert lock_2 is lock_1 def test_singleton_locks_survive_concurrent_first_construction(tmp_path: Path) -> None: # Two threads constructing the same is_singleton=True lock at once must still share one instance. A slow # __init__ widens the window between the cache miss and the store so the race is hit deterministically. lock_path = tmp_path / "a" class _SlowLock(SoftFileLock): def __init__(self, lock_file: str, *, is_singleton: bool = True) -> None: time.sleep(0.05) super().__init__(lock_file, is_singleton=is_singleton) results: list[BaseFileLock] = [] barrier = threading.Barrier(2) def build() -> None: barrier.wait() results.append(_SlowLock(str(lock_path), is_singleton=True)) threads = [threading.Thread(target=build) for _ in range(2)] for thread in threads: thread.start() for thread in threads: thread.join() assert results[0] is results[1] @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_singleton_locks_are_distinct_per_lock_file(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path_1 = tmp_path / "a" lock_1 = lock_type(str(lock_path_1), is_singleton=True) lock_path_2 = tmp_path / "b" lock_2 = lock_type(str(lock_path_2), is_singleton=True) assert lock_1 is not lock_2 @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_singleton_locks_must_be_initialized_with_the_same_args(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" args: dict[str, Any] = {"timeout": -1, "mode": 0o644, "thread_local": True, "blocking": True, "poll_interval": 0.05} alternate_args: dict[str, Any] = { "timeout": 10, "mode": 0o600, "thread_local": False, "blocking": False, "poll_interval": 0.1, } lock = lock_type(str(lock_path), is_singleton=True, **args) general_msg = "Singleton lock instances cannot be initialized with differing arguments" for arg_name in args: altered_args = args.copy() altered_args[arg_name] = alternate_args[arg_name] with pytest.raises(ValueError, match=general_msg) as exc_info: lock_type(str(lock_path), is_singleton=True, **altered_args) exc_info.match(arg_name) # ensure specific non-matching argument is included in exception text del lock, exc_info @pytest.mark.parametrize( ("first", "second"), [ pytest.param(-1, math.inf, id="inf"), pytest.param(-1, -5, id="other-negative"), pytest.param(math.nan, math.nan, id="nan"), pytest.param(Decimal("0.1"), Decimal("0.1"), id="decimal"), ], ) def test_singleton_accepts_a_timeout_that_waits_the_same(first: float, second: float, tmp_path: Path) -> None: lock: Final = FileLock(tmp_path / "a", is_singleton=True, timeout=first) assert FileLock(tmp_path / "a", is_singleton=True, timeout=second) is lock @NEEDS_PROMPT_FINALIZATION @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_singleton_locks_are_deleted_when_no_external_references_exist( lock_type: type[BaseFileLock], tmp_path: Path, ) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path), is_singleton=True) assert lock_type._instances == {str(lock_path): lock} del lock assert lock_type._instances == {} @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_singleton_instance_tracking_is_unique_per_subclass(lock_type: type[BaseFileLock]) -> None: class Lock1(lock_type): # ty: ignore[unsupported-base] pass class Lock2(lock_type): # ty: ignore[unsupported-base] pass assert isinstance(Lock1._instances, WeakValueDictionary) assert isinstance(Lock2._instances, WeakValueDictionary) assert Lock1._instances is not Lock2._instances def test_singleton_locks_when_inheriting_init_is_called_once(tmp_path: Path) -> None: init_calls = 0 class MyFileLock(FileLock): def __init__(self, *args: Any, **kwargs: Any) -> None: # ruff:ignore[any-type] # forwards arbitrary constructor arguments to super().__init__ super().__init__(*args, **kwargs) nonlocal init_calls init_calls += 1 lock_path = tmp_path / "a" lock1 = MyFileLock(str(lock_path), is_singleton=True) lock2 = MyFileLock(str(lock_path), is_singleton=True) assert lock1 is lock2 assert init_calls == 1 def test_file_lock_positional_argument(tmp_path: Path) -> None: class FilePathLock(FileLock): def __init__(self, file_path: str) -> None: super().__init__(file_path + ".lock") lock_path = tmp_path / "a" lock = FilePathLock(str(lock_path)) assert lock.lock_file == str(lock_path) + ".lock" @pytest.mark.skipif(sys.platform != "win32" and os.geteuid() == 0, reason="root can open a 0o444 file for writing") @pytest.mark.parametrize("lock_type", [SoftFileLock, FileLock]) def test_readonly_lock_file_with_mtime_zero_raises(lock_type: type[BaseFileLock], tmp_path: Path) -> None: # acquire() no longer short-circuits the writability check on mtime 0, so a read-only lock file is still rejected. lock_path = tmp_path / "z.lock" lock_path.touch() lock_path.chmod(0o444) os.utime(lock_path, (0, 0)) try: with pytest.raises(PermissionError): lock_type(str(lock_path)).acquire(timeout=0) finally: lock_path.chmod(0o644) @pytest.mark.parametrize("lock_type", [SoftFileLock]) def test_lock_file_removed_after_release(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock_path = tmp_path / "test.lock" lock = lock_type(str(lock_path)) with lock: assert lock_path.exists() assert not lock_path.exists() @NEEDS_FCNTL # pragma: needs fcntl def test_concurrent_acquire_release_keeps_lock_file(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" errors: list[Exception] = [] def worker() -> None: try: for _ in range(20): lock = FileLock(str(lock_path), is_singleton=False) with lock: pass except Exception as exc: # pragma: no cover # a healthy run never raises here errors.append(exc) threads = [threading.Thread(target=worker) for _ in range(4)] for thread in threads: thread.start() for thread in threads: thread.join(timeout=30) assert not errors, errors assert lock_path.exists() @NEEDS_FCNTL # pragma: needs fcntl def test_lock_acquired_after_release_keeps_path(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" first = FileLock(str(lock_path), is_singleton=False) second = FileLock(str(lock_path), is_singleton=False) first.acquire() assert lock_path.exists() first.release() assert lock_path.exists() second.acquire() assert lock_path.exists() second.release() assert lock_path.exists() @NEEDS_FCNTL # pragma: needs fcntl def test_waiter_fd_cannot_split_lock_after_release(tmp_path: Path) -> None: # typeshed hides fcntl's members off POSIX, so state the invariant the capability gate already enforces. assert sys.platform != "win32" import fcntl lock_path = tmp_path / "test.lock" first = FileLock(str(lock_path), is_singleton=False) replacement = FileLock(str(lock_path), is_singleton=False) first.acquire() waiter_fd = os.open(lock_path, os.O_RDWR) try: first.release() assert lock_path.exists() replacement.acquire() try: with pytest.raises(BlockingIOError) as exc_info: fcntl.flock(waiter_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) assert exc_info.value.errno in {EAGAIN, EWOULDBLOCK} finally: replacement.release() finally: os.close(waiter_fd) @NEEDS_FCNTL # pragma: needs fcntl def test_release_reentered_after_unlock_closes_descriptor_once(tmp_path: Path, mocker: MockerFixture) -> None: # typeshed hides fcntl's members off POSIX, so state the invariant the capability gate already enforces. assert sys.platform != "win32" import fcntl lock: Final = FileLock(str(tmp_path / "a.lock")) lock.acquire() real_flock: Final = fcntl.flock unlocked: Final[list[int]] = [] def unlock_then_reenter(fd: int, operation: int) -> None: real_flock(fd, operation) unlocked.append(fd) # A signal handler landing right after the kernel unlock runs this on the releasing thread. lock.release(force=True) mocker.patch("fcntl.flock", autospec=True, side_effect=unlock_then_reenter) close: Final = mocker.spy(os, "close") lock.release() assert ([call.args for call in close.call_args_list], lock.is_locked) == ([(unlocked[0],)], False) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock, MarkerSoftFileLock, SoftFileLease]) @pytest.mark.parametrize("thread_local", [pytest.param(True, id="thread_local"), pytest.param(False, id="shared")]) def test_release_interrupted_at_any_call_leaves_the_lock_usable( tmp_path: Path, lock_type: type[BaseFileLock], thread_local: bool ) -> None: # A raising signal handler lands at some call; wherever, a retry and one more hold must leave the path free. lock_path: Final = str(tmp_path / "a") for target in count(1): # pragma: no branch # count() never runs out; the release that finishes ends the loop lock = lock_type(lock_path, thread_local=thread_local, timeout=_SHARED_WAIT) lock.acquire() if not _interrupted_at_call(lock, target, lock.release): break lock.release() # Bounded first, so a lock the interrupt leaked fails here instead of hanging the blocking acquire below. with lock_type(lock_path, blocking=False) as peer: assert peer.is_locked # A new instance in this thread meets the deadlock check, which a leftover registry entry would trip. with lock_type(lock_path) as peer: assert peer.is_locked lock.acquire() lock.release() assert target > 1 @pytest.mark.parametrize( "lock_type", [ FileLock, SoftFileLock, MarkerSoftFileLock, SoftFileLease, pytest.param(StrictSoftFileLock, marks=pytest.mark.requires_hard_links), ], ) @pytest.mark.parametrize("thread_local", [pytest.param(True, id="thread_local"), pytest.param(False, id="shared")]) @pytest.mark.parametrize("operation", ["acquire", "release"]) def test_interrupted_at_any_call_reports_whether_it_holds( tmp_path: Path, lock_type: type[BaseFileLock], thread_local: bool, operation: Literal["acquire", "release"] ) -> None: # A with block makes no second call that could correct is_locked, so it must not report a hold the OS dropped. lock_path: Final = str(tmp_path / "a") for target in count(1): # pragma: no branch # count() never runs out; the call that finishes ends the loop lock = lock_type(lock_path, thread_local=thread_local) if operation == "release": lock.acquire() if not _interrupted_at_call(lock, target, getattr(lock, operation)): lock.release() break peer = lock_type(lock_path, blocking=False) if lock.is_locked: with pytest.raises(Timeout): peer.acquire() # Windows leaves a soft marker's unlink that the interrupt cut short to the instance's next call. lock.release() with peer: assert peer.is_locked assert target > 1 @pytest.mark.parametrize( "lock_type", [ FileLock, SoftFileLock, MarkerSoftFileLock, SoftFileLease, pytest.param(StrictSoftFileLock, marks=pytest.mark.requires_hard_links), ], ) @pytest.mark.parametrize("thread_local", [pytest.param(True, id="thread_local"), pytest.param(False, id="shared")]) def test_release_interrupted_below_release_completes( tmp_path: Path, lock_type: type[BaseFileLock], thread_local: bool ) -> None: lock_path: Final = str(tmp_path / "a") # Target 1 is the entry of release() itself, which no Python code can cover. for target in count(2): # pragma: no branch # count() never runs out; the release that finishes ends the loop lock = lock_type(lock_path, thread_local=thread_local) lock.acquire() if not _interrupted_at_call(lock, target, lock.release): lock.release() break assert not lock.is_locked with lock_type(lock_path, blocking=False) as peer: assert peer.is_locked assert target > 2 def test_acquire_interrupted_after_recording_the_hold_releases_it(tmp_path: Path) -> None: # The thread-hold bookkeeping after _mark_descriptor_owned stores the descriptor calls into threading, where a # signal can land too. lock_path: Final = str(tmp_path / "a") for target in count(1): # pragma: no branch # count() never runs out; the acquire that finishes ends the loop lock = FileLock(lock_path) if not _interrupted_at_call(lock, target, lock.acquire, callee=threading.current_thread.__code__): lock.release() break assert not lock.is_locked with FileLock(lock_path, blocking=False) as peer: assert peer.is_locked assert target > 1 def test_acquire_after_one_interrupted_once_held_excludes_another_process(tmp_path: Path) -> None: # The interrupt lands right after acquire takes and records the OS lock. The same object's next acquire must take # the OS lock again, not nest into a hold whose handle the cut-short acquire closed. lock_path: Final = str(tmp_path / "a") lock: Final = FileLock(lock_path) assert _interrupted_at_call(lock, 1, lock.acquire, callee=threading.current_thread.__code__) with lock: script = textwrap.dedent(f""" from filelock import FileLock, Timeout try: FileLock({lock_path!r}).acquire(blocking=False) except Timeout: print("refused") """) result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, check=False) assert (result.returncode, result.stdout.strip()) == (0, "refused"), result.stderr def _interrupted_at_call( lock: BaseFileLock, target: int, operation: Callable[[], object], *, callee: CodeType | None = None ) -> bool: # Interpreters run a profile hook with tracing off, so coverage never records its body. def interrupt(frame: FrameType, event: str, _arg: Unused) -> None: # pragma: no cover # Only calls handed the lock, or calls of callee made by one: raising inside threading's own context managers # would leak its locks. if event != "call" or (callee is not None and frame.f_code is not callee): return holder = frame if callee is None else frame.f_back if ( (resumable or frame not in entered) and holder is not None and any(v is lock for v in holder.f_locals.values()) ): entered.add(frame) if next(calls) == target: raise KeyboardInterrupt calls: Final = count(1) # Where the hook would end a resumed generator without its finally, only a fresh call models a signal. resumable: Final = CAPABILITIES["profile-interrupted-generator-finishes"] entered: Final[set[FrameType]] = set() sys.setprofile(interrupt) try: operation() except KeyboardInterrupt: return True finally: sys.setprofile(None) return False @NEEDS_PROMPT_SIGNAL_DELIVERY # pragma: needs prompt-signal-delivery def test_acquire_from_a_signal_handler_during_an_acquire_fails_fast(tmp_path: Path, mocker: MockerFixture) -> None: lock: Final = SoftFileLock(str(tmp_path / "a")) def acquire_in_handler(_signum: int, _frame: FrameType | None) -> None: lock.acquire() # A handler's acquire used to take the lock under the interrupted acquire, which then waited on that hold forever. mocker.patch("os.open", autospec=True, side_effect=lambda *_args, **_kwargs: signal.raise_signal(signal.SIGINT)) previous: Final = signal.signal(signal.SIGINT, acquire_in_handler) try: with pytest.raises(RuntimeError, match="acquired again while this thread's acquire of it waits"): lock.acquire() finally: signal.signal(signal.SIGINT, previous) mocker.stopall() lock.acquire(blocking=False) lock.release() assert not (tmp_path / "a").exists() @NEEDS_PROMPT_SIGNAL_DELIVERY # pragma: needs prompt-signal-delivery @pytest.mark.parametrize("thread_local", [pytest.param(True, id="thread_local"), pytest.param(False, id="shared")]) def test_release_interrupted_by_a_signal_before_the_backend_completes( tmp_path: Path, mocker: MockerFixture, thread_local: bool ) -> None: lock: Final = FileLock(str(tmp_path / "a"), thread_local=thread_local) lock.acquire() pending: Final = [signal.SIGINT] def signal_once(*_args: object) -> None: signals = pending.copy() pending.clear() list(map(signal.raise_signal, signals)) mocker.patch("filelock._api._LOGGER.debug", autospec=True, side_effect=signal_once) previous: Final = signal.signal(signal.SIGINT, signal.default_int_handler) try: with pytest.raises(KeyboardInterrupt): lock.release() finally: signal.signal(signal.SIGINT, previous) mocker.stopall() assert not lock.is_locked with FileLock(str(tmp_path / "a"), blocking=False) as peer: assert peer.is_locked @NEEDS_PROMPT_SIGNAL_DELIVERY # pragma: needs prompt-signal-delivery @pytest.mark.parametrize("thread_local", [pytest.param(True, id="thread_local"), pytest.param(False, id="shared")]) @pytest.mark.parametrize( "interrupted", [pytest.param("fcntl.flock", id="after_unlock", marks=NEEDS_FCNTL), pytest.param("os.close", id="after_close")], ) def test_acquire_from_a_signal_handler_during_a_release_is_refused( tmp_path: Path, mocker: MockerFixture, thread_local: bool, interrupted: str ) -> None: lock: Final = FileLock(str(tmp_path / "a"), thread_local=thread_local) lock.acquire() def acquire_in_handler(_signum: int, _frame: FrameType | None) -> None: lock.acquire() # Right after the unlock the handler used to nest into a hold the OS had dropped. After the close it took a hold # whose release the interrupted one swallowed. real: Final = resolve_name(interrupted) pending: Final = [signal.SIGINT] def call_then_signal_once(*args: int) -> None: real(*args) # Only the first call raises the signal, so the release the interrupt restarts can finish. Windows makes one # call here, so a branch on the call count would stay half covered there. signals = pending.copy() pending.clear() list(map(signal.raise_signal, signals)) mocker.patch(interrupted, autospec=True, side_effect=call_then_signal_once) previous: Final = signal.signal(signal.SIGINT, acquire_in_handler) try: with pytest.raises(RuntimeError, match="acquired again while this thread's release of it runs"): lock.release() finally: signal.signal(signal.SIGINT, previous) mocker.stopall() with FileLock(str(tmp_path / "a"), blocking=False) as peer: assert peer.is_locked @NEEDS_FCNTL # pragma: needs fcntl def test_stale_inode_retry_on_unlinked_lock(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "test.lock" lock = FileLock(str(lock_path), is_singleton=False) real_fstat = os.fstat call_count = 0 def fstat_unlinked_once(fd: int) -> os.stat_result: nonlocal call_count call_count += 1 if call_count == 1: Path(lock_path).unlink() return real_fstat(fd) return real_fstat(fd) mocker.patch("os.fstat", side_effect=fstat_unlinked_once) lock.acquire() assert lock.is_locked assert call_count == 2 lock.release() @NEEDS_FCNTL # pragma: needs fcntl def test_permission_error_fallback_without_o_creat(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "test.lock" lock_path.touch() lock = FileLock(str(lock_path), is_singleton=False) real_open = os.open call_count = 0 def open_no_creat(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: nonlocal call_count call_count += 1 if call_count == 1 and flags & os.O_CREAT: raise PermissionError(13, "Permission denied", path) return real_open(path, flags, mode) if dir_fd is None else real_open(path, flags, mode, dir_fd=dir_fd) mocker.patch("os.open", side_effect=open_no_creat) lock.acquire() assert lock.is_locked assert call_count == 2 lock.release() @NEEDS_FCNTL # pragma: needs fcntl def test_permission_error_propagates_when_file_missing(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "test.lock" lock = FileLock(str(lock_path), is_singleton=False) real_open = os.open def open_always_permission_error(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: if "test.lock" in path: raise PermissionError(13, "Permission denied", path) return ( # pragma: no cover # every os.open during this acquire targets the lock path real_open(path, flags, mode) if dir_fd is None else real_open(path, flags, mode, dir_fd=dir_fd) ) mocker.patch("os.open", side_effect=open_always_permission_error) with pytest.raises(PermissionError, match="Permission denied"): lock.acquire(timeout=0) @NEEDS_FCNTL # pragma: needs fcntl def test_sticky_bit_fallback_handles_concurrent_unlink(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "test.lock" lock_path.touch() lock = FileLock(str(lock_path), is_singleton=False) real_open = os.open denied_create = False unlinked_open = False # Key the injections off each open's signature rather than a global call count: GraalPy opens unrelated paths # mid-acquire, which would shift a counter and land the errors on the wrong opens. Deny the first create, then # unlink under the first no-create open, so the sticky-bit fallback must retry once more to succeed. def open_permission_then_unlink(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: nonlocal denied_create, unlinked_open if not denied_create and flags & os.O_CREAT and "test.lock" in path: denied_create = True raise PermissionError(13, "Permission denied", path) if not unlinked_open and not flags & os.O_CREAT and "test.lock" in path: unlinked_open = True lock_path.unlink(missing_ok=True) raise FileNotFoundError(2, "No such file or directory", path) return real_open(path, flags, mode) if dir_fd is None else real_open(path, flags, mode, dir_fd=dir_fd) mocker.patch("os.open", side_effect=open_permission_then_unlink) lock.acquire() assert lock.is_locked assert (denied_create, unlinked_open) == (True, True) lock.release() @NEEDS_FCNTL # pragma: needs fcntl def test_existing_lock_file_open_that_would_block_is_contention(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "test.lock" lock_path.touch() real_open = os.open def open_would_block(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: if not flags & os.O_CREAT and "test.lock" in path: raise BlockingIOError(EWOULDBLOCK, "Resource temporarily unavailable", path) return real_open(path, flags, mode, dir_fd=dir_fd) mocker.patch("os.open", side_effect=open_would_block) with pytest.raises(Timeout): FileLock(str(lock_path), is_singleton=False).acquire(timeout=0) _SKIP_AS_ROOT: Final[pytest.MarkDecorator] = pytest.mark.skipif( sys.platform != "win32" and os.geteuid() == 0, reason="root can write a 0o400 file" ) _NEEDS_FILE_LEASE: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["file-lease"], reason="leasing the lock file needs Linux F_SETLEASE on a filesystem that grants it" ) @pytest.fixture def leased_lock_path(tmp_path: Path) -> Iterator[Path]: # pragma: needs file-lease # typeshed hides fcntl's members off POSIX, so state the invariant the capability gate already enforces. assert sys.platform != "win32" import fcntl lock_path = tmp_path / "test.lock" # The kernel asks the holder to give the lease up with SIGIO, which kills by default; ignoring it keeps the lease # for fs.lease-break-time, which is how long a blocking open would sleep. previous_handler = signal.signal(signal.SIGIO, signal.SIG_IGN) lease_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT) try: fcntl.fcntl(lease_fd, fcntl.F_SETLEASE, fcntl.F_WRLCK) yield lock_path finally: os.close(lease_fd) signal.signal(signal.SIGIO, previous_handler) @_NEEDS_FILE_LEASE # pragma: needs file-lease @pytest.mark.parametrize( ("timeout", "blocking"), [pytest.param(0.1, True, id="timeout"), pytest.param(-1, False, id="non-blocking")] ) def test_lease_on_lock_file_does_not_outlast_deadline( leased_lock_path: Path, timeout: float, *, blocking: bool ) -> None: started = time.monotonic() with pytest.raises(Timeout): FileLock(str(leased_lock_path), is_singleton=False).acquire(timeout=timeout, blocking=blocking) assert time.monotonic() - started < 5 @_NEEDS_FILE_LEASE # pragma: needs file-lease @_SKIP_AS_ROOT def test_lease_on_a_read_only_lock_file_is_contention(leased_lock_path: Path) -> None: leased_lock_path.chmod(0o400) started = time.monotonic() with pytest.raises(Timeout): FileLock(str(leased_lock_path), is_singleton=False).acquire(timeout=0.1) assert time.monotonic() - started < 5 @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_cancel_check_triggers(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_1 = lock_type(str(lock_path)) lock_2 = lock_type(str(lock_path)) lock_1.acquire() with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): lock_2.acquire(timeout=1, cancel_check=lambda: True) assert not lock_2.is_locked lock_1.release() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_cancel_check_after_n_polls(lock_type: type[BaseFileLock], tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "a" lock_1 = lock_type(str(lock_path)) lock_2 = lock_type(str(lock_path)) lock_1.acquire() call_count = 0 def cancel_after_two() -> bool: nonlocal call_count call_count += 1 return call_count >= 2 mocker.patch( "filelock._api.time", new=SimpleNamespace(perf_counter=time.perf_counter, sleep=mocker.create_autospec(time.sleep)), ) with pytest.raises(Timeout): lock_2.acquire(timeout=10, cancel_check=cancel_after_two) assert call_count == 2 assert not lock_2.is_locked lock_1.release() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_cancel_check_not_called_when_lock_available(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) called = False def should_not_be_called() -> bool: # pragma: no cover # a free lock never consults cancel_check nonlocal called called = True return True lock.acquire(cancel_check=should_not_be_called) assert lock.is_locked assert not called lock.release() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_cancel_check_false_allows_acquisition(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) lock.acquire(cancel_check=lambda: False) assert lock.is_locked lock.release() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_cancel_check_log_message( lock_type: type[BaseFileLock], tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: caplog.set_level(logging.DEBUG) lock_path = tmp_path / "a" lock_1 = lock_type(str(lock_path)) lock_2 = lock_type(str(lock_path)) lock_1.acquire() with pytest.raises(Timeout): lock_2.acquire(timeout=1, cancel_check=lambda: True) assert any("Cancellation requested" in msg for msg in caplog.messages) lock_1.release() @NEEDS_FCNTL # pragma: needs fcntl def test_filenotfound_on_fuse_nfs_retries(tmp_path: Path, mocker: MockerFixture) -> None: """Retry recovers from the FUSE/NFS os.open(O_CREAT) race that raises FileNotFoundError.""" lock_path = tmp_path / "test.lock" lock = FileLock(str(lock_path), is_singleton=False) real_open = os.open call_count = 0 def open_enoent_then_succeed(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: nonlocal call_count call_count += 1 if call_count == 1 and flags & os.O_CREAT and "test.lock" in path: # first O_CREAT hits the FUSE/NFS ENOENT raise FileNotFoundError(2, "No such file or directory", path) return real_open(path, flags, mode) if dir_fd is None else real_open(path, flags, mode, dir_fd=dir_fd) mocker.patch("os.open", side_effect=open_enoent_then_succeed) lock.acquire() assert lock.is_locked assert call_count >= 2 lock.release() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_same_thread_different_instances_raises(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) with lock1: lock2 = lock_type(lock_path) with pytest.raises(RuntimeError, match="Deadlock"): lock2.acquire() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_finite_timeout_gives_timeout_not_deadlock(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) with lock1: lock2 = lock_type(lock_path, timeout=0.1) with pytest.raises(Timeout): lock2.acquire() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_infinite_timeout_gives_deadlock_not_hang(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock_path: Final = tmp_path / "test.lock" with lock_type(lock_path): lock: Final = lock_type(lock_path, timeout=float("inf")) with pytest.raises(RuntimeError, match="Deadlock"): lock.acquire() _NANS: Final = [ pytest.param(math.nan, id="nan"), pytest.param(Decimal("sNaN"), id="signaling-nan"), ] @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.parametrize("from_default", [pytest.param(True, id="default"), pytest.param(False, id="argument")]) @pytest.mark.parametrize("nan", _NANS) def test_blocking_acquire_rejects_nan_timeout( lock_type: type[BaseFileLock], from_default: bool, nan: float, tmp_path: Path ) -> None: lock: Final = lock_type(tmp_path / "a", timeout=nan if from_default else -1) with pytest.raises(ValueError, match="not nan"): lock.acquire(timeout=None if from_default else nan) assert not lock.is_locked @pytest.mark.parametrize( "timeout", [ pytest.param("5", id="str"), pytest.param(None, id="none"), pytest.param([1], id="list"), pytest.param(True, id="bool"), ], ) def test_constructor_rejects_non_number_timeout(timeout: str | list[int] | bool | None, tmp_path: Path) -> None: with pytest.raises(TypeError, match="timeout must be a number of seconds"): FileLock(tmp_path / "a", timeout=timeout) # ty: ignore[invalid-argument-type] # the rejected type is the contract @pytest.mark.parametrize( "blocking", [pytest.param("no", id="str"), pytest.param(0, id="int"), pytest.param(None, id="none")] ) def test_constructor_rejects_non_bool_blocking(blocking: str | int | None, tmp_path: Path) -> None: with pytest.raises(TypeError, match="blocking must be a bool"): FileLock(tmp_path / "a", blocking=blocking) # ty: ignore[invalid-argument-type] # the rejected type is the contract @pytest.mark.parametrize("timeout", [pytest.param(None, id="none"), pytest.param(True, id="bool")]) def test_timeout_setter_rejects_non_number(timeout: bool | None, tmp_path: Path) -> None: lock: Final = FileLock(tmp_path / "a", timeout=3) with pytest.raises(TypeError, match="timeout must be a number of seconds"): lock.timeout = timeout # ty: ignore[invalid-assignment] # the rejected type is the contract assert lock.timeout == 3 @pytest.mark.parametrize("timeout", [pytest.param("2.5", id="str"), pytest.param(b"2.5", id="bytes")]) def test_timeout_setter_still_takes_a_numeric_string(timeout: str | bytes, tmp_path: Path) -> None: lock: Final = FileLock(tmp_path / "a") with pytest.deprecated_call(match="setting timeout from a string is deprecated"): lock.timeout = timeout assert (lock.timeout, type(lock.timeout)) == (2.5, float) def test_non_blocking_acquire_still_rejects_a_bool_timeout(tmp_path: Path) -> None: with pytest.raises(TypeError, match="timeout must be a number of seconds, not bool"): FileLock(tmp_path / "a").acquire(timeout=True, blocking=False) @pytest.mark.parametrize( "timeout", [pytest.param(Decimal("0.25"), id="decimal"), pytest.param(Fraction(1, 4), id="fraction")] ) def test_timeout_takes_any_real_number(timeout: float, tmp_path: Path) -> None: lock: Final = FileLock(tmp_path / "a", timeout=timeout) assert (type(lock.timeout), lock.timeout) == (float, 0.25) @pytest.mark.parametrize( "timeout", [pytest.param(Decimal("-1E-400"), id="decimal"), pytest.param(Fraction(-1, 10**400), id="fraction")], ) def test_timeout_too_small_for_a_float_stays_negative(timeout: float, tmp_path: Path) -> None: # A negative timeout waits without a limit, which rounding to -0.0 would turn into a single attempt. assert FileLock(tmp_path / "a", timeout=timeout).timeout < 0 def test_timeout_too_large_for_a_float_raises_value_error(tmp_path: Path) -> None: with pytest.raises(ValueError, match="timeout is too large to be a number of seconds"): FileLock(tmp_path / "a", timeout=10**400) def test_timeout_setter_stores_a_float(tmp_path: Path) -> None: lock: Final = FileLock(tmp_path / "a") lock.timeout = 2 assert (lock.timeout, type(lock.timeout)) == (2.0, float) def test_blocking_setter_rejects_non_bool(tmp_path: Path) -> None: lock: Final = FileLock(tmp_path / "a") with pytest.raises(TypeError, match="blocking must be a bool"): lock.blocking = "no" # ty: ignore[invalid-assignment] # the rejected type is the contract assert lock.blocking is True @pytest.mark.parametrize("blocking", [pytest.param("false", id="str"), pytest.param(0, id="int")]) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_acquire_rejects_non_bool_blocking(lock_type: type[BaseFileLock], blocking: str | int, tmp_path: Path) -> None: lock: Final = lock_type(tmp_path / "a") with pytest.raises(TypeError, match="blocking must be a bool"): lock.acquire(blocking=blocking) # ty: ignore[invalid-argument-type] # the rejected type is the contract assert (lock.lock_counter, lock.is_locked) == (0, False) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.parametrize("nan", _NANS) def test_non_blocking_acquire_ignores_nan_timeout(lock_type: type[BaseFileLock], nan: float, tmp_path: Path) -> None: lock: Final = lock_type(tmp_path / "a") with lock.acquire(timeout=nan, blocking=False): assert lock.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_poll_wait_stays_within_timeout(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock_path: Final = tmp_path / "test.lock" with lock_type(lock_path): start: Final = time.perf_counter() with pytest.raises(Timeout): lock_type(lock_path).acquire(timeout=0.05, poll_interval=10) assert time.perf_counter() - start < 5 @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_non_blocking_gives_timeout_not_deadlock(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) with lock1: lock2 = lock_type(lock_path, blocking=False) with pytest.raises(Timeout): lock2.acquire() @pytest.mark.parametrize( "mode", [pytest.param("finite", id="finite"), pytest.param("nonblocking", id="nonblocking")], ) def test_failed_acquire_keeps_holder_registered(tmp_path: Path, mode: Literal["finite", "nonblocking"]) -> None: lock_path = tmp_path / "test.lock" with FileLock(lock_path): with pytest.raises(Timeout): _acquire_for_mode(FileLock(lock_path), mode) with pytest.raises(RuntimeError, match="Deadlock"): FileLock(lock_path).acquire(cancel_check=lambda: True) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_different_paths_no_conflict(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock1 = lock_type(tmp_path / "a.lock") lock2 = lock_type(tmp_path / "b.lock") with lock1, lock2: assert lock1.is_locked assert lock2.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_same_instance_reentrant_works(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock = lock_type(tmp_path / "test.lock") with lock: with lock: assert lock.is_locked assert lock.is_locked assert not lock.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_singleton_avoids_deadlock(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path, is_singleton=True) with lock1: lock2 = lock_type(lock_path, is_singleton=True) assert lock1 is lock2 with lock2: assert lock2.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_different_threads_no_false_positive(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path, timeout=0) lock1.acquire() error: BaseException | None = None def acquire_in_thread() -> None: nonlocal error lock2 = lock_type(lock_path, timeout=0) try: lock2.acquire() except BaseException as exc: error = exc thread = threading.Thread(target=acquire_in_thread) thread.start() thread.join() lock1.release() assert not isinstance(error, RuntimeError), "Should not raise RuntimeError in different thread" _NEEDS_O_NOFOLLOW: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["o-nofollow"], reason="refusing to open through a final symlink needs a honored O_NOFOLLOW" ) @NEEDS_PARENT_SYMLINK_COLLAPSE # pragma: win32 no cover @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_symlink_same_canonical_path(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: # A symlinked parent directory resolves to the same canonical key (the final component is kept literal, so a final # symlink stays distinct — see test_final_symlink_stays_a_distinct_key). real_dir = tmp_path / "real" real_dir.mkdir() (tmp_path / "link").symlink_to(real_dir) lock1 = lock_type(str(real_dir / "test.lock")) with lock1: lock2 = lock_type(str(tmp_path / "link" / "test.lock")) with pytest.raises(RuntimeError, match="Deadlock"): lock2.acquire() @NEEDS_PARENT_SYMLINK_COLLAPSE # pragma: win32 no cover @pytest.mark.parametrize( ("depth", "force"), [ pytest.param(1, False, id="direct"), pytest.param(2, False, id="nested"), pytest.param(2, True, id="forced"), ], ) def test_release_drops_acquisition_key_after_parent_retarget(tmp_path: Path, depth: int, force: bool) -> None: lock_path, original_path, replacement_path = _symlinked_lock_paths(tmp_path) lock = FileLock(lock_path) for _depth in range(depth): lock.acquire() _retarget_parent(lock_path, replacement_path) if force: lock.release(force=True) else: for _depth in range(depth): lock.release() with FileLock(original_path) as successor: assert successor.is_locked @NEEDS_PARENT_SYMLINK_COLLAPSE # pragma: win32 no cover def test_release_keeps_retargeted_parent_holder_registered(tmp_path: Path) -> None: lock_path, _original_path, replacement_path = _symlinked_lock_paths(tmp_path) original = FileLock(lock_path) original.acquire() _retarget_parent(lock_path, replacement_path) with FileLock(replacement_path): original.release() with pytest.raises(RuntimeError, match="Deadlock"): FileLock(replacement_path).acquire(cancel_check=lambda: True) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_deadlock_registry_cleanup_on_release(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) lock1.acquire() lock1.release() lock2 = lock_type(lock_path) with lock2: assert lock2.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_force_release_cleans_registry(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) with lock1: lock1.acquire() assert lock1.lock_counter == 2 lock1.release(force=True) lock2 = lock_type(lock_path) with lock2: assert lock2.is_locked def _symlinked_lock_paths(tmp_path: Path) -> tuple[Path, Path, Path]: # pragma: win32 no cover original = tmp_path / "original" replacement = tmp_path / "replacement" original.mkdir() replacement.mkdir() link = tmp_path / "link" link.symlink_to(original, target_is_directory=True) return link / "test.lock", original / "test.lock", replacement / "test.lock" def _retarget_parent(lock_path: Path, replacement_path: Path) -> None: # pragma: win32 no cover link = lock_path.parent link.unlink() link.symlink_to(replacement_path.parent, target_is_directory=True) def _acquire_for_mode(lock: BaseFileLock, mode: Literal["finite", "nonblocking"]) -> None: if mode == "finite": lock.acquire(timeout=0) else: lock.acquire(blocking=False) @pytest.fixture def held_lock_path(tmp_path: Path) -> Iterator[Path]: # pragma: needs fcntl path = tmp_path / "resource.lock" with FileLock(path, timeout=0): yield path @NEEDS_FCNTL # pragma: needs fcntl @pytest.mark.parametrize("mode", [pytest.param(-1, id="default-mode"), pytest.param(0o666, id="explicit-mode")]) def test_winner_leaves_existing_file_as_found(tmp_path: Path, mode: int) -> None: (journal := tmp_path / "journal.log").write_text("data", encoding="utf-8") journal.chmod(0o600) # A regular file with one name is indistinguishable from a lock file an earlier holder left. journal.rename(lock_path := tmp_path / "resource.lock") with FileLock(lock_path, timeout=0, mode=mode): assert (lock_path.read_text(encoding="utf-8"), S_IMODE(lock_path.stat().st_mode)) == ("data", 0o600) @NEEDS_FCNTL # pragma: needs fcntl def test_creator_that_loses_the_flock_applies_mode(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" # A peer may win the flock first, and only the creator may set the mode. mocker.patch("filelock._unix.fcntl.flock", side_effect=OSError(EWOULDBLOCK, "Resource temporarily unavailable")) initial_umask = os.umask(0o077) try: with pytest.raises(Timeout): FileLock(lock_path, timeout=0, mode=0o666).acquire() finally: os.umask(initial_umask) assert S_IMODE(lock_path.stat().st_mode) == 0o666 @NEEDS_FCNTL # pragma: needs fcntl def test_contender_preserves_holder_contents(held_lock_path: Path) -> None: held_lock_path.write_text("holder metadata", encoding="utf-8") with pytest.raises(Timeout): FileLock(held_lock_path, timeout=0).acquire() assert held_lock_path.read_text(encoding="utf-8") == "holder metadata" @NEEDS_FCNTL # pragma: needs fcntl def test_contender_preserves_holder_mode(held_lock_path: Path) -> None: held_lock_path.chmod(0o600) with pytest.raises(Timeout): FileLock(held_lock_path, timeout=0, mode=0o644).acquire() assert S_IMODE(held_lock_path.stat().st_mode) == 0o600 @NEEDS_FCNTL # pragma: needs fcntl def test_contender_does_not_fchmod(held_lock_path: Path, mocker: MockerFixture) -> None: fchmod_spy = mocker.spy(os, "fchmod") with pytest.raises(Timeout): FileLock(held_lock_path, timeout=0, mode=0o644).acquire() fchmod_spy.assert_not_called() @NEEDS_FCNTL # pragma: needs fcntl @pytest.mark.parametrize("failing", [pytest.param("fchmod", id="fchmod"), pytest.param("fstat", id="fstat")]) def test_post_open_failure_closes_fd(tmp_path: Path, mocker: MockerFixture, failing: str) -> None: open_spy = mocker.spy(os, "open") close_spy = mocker.spy(os, "close") real: Final = getattr(os, failing) def fail_for_the_lock_file(fd: int, *args: int) -> os.stat_result | None: if fd == open_spy.spy_return: raise OSError(EIO, "Input/output error") return real(fd, *args) mocker.patch(f"os.{failing}", side_effect=fail_for_the_lock_file) with pytest.raises(OSError, match="Input/output error"): FileLock(tmp_path / "resource.lock", timeout=0, mode=0o644).acquire() assert any(call.args and call.args[0] == open_spy.spy_return for call in close_spy.call_args_list) # Only a Windows run that also holds the symlink privilege reaches this body, so both exclusions apply. @_WINDOWS_ONLY # pragma: win32 cover # pragma: needs symlink @NEEDS_SYMLINK def test_windows_reparse_point_lock_file_rejected(tmp_path: Path) -> None: target = tmp_path / "target.txt" target.write_text("sensitive", encoding="utf-8") link = tmp_path / "resource.lock" link.symlink_to(target) with pytest.raises(OSError, match="reparse point"): FileLock(link).acquire() assert target.read_text(encoding="utf-8") == "sensitive" def test_release_keeps_lock_until_final_hold(tmp_path: Path) -> None: lock = FileLock(str(tmp_path / "a")) lock.acquire() lock.acquire() lock.release() assert lock.is_locked assert lock.lock_counter == 1 lock.release() assert not lock.is_locked assert lock.lock_counter == 0 def test_forced_release_drops_all_holds(tmp_path: Path) -> None: lock = FileLock(str(tmp_path / "a")) lock.acquire() lock.acquire() lock.release(force=True) assert not lock.is_locked assert lock.lock_counter == 0 @NEEDS_FCNTL # pragma: needs fcntl def test_unix_release_keeps_lock_held_when_unlock_fails(tmp_path: Path, mocker: MockerFixture) -> None: lock = FileLock(str(tmp_path / "a")) lock.acquire() mocker.patch("filelock._unix.fcntl.flock", side_effect=[OSError(EIO, "unlock failed"), None]) with pytest.raises(OSError, match="unlock failed"): lock.release() assert lock.is_locked assert lock.lock_counter == 1 lock.release() assert not lock.is_locked @NEEDS_FCNTL # pragma: needs fcntl def test_unix_context_exit_propagates_unlock_failure(tmp_path: Path, mocker: MockerFixture) -> None: # One LOCK_EX for acquire, then one LOCK_UN per release; fail only the first unlock so __exit__ propagates it. mocker.patch("filelock._unix.fcntl.flock", side_effect=[None, OSError(EIO, "unlock failed"), None]) lock = FileLock(str(tmp_path / "a")) with pytest.raises(OSError, match="unlock failed"), lock: assert lock.is_locked assert lock.is_locked lock.release() assert not lock.is_locked @_WINDOWS_ONLY # pragma: win32 cover def test_windows_release_keeps_lock_held_when_unlock_fails(tmp_path: Path, mocker: MockerFixture) -> None: lock = FileLock(str(tmp_path / "a")) lock.acquire() mocker.patch("filelock._windows._unlock_fd", side_effect=[OSError(EIO, "unlock failed"), None]) with pytest.raises(OSError, match="unlock failed"): lock.release() assert lock.is_locked assert lock.lock_counter == 1 lock.release() assert not lock.is_locked @_WINDOWS_ONLY # pragma: win32 cover def test_windows_close_failure_still_commits_release(tmp_path: Path, mocker: MockerFixture) -> None: lock = FileLock(str(tmp_path / "a")) lock.acquire() # The OS unlock succeeds, so the lock is released; the later close failure propagates but must not leave the # counter or registry believing the lock is still held. mocker.patch("filelock._windows.os.close", side_effect=OSError(EIO, "close failed")) with pytest.raises(OSError, match="close failed"): lock.release() assert not lock.is_locked assert lock.lock_counter == 0 @_WINDOWS_ONLY # pragma: win32 cover def test_windows_delete_pending_is_treated_as_contention(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._windows._nt_open", return_value=(0, 0xC0000056)) # STATUS_DELETE_PENDING with pytest.raises(Timeout): FileLock(str(tmp_path / "a"), timeout=0.2).acquire() @_WINDOWS_ONLY # pragma: win32 cover def test_windows_permanent_denial_raises_without_timeout(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._windows._ACCESS_DENIED_PROBES", 10) nt_open = mocker.patch("filelock._windows._nt_open", return_value=(0, 0xC0000022)) # STATUS_ACCESS_DENIED with pytest.raises(PermissionError): FileLock(str(tmp_path / "a"), timeout=5).acquire() # The denial outlasted the probes, so it surfaced after them rather than at the caller's timeout. assert nt_open.call_count == 11 @_WINDOWS_ONLY # pragma: win32 cover def test_windows_transient_denial_is_retried_within_grace(tmp_path: Path, mocker: MockerFixture) -> None: # A peer unlinking the lock file as it releases can make NtCreateFile answer STATUS_ACCESS_DENIED for a moment, # which the open must wait out instead of raising a PermissionError. timeout=0 leaves the acquire loop one attempt, # so the retry has to come from the open itself. The marker skips the run; the guard only narrows the import for # ty, so it goes one way. if sys.platform == "win32": # pragma: no branch from filelock._windows import _nt_open as real_nt_open statuses = [0xC0000022] def deny_once(path: str, *, read_only: bool, owner_only: bool) -> tuple[int, int]: if statuses: return 0, statuses.pop() return real_nt_open(path, read_only=read_only, owner_only=owner_only) nt_open = mocker.patch("filelock._windows._nt_open", side_effect=deny_once) lock = FileLock(str(tmp_path / "a"), timeout=0) lock.acquire() assert (lock.is_locked, nt_open.call_count) == (True, 2) lock.release() @_WINDOWS_ONLY # pragma: win32 cover def test_windows_delete_in_progress_is_contention_not_denial(tmp_path: Path) -> None: # The marker skips the run; this only narrows ctypes.windll for ty, so it goes one way. if sys.platform == "win32": # pragma: no branch import ctypes target = str(tmp_path / "dp.lock") kernel32 = ctypes.windll.kernel32 delete_access, generic_read, share_all, create_always, delete_on_close = ( 0x10000, 0x80000000, 0x1 | 0x2 | 0x4, 2, 0x04000000, ) # Open with delete-on-close so the name is marked for deletion but the live handle keeps it around: a fresh # open sees the deletion in progress (delete-pending or a sharing violation), which acquire must retry rather # than mistake for a permanent PermissionError. handle = kernel32.CreateFileW( target, delete_access | generic_read, share_all, None, create_always, delete_on_close, None ) assert handle not in {0, ctypes.c_void_p(-1).value} try: with pytest.raises(Timeout): FileLock(target, timeout=0.3).acquire() finally: kernel32.CloseHandle(handle) @_WINDOWS_ONLY # pragma: win32 cover @pytest.mark.parametrize( ("mode", "inherits"), [ pytest.param(-1, True, id="unset"), pytest.param(0o644, True, id="others-read"), pytest.param(0o600, False, id="owner-only"), ], ) def test_windows_owner_only_mode_keeps_out_the_directory_acl( inheriting_directory: Path, mode: int, *, inherits: bool ) -> None: lock_path = inheriting_directory / "a" with FileLock(str(lock_path), mode=mode): acl = subprocess.run( [Path(os.environ["SYSTEMROOT"], "System32", "icacls.exe"), lock_path], capture_output=True, check=True ).stdout assert (b"(I)" in acl) is inherits # icacls marks inherited entries with (I) in every display language @_WINDOWS_ONLY # pragma: win32 cover @pytest.mark.parametrize( ("timeout", "blocking"), [pytest.param(0.1, True, id="timeout"), pytest.param(-1, False, id="non-blocking")] ) def test_windows_oplock_on_lock_file_does_not_outlast_deadline( oplocked_lock_path: Path, timeout: float, *, blocking: bool ) -> None: started = time.monotonic() with pytest.raises(Timeout): FileLock(str(oplocked_lock_path), is_singleton=False).acquire(timeout=timeout, blocking=blocking) assert time.monotonic() - started < 5 @_WINDOWS_ONLY # pragma: win32 cover def test_windows_release_does_not_wait_for_an_oplock_taken_after_the_close( tmp_path: Path, hold_oplock: Callable[[Path], Callable[[], None]], mocker: MockerFixture ) -> None: lock_path = tmp_path / "a" lock = FileLock(str(lock_path), is_singleton=False, thread_local=False) real_close = BaseFileLock._close_released_fd def close_then_take_oplock(self: BaseFileLock, fd: int, *, default_suppresses: bool) -> None: real_close(self, fd, default_suppresses=default_suppresses) hold_oplock(lock_path) mocker.patch.object(BaseFileLock, "_close_released_fd", autospec=True, side_effect=close_then_take_oplock) lock.acquire() assert call_within(lock.release, 5) == (True, None) @_WINDOWS_ONLY # pragma: win32 cover @pytest.mark.parametrize("name", [pytest.param("missing", id="name"), pytest.param("missing/a", id="path")]) def test_windows_unlink_without_oplock_wait_reports_a_missing_file(tmp_path: Path, name: str) -> None: assert sys.platform == "win32" # the marker skips the run elsewhere; this narrows the import for ty from filelock._windows import unlink_without_oplock_wait with pytest.raises(FileNotFoundError): unlink_without_oplock_wait(str(tmp_path / name)) @_WINDOWS_ONLY # pragma: win32 cover def test_windows_unlink_without_oplock_wait_reports_a_file_held_without_delete_sharing(tmp_path: Path) -> None: assert sys.platform == "win32" # the marker skips the run elsewhere; this narrows the import for ty from filelock._windows import unlink_without_oplock_wait (path := tmp_path / "a").touch() fd = os.open(path, os.O_RDONLY) try: with pytest.raises(PermissionError): unlink_without_oplock_wait(str(path)) finally: os.close(fd) @_WINDOWS_ONLY # pragma: win32 cover def test_windows_unlink_without_oplock_wait_leaves_an_oplocked_file( tmp_path: Path, hold_oplock: Callable[[Path], Callable[[], None]] ) -> None: assert sys.platform == "win32" # the marker skips the run elsewhere; this narrows the import for ty from filelock._windows import unlink_without_oplock_wait (path := tmp_path / "a").touch() hold_oplock(path) assert call_within(lambda: unlink_without_oplock_wait(str(path)), 5) == (True, None) @_WINDOWS_ONLY # pragma: win32 cover @NEEDS_SYMLINK # pragma: needs symlink def test_windows_unlink_without_oplock_wait_deletes_a_symlink_not_its_target(tmp_path: Path) -> None: assert sys.platform == "win32" # the marker skips the run elsewhere; this narrows the import for ty from filelock._windows import unlink_without_oplock_wait (target := tmp_path / "target").touch() (link := tmp_path / "link").symlink_to(target) unlink_without_oplock_wait(str(link)) assert (link.is_symlink(), target.exists()) == (False, True) @_WINDOWS_ONLY # pragma: win32 cover def test_windows_open_read_without_oplock_wait_reads_the_file(tmp_path: Path) -> None: assert sys.platform == "win32" # the marker skips the run elsewhere; this narrows the import for ty from filelock._windows import open_read_without_oplock_wait (path := tmp_path / "a").write_bytes(b"marker") fd = open_read_without_oplock_wait(str(path)) try: assert os.read(fd, 16) == b"marker" finally: os.close(fd) @_WINDOWS_ONLY # pragma: win32 cover def test_windows_open_read_without_oplock_wait_reports_a_missing_file(tmp_path: Path) -> None: assert sys.platform == "win32" # the marker skips the run elsewhere; this narrows the import for ty from filelock._windows import open_read_without_oplock_wait with pytest.raises(FileNotFoundError): open_read_without_oplock_wait(str(tmp_path / "missing")) @_WINDOWS_ONLY # pragma: win32 cover @NEEDS_SYMLINK # pragma: needs symlink def test_windows_open_read_without_oplock_wait_refuses_a_symlink(tmp_path: Path) -> None: assert sys.platform == "win32" # the marker skips the run elsewhere; this narrows the import for ty from filelock._windows import open_read_without_oplock_wait (target := tmp_path / "target").touch() (link := tmp_path / "link").symlink_to(target) with pytest.raises(OSError, match="reparse point"): open_read_without_oplock_wait(str(link)) @_WINDOWS_ONLY # pragma: win32 cover @pytest.mark.parametrize("opener", ["open_read_without_oplock_wait", "open_for_delete_without_oplock_wait"]) def test_windows_open_without_oplock_wait_reports_the_break_as_contention( oplocked_lock_path: Path, opener: str ) -> None: import filelock._windows as windows finished, error = call_within(lambda: getattr(windows, opener)(str(oplocked_lock_path)), 5) assert (finished, type(error)) == (True, BlockingIOError) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) def test_context_group_detaches_release_context( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], *, use_proxy: bool, ) -> None: capture, release_error, release_cause = close_failure body_error = ValueError("body failed") body_cause = LookupError("body cause") lock = FileLock(str(tmp_path / "a"), context_error_policy="group", close_error_policy="raise", on_acquired=capture) with pytest.raises(ExceptionGroup) as info, lock.acquire() if use_proxy else lock: raise body_error from body_cause assert ( info.value.exceptions, body_error.__context__, release_error.__context__, body_error.__cause__, release_error.__cause__, body_error.__traceback__ is not None, release_error.__traceback__ is not None, ) == ((body_error, release_error), None, None, body_cause, release_cause, True, True) def test_context_group_handles_deep_body_context( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], ) -> None: capture, release_error, _ = close_failure body_error = ValueError("body failed") context: BaseException = body_error for index in range(2_000): next_context = RuntimeError(str(index)) context.__context__ = next_context context = next_context lock = FileLock(str(tmp_path / "a"), context_error_policy="group", close_error_policy="raise", on_acquired=capture) with pytest.raises(ExceptionGroup) as info, lock: raise body_error assert (info.value.exceptions, release_error.__context__) == ((body_error, release_error), None) def test_context_group_detaches_equivalent_shared_exception_dag( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], ) -> None: capture, release_error, _ = close_failure body_error: BaseException = ValueError("shared leaf") context_error = body_error for depth in range(25): body_error = ExceptionGroup(str(depth), (body_error, body_error)) context_error = ExceptionGroup(str(depth), (context_error, context_error)) lock = FileLock(str(tmp_path / "a"), context_error_policy="group", close_error_policy="raise", on_acquired=capture) try: raise context_error except ExceptionGroup: with pytest.raises(ExceptionGroup) as info, lock: raise body_error # ruff:ignore[raise-without-from-inside-except] # build the caller-controlled implicit context graph assert (info.value.exceptions, body_error.__context__, release_error.__context__) == ( (body_error, release_error), None, None, ) def test_context_group_preserves_distinct_shared_exception_dag( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], ) -> None: capture, release_error, _ = close_failure body_error: BaseException = ValueError("body leaf") context_error: BaseException = TypeError("context leaf") for depth in range(25): body_error = ExceptionGroup(str(depth), (body_error, body_error)) context_error = ExceptionGroup(str(depth), (context_error, context_error)) lock = FileLock(str(tmp_path / "a"), context_error_policy="group", close_error_policy="raise", on_acquired=capture) try: raise context_error except ExceptionGroup: with pytest.raises(ExceptionGroup) as info, lock: raise body_error # ruff:ignore[raise-without-from-inside-except] # build the caller-controlled implicit context graph assert (info.value.exceptions, body_error.__context__, release_error.__context__) == ( (body_error, release_error), context_error, None, ) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) def test_context_group_renders_independent_leaves( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], *, use_proxy: bool, ) -> None: capture, release_error, _ = close_failure release_error.__cause__ = None release_error.__suppress_context__ = False body_error = ValueError("body failed") lock = FileLock(str(tmp_path / "a"), context_error_policy="group", close_error_policy="raise", on_acquired=capture) with pytest.raises(ExceptionGroup) as info, lock.acquire() if use_proxy else lock: raise body_error group_rendering = "".join(traceback.format_exception(info.value)) release_rendering = "".join(traceback.format_exception(release_error)) assert ( sum(line.endswith("ValueError: body failed") for line in group_rendering.splitlines()), sum(line.endswith("ValueError: body failed") for line in release_rendering.splitlines()), sum(line.endswith("RuntimeError: release cause") for line in release_rendering.splitlines()), sum(line.endswith("OSError: release failed") for line in release_rendering.splitlines()), ) == (1, 0, 0, 1) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) def test_context_chain_keeps_release_error_with_body_in_context( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], *, use_proxy: bool, ) -> None: capture, release_error, _ = close_failure body_error = ValueError("body failed") lock = FileLock(str(tmp_path / "a"), context_error_policy="chain", close_error_policy="raise", on_acquired=capture) with pytest.raises(OSError, match="release failed") as info, lock.acquire() if use_proxy else lock: raise body_error assert (info.value, release_error.__context__) == (release_error, body_error) @pytest.mark.parametrize( "policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")], ) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) def test_context_body_only_failure_propagates_body( tmp_path: Path, policy: ContextErrorPolicy, *, use_proxy: bool ) -> None: lock = SoftFileLock(str(tmp_path / "a"), context_error_policy=policy) body = ValueError("body failed") with pytest.raises(ValueError, match="body failed") as info, lock.acquire() if use_proxy else lock: raise body assert info.value is body @pytest.mark.parametrize( "policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")], ) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) def test_context_release_only_failure_propagates_release( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], policy: ContextErrorPolicy, *, use_proxy: bool, ) -> None: capture, release_error, release_cause = close_failure lock = FileLock(str(tmp_path / "a"), context_error_policy=policy, close_error_policy="raise", on_acquired=capture) with pytest.raises(OSError, match="release failed") as info, lock.acquire() if use_proxy else lock: pass assert (info.value, release_error.__context__, release_error.__cause__) == (release_error, None, release_cause) def test_context_group_base_exception_leaf_is_base_group( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError] ) -> None: capture, _, _ = close_failure lock = FileLock(str(tmp_path / "a"), context_error_policy="group", close_error_policy="raise", on_acquired=capture) with pytest.raises(BaseExceptionGroup) as info, lock: raise KeyboardInterrupt assert not isinstance(info.value, ExceptionGroup) # a BaseException leaf stays outside except Exception assert [type(leaf) for leaf in info.value.exceptions] == [KeyboardInterrupt, OSError] def test_invalid_context_error_policy_rejected(tmp_path: Path) -> None: with pytest.raises(ValueError, match="context_error_policy must be"): SoftFileLock(str(tmp_path / "a"), context_error_policy="explode") # ty: ignore[invalid-argument-type] def test_singleton_rejects_different_context_policy(tmp_path: Path) -> None: path = str(tmp_path / "a") first = SoftFileLock(path, is_singleton=True, context_error_policy="chain") try: with pytest.raises(ValueError, match="context_error_policy"): SoftFileLock(path, is_singleton=True, context_error_policy="group") finally: first.release(force=True) def _fail_close_of(mocker: MockerFixture, lock: BaseFileLock, error: OSError) -> list[int]: # Fail os.close only for this lock's own descriptor, and record each attempt on it. A blanket patch would also hit # the close another test's lock runs from __del__ during this test, turning an unrelated garbage collection into a # spurious failure. Returns the list of attempts so a caller can assert close is not retried. fd = lock._context.lock_file_fd real_close = os.close attempts: list[int] = [] def close(target: int) -> None: if target == fd: attempts.append(target) raise error real_close(target) # pragma: no cover # only an unrelated __del__ close during the mock window reaches here mocker.patch("filelock._api.os.close", side_effect=close) return attempts @NEEDS_FCNTL # pragma: needs fcntl def test_close_error_default_suppressed_on_unix(tmp_path: Path, mocker: MockerFixture) -> None: lock = FileLock(str(tmp_path / "a")) # default policy lock.acquire() _fail_close_of(mocker, lock, OSError(EIO, "close failed")) lock.release() # Unix default drops a FUSE/Docker EIO assert not lock.is_locked @_WINDOWS_ONLY # pragma: win32 cover def test_close_error_default_raises_on_windows(tmp_path: Path, mocker: MockerFixture) -> None: lock = FileLock(str(tmp_path / "a")) # default policy lock.acquire() _fail_close_of(mocker, lock, OSError(EIO, "close failed")) with pytest.raises(OSError, match="close failed"): lock.release() assert not lock.is_locked def test_close_error_suppress(tmp_path: Path, mocker: MockerFixture) -> None: lock = FileLock(str(tmp_path / "a"), close_error_policy="suppress") lock.acquire() _fail_close_of(mocker, lock, OSError(EIO, "close failed")) lock.release() assert not lock.is_locked @pytest.mark.parametrize("errno", [EINTR, EIO, ENOSPC]) def test_close_error_raise_is_exact_and_committed(tmp_path: Path, mocker: MockerFixture, errno: int) -> None: lock = FileLock(str(tmp_path / "a"), close_error_policy="raise") lock.acquire() injected = OSError(errno, "close failed") attempts = _fail_close_of(mocker, lock, injected) with pytest.raises(OSError, match="close failed") as info: lock.release() assert info.value is injected # the original error, no wrapper assert len(attempts) == 1 # os.close is never retried, even after EINTR assert not lock.is_locked # the unlock committed even though close failed assert lock.lock_counter == 0 @NEEDS_FCNTL # pragma: needs fcntl def test_close_not_reached_when_unlock_fails(tmp_path: Path, mocker: MockerFixture) -> None: lock = FileLock(str(tmp_path / "a"), close_error_policy="raise") lock.acquire() fd = lock._context.lock_file_fd mocker.patch("filelock._unix.fcntl.flock", side_effect=[OSError(EIO, "unlock failed"), None]) real_close = os.close closed: list[int] = [] mocker.patch("filelock._api.os.close", side_effect=lambda target: closed.append(target) or real_close(target)) with pytest.raises(OSError, match="unlock failed"): lock.release() assert lock.is_locked # the kernel unlock failed, so the lock is still held assert fd not in closed # close is not reached while the lock is still held lock.release() # retry: unlock succeeds and close runs assert not lock.is_locked assert fd in closed def test_dual_body_and_close_failure_grouped(tmp_path: Path, mocker: MockerFixture) -> None: lock = FileLock(str(tmp_path / "a"), close_error_policy="raise", context_error_policy="group") lock.acquire() _fail_close_of(mocker, lock, OSError("close failed")) body = ValueError("body failed") with pytest.raises(ExceptionGroup) as info: lock._release_in_context(body) # what __exit__ runs; the close failure joins the body failure leaf_body, close = info.value.exceptions assert isinstance(leaf_body, ValueError) assert isinstance(close, OSError) def test_invalid_close_error_policy_rejected(tmp_path: Path) -> None: with pytest.raises(ValueError, match="close_error_policy must be"): FileLock(str(tmp_path / "a"), close_error_policy="explode") # ty: ignore[invalid-argument-type] def test_singleton_rejects_different_close_policy(tmp_path: Path) -> None: path = str(tmp_path / "a") first = FileLock(path, is_singleton=True, close_error_policy="raise") try: with pytest.raises(ValueError, match="close_error_policy"): FileLock(path, is_singleton=True, close_error_policy="suppress") finally: first.release(force=True) def test_singleton_shares_across_equivalent_spellings(tmp_path: Path) -> None: (tmp_path / "sub").mkdir() absolute = FileLock(str(tmp_path / "a"), is_singleton=True) dot = FileLock(str(tmp_path) + "/./a", is_singleton=True) dotdot = FileLock(str(tmp_path / "sub") + "/../a", is_singleton=True) try: assert absolute is dot is dotdot # one instance for equivalent spellings of one path finally: absolute.release(force=True) @NEEDS_PARENT_SYMLINK_COLLAPSE # pragma: win32 no cover def test_singleton_shares_through_symlinked_parent(tmp_path: Path) -> None: real = tmp_path / "real" real.mkdir() (tmp_path / "link").symlink_to(real) via_real = FileLock(str(real / "a"), is_singleton=True) via_link = FileLock(str(tmp_path / "link" / "a"), is_singleton=True) try: assert via_real is via_link # a symlinked parent directory resolves to the same key finally: via_real.release(force=True) @NEEDS_SYMLINK # pragma: needs symlink def test_final_symlink_stays_a_distinct_key(tmp_path: Path) -> None: target = tmp_path / "target" target.write_text("") (tmp_path / "link").symlink_to(target) on_link = FileLock(str(tmp_path / "link"), is_singleton=True) on_target = FileLock(str(target), is_singleton=True) try: assert on_link is not on_target # the final component is not resolved, so the symlink is its own key finally: on_link.release(force=True) on_target.release(force=True) _RELATIVE_LOCK_TYPES: Final = [FileLock, SoftFileLock, MarkerSoftFileLock, SoftFileLease] @pytest.mark.parametrize("lock_type", _RELATIVE_LOCK_TYPES) def test_relative_waiter_keeps_acquisition_directory( lock_type: type[BaseFileLock], working_directories: tuple[Path, Path] ) -> None: # acquire resolves a relative path once, when the acquisition starts, and keeps that file until the hold ends, so # a chdir mid-wait does not move the waiter to a file in the new directory. first, second = working_directories holder = lock_type(first / "resource.lock") holder.acquire() polling = threading.Event() try: with ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(_acquire_relative_lock, lock_type, polling) assert polling.wait(timeout=2) os.chdir(second) with pytest.raises(Timeout): future.result(timeout=2) finally: holder.release(force=True) assert not (second / "resource.lock").exists() def _acquire_relative_lock(lock_type: type[BaseFileLock], polling: threading.Event, timeout: float = 0.2) -> None: lock_type("resource.lock", timeout=timeout, poll_interval=0.001).acquire( cancel_check=partial(_signal_poll, polling) ) def _signal_poll(polling: threading.Event) -> bool: polling.set() return False @pytest.mark.parametrize("lock_type", [SoftFileLock, MarkerSoftFileLock, SoftFileLease]) def test_relative_release_uses_acquisition_directory( lock_type: type[BaseFileLock], working_directories: tuple[Path, Path] ) -> None: first, second = working_directories lock = lock_type("resource.lock") lock.acquire() os.chdir(second) with lock: # re-entry joins the hold, so it keeps that hold's path pass lock.release() assert not (first / "resource.lock").exists() @pytest.mark.skipif( sys.platform == "win32", reason="Windows refuses to rename a directory holding an open file" ) # pragma: win32 no cover @pytest.mark.parametrize("lock_type", [SoftFileLock, MarkerSoftFileLock, SoftFileLease]) def test_relative_release_after_directory_rename_removes_marker( lock_type: type[BaseFileLock], working_directories: tuple[Path, Path] ) -> None: first, _ = working_directories lock = lock_type("resource.lock") lock.acquire() renamed = first.parent / "renamed" Path(first).rename(renamed) lock.release() assert not (renamed / "resource.lock").exists() @pytest.mark.skipif( sys.platform == "win32", reason="Windows refuses to rename a directory holding an open file" ) # pragma: win32 no cover @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_waiter_after_directory_rename_does_not_lock_a_new_file( lock_type: type[BaseFileLock], working_directories: tuple[Path, Path] ) -> None: first, _ = working_directories holder = lock_type(first / "resource.lock") holder.acquire() polling = threading.Event() try: with ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(_acquire_relative_lock, lock_type, polling, 2) assert polling.wait(timeout=2) Path(first).rename(first.parent / "renamed") with pytest.raises(FileNotFoundError): future.result(timeout=5) assert not (first / "resource.lock").exists() assert holder.is_locked finally: holder.release(force=True) @pytest.mark.skipif( sys.platform == "win32", reason="Windows refuses to rename a directory holding an open file" ) # pragma: win32 no cover @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.parametrize( "occupy_old_name", [ pytest.param(lambda _parent: None, id="old-name-absent"), pytest.param(lambda parent: parent.write_text("replacement", encoding="utf-8"), id="old-name-is-a-file"), ], ) def test_reentrant_acquire_after_directory_rename( lock_type: type[BaseFileLock], occupy_old_name: Callable[[Path], object], tmp_path: Path ) -> None: parent = tmp_path / "original" lock = lock_type(parent / "resource.lock") with lock: parent.rename(tmp_path / "renamed") occupy_old_name(parent) with lock: assert lock.lock_counter == 2 assert lock.lock_counter == 1 assert not parent.is_dir() assert not lock.is_locked @pytest.mark.parametrize("lock_type", _RELATIVE_LOCK_TYPES) def test_each_acquisition_resolves_the_working_directory( lock_type: type[BaseFileLock], working_directories: tuple[Path, Path] ) -> None: _, second = working_directories lock = lock_type("resource.lock") with lock: pass os.chdir(second) with lock: assert (lock.lock_file, (second / "resource.lock").exists()) == ("resource.lock", True) @pytest.mark.skipif( sys.platform == "win32", reason="a working directory in use cannot be removed" ) # pragma: win32 no cover @pytest.mark.parametrize("lock_type", _RELATIVE_LOCK_TYPES) def test_acquire_without_working_directory_leaves_no_hold( lock_type: type[BaseFileLock], working_directories: tuple[Path, Path] ) -> None: lock: Final[BaseFileLock] = lock_type("resource.lock") working_directories[0].rmdir() with pytest.raises(FileNotFoundError): lock.acquire() second: Final[Path] = working_directories[1] os.chdir(second) with lock: assert (lock.lock_counter, lock.is_locked, (second / "resource.lock").exists()) == (1, True, True) assert (lock.lock_counter, lock.is_locked) == (0, False) @pytest.mark.skipif( sys.platform == "win32", reason="a working directory in use cannot be removed" ) # pragma: win32 no cover @pytest.mark.parametrize("lock_type", _RELATIVE_LOCK_TYPES) def test_nested_acquire_without_working_directory_preserves_hold( lock_type: type[BaseFileLock], working_directories: tuple[Path, Path] ) -> None: lock: Final[BaseFileLock] = lock_type("resource.lock") lock.acquire() first: Final[Path] = working_directories[0] (first / "resource.lock").unlink() first.rmdir() with pytest.raises(FileNotFoundError): lock.acquire() assert (lock.lock_counter, lock.is_locked) == (1, True) os.chdir(working_directories[1]) lock.release() assert (lock.lock_counter, lock.is_locked) == (0, False) @pytest.mark.parametrize("lock_type", _RELATIVE_LOCK_TYPES) def test_singleton_after_chdir_locks_the_requested_file( lock_type: type[BaseFileLock], working_directories: tuple[Path, Path] ) -> None: first, second = working_directories relative = lock_type("resource.lock", is_singleton=True) os.chdir(second) with lock_type(first / "resource.lock", is_singleton=True): assert ((first / "resource.lock").exists(), (second / "resource.lock").exists()) == (True, False) del relative @pytest.mark.parametrize("lock_type", _RELATIVE_LOCK_TYPES) def test_singleton_held_before_chdir_re_enters_by_absolute_path( lock_type: type[BaseFileLock], working_directories: tuple[Path, Path] ) -> None: first, second = working_directories held = lock_type("resource.lock", is_singleton=True) held.acquire() os.chdir(second) with lock_type(first / "resource.lock", is_singleton=True) as again: assert (again is held, held.lock_counter) == (True, 2) held.release(force=True) @NEEDS_PARENT_SYMLINK_COLLAPSE # pragma: win32 no cover @pytest.mark.parametrize("lock_type", _RELATIVE_LOCK_TYPES) def test_singleton_after_parent_link_moves_locks_the_requested_file( lock_type: type[BaseFileLock], working_directories: tuple[Path, Path] ) -> None: first, second = working_directories link = first.parent / "current" link.symlink_to(first) through_link = lock_type(link / "resource.lock", is_singleton=True) link.unlink() link.symlink_to(second) with lock_type(first / "resource.lock", is_singleton=True): assert ((first / "resource.lock").exists(), (second / "resource.lock").exists()) == (True, False) del through_link @NEEDS_FCNTL # pragma: needs fcntl @_NEEDS_O_NOFOLLOW # pragma: needs o-nofollow def test_final_symlink_backend_refuses_to_lock(tmp_path: Path) -> None: (tmp_path / "target").write_text("") (tmp_path / "link").symlink_to(tmp_path / "target") # Keeping the final symlink a distinct key is safe because the backend still refuses to lock through it. O_NOFOLLOW # reports ELOOP on Linux/macOS, EFTYPE on NetBSD, and EMLINK on FreeBSD. with pytest.raises( OSError, check=lambda error: error.errno in {errno.ELOOP, errno.EMLINK, getattr(errno, "EFTYPE", None)} ): FileLock(str(tmp_path / "link")).acquire() def test_separate_lock_classes_keep_separate_registries(tmp_path: Path) -> None: path = str(tmp_path / "a") native = FileLock(path, is_singleton=True) soft = SoftFileLock(path, is_singleton=True) try: assert native is not soft # each class caches its own singletons finally: native.release(force=True) soft.release(force=True) @NEEDS_FCNTL # pragma: needs fcntl def test_fallback_to_soft_disabled_raises_enosys(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._unix.fcntl.flock", side_effect=OSError(ENOSYS, "no flock")) lock = FileLock(str(tmp_path / "a"), fallback_to_soft=False) with pytest.raises(OSError, match="no flock") as info: lock.acquire() assert info.value.errno == ENOSYS # the original error, not a soft-lock timeout assert not lock.is_locked assert type(lock).__name__ == "UnixFileLock" # the class is not swapped to SoftFileLock assert lock.lock_counter == 0 # the failed acquire left no holder count @NEEDS_FCNTL # pragma: needs fcntl def test_fallback_to_soft_default_switches_to_soft(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._unix.fcntl.flock", side_effect=OSError(ENOSYS, "no flock")) lock = FileLock(str(tmp_path / "a")) # default fallback_to_soft=True with pytest.warns(UserWarning, match="falling back to SoftFileLock"): lock.acquire() try: assert lock.is_locked assert isinstance(lock, SoftFileLock) # switched to existence-lock semantics finally: lock.release() @NEEDS_FCNTL # pragma: needs fcntl def test_fallback_disabled_recursive_reraises(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._unix.fcntl.flock", side_effect=OSError(ENOSYS, "no flock")) lock = FileLock(str(tmp_path / "a"), fallback_to_soft=False) for _ in range(2): # a repeat acquire keeps failing the same way, never a soft downgrade with pytest.raises(OSError, match="no flock"): lock.acquire() assert not lock.is_locked @NEEDS_FCNTL # pragma: needs fcntl def test_singleton_rejects_different_fallback_to_soft(tmp_path: Path) -> None: path = str(tmp_path / "a") first = FileLock(path, is_singleton=True, fallback_to_soft=True) try: with pytest.raises(ValueError, match="fallback_to_soft"): FileLock(path, is_singleton=True, fallback_to_soft=False) finally: first.release(force=True) def test_lock_descriptor_roundtrip(tmp_path: Path) -> None: fd = os.open(str(tmp_path / "a"), os.O_RDWR | os.O_CREAT) try: assert lock_descriptor(fd, blocking=False) is True os.write(fd, b"held") # the descriptor stays usable while locked unlock_descriptor(fd) os.lseek(fd, 0, os.SEEK_SET) assert os.read(fd, 4) == b"held" # and after unlock finally: os.close(fd) def test_lock_descriptor_nonblocking_contention(tmp_path: Path) -> None: path = str(tmp_path / "a") holder = os.open(path, os.O_RDWR | os.O_CREAT) contender = os.open(path, os.O_RDWR | os.O_CREAT) try: assert lock_descriptor(holder, blocking=False) is True assert lock_descriptor(contender, blocking=False) is False # a second descriptor sees the lock unlock_descriptor(holder) assert lock_descriptor(contender, blocking=False) is True # free once the holder releases unlock_descriptor(contender) finally: os.close(holder) os.close(contender) def test_lock_descriptor_invalid_fd_raises(tmp_path: Path) -> None: fd = os.open(str(tmp_path / "a"), os.O_RDWR | os.O_CREAT) os.close(fd) # a closed descriptor is invalid; the native lock must raise, not silently succeed or contend with pytest.raises(OSError, match=r"Bad file descriptor|not open|invalid"): lock_descriptor(fd, blocking=False) @pytest.mark.parametrize( "poll_interval", _INVALID_DESCRIPTOR_POLL_INTERVALS, ) def test_lock_descriptor_rejects_invalid_blocking_poll_interval(poll_interval: float) -> None: with pytest.raises(ValueError, match="poll_interval must be greater than 0 and at most"): lock_descriptor(-1, poll_interval=poll_interval) @pytest.mark.parametrize( "poll_interval", _INVALID_DESCRIPTOR_POLL_INTERVALS, ) def test_lock_descriptor_nonblocking_ignores_poll_interval(tmp_path: Path, poll_interval: float) -> None: fd = os.open(str(tmp_path / "a"), os.O_RDWR | os.O_CREAT) try: assert lock_descriptor(fd, blocking=False, poll_interval=poll_interval) is True unlock_descriptor(fd) finally: os.close(fd) @pytest.mark.parametrize("direction", ["filelock_first", "descriptor_first"]) def test_filelock_and_descriptor_contend(tmp_path: Path, direction: str) -> None: path = str(tmp_path / "a") lock = FileLock(path) fd = os.open(path, os.O_RDWR | os.O_CREAT) try: if direction == "filelock_first": lock.acquire() assert lock_descriptor(fd, blocking=False) is False # the path lock blocks the descriptor lock lock.release() assert lock_descriptor(fd, blocking=False) is True unlock_descriptor(fd) else: assert lock_descriptor(fd, blocking=False) is True with pytest.raises(Timeout): lock.acquire(timeout=0.2) # the descriptor lock blocks the path lock unlock_descriptor(fd) lock.acquire() lock.release() finally: os.close(fd) @NEEDS_FCNTL # pragma: needs fcntl def test_lock_descriptor_touches_no_paths(tmp_path: Path) -> None: path = tmp_path / "a" fd = os.open(str(path), os.O_RDWR | os.O_CREAT) try: os.write(fd, b"payload") before = os.fstat(fd) assert lock_descriptor(fd, blocking=False) is True unlock_descriptor(fd) after = os.fstat(fd) # The adapter works purely on the descriptor: our fd stays open on the same inode with its size and mode # intact, and the file keeps its contents. That rules out any open, close, unlink, truncate or chmod on our # behalf without spying on hot global os functions, which unrelated tempfile cleanup would race and pollute. assert (after.st_ino, after.st_dev, after.st_size, after.st_mode) == ( before.st_ino, before.st_dev, before.st_size, before.st_mode, ) assert path.read_bytes() == b"payload" finally: os.close(fd) @NEEDS_FCNTL # pragma: needs fcntl def test_unlock_descriptor_failure_allows_retry(tmp_path: Path, mocker: MockerFixture) -> None: fd = os.open(str(tmp_path / "a"), os.O_RDWR | os.O_CREAT) try: assert lock_descriptor(fd, blocking=False) is True mocker.patch("filelock._unix.fcntl.flock", side_effect=[OSError(EIO, "unlock failed"), None]) with pytest.raises(OSError, match="unlock failed"): unlock_descriptor(fd) unlock_descriptor(fd) # the same descriptor can retry finally: os.close(fd) @pytest.mark.parametrize( "poll_interval", [pytest.param(0.01, id="short"), pytest.param(threading.TIMEOUT_MAX, id="timeout-max")] ) def test_lock_descriptor_blocking_retries_until_free( tmp_path: Path, mocker: MockerFixture, poll_interval: float ) -> None: path = str(tmp_path / "a") holder = os.open(path, os.O_RDWR | os.O_CREAT) fd = os.open(path, os.O_RDWR | os.O_CREAT) assert lock_descriptor(holder, blocking=False) is True # Drive the real blocking loop: the first attempt sees contention, the mocked sleep frees the holder, and the # second attempt wins. Only the clock is mocked, so a single sleep call proves exactly one retry happened. sleep: Final = mocker.create_autospec(time.sleep, side_effect=lambda _: unlock_descriptor(holder)) mocker.patch("filelock._descriptor.time", new=SimpleNamespace(sleep=sleep)) try: assert lock_descriptor(fd, blocking=True, poll_interval=poll_interval) is True sleep.assert_called_once_with(poll_interval) unlock_descriptor(fd) finally: os.close(holder) os.close(fd) #: NetBSD has no CLOCK_THREAD_CPUTIME_ID, so time.thread_time is absent; process CPU time proves "not spinning" too. _CPU_TIME: Final[Callable[[], float]] = getattr(time, "thread_time", time.process_time) def test_lock_descriptor_blocking_wait_does_not_spin(tmp_path: Path) -> None: path = str(tmp_path / "a") holder = os.open(path, os.O_RDWR | os.O_CREAT) contender = os.open(path, os.O_RDWR | os.O_CREAT) assert lock_descriptor(holder, blocking=False) is True started = threading.Event() def acquire() -> float: started.set() before = _CPU_TIME() lock_descriptor(contender, poll_interval=0.01) return _CPU_TIME() - before try: with ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(acquire) try: assert started.wait(timeout=1) time.sleep(0.2) finally: unlock_descriptor(holder) assert future.result(timeout=2) < 0.05 unlock_descriptor(contender) finally: os.close(holder) os.close(contender) def test_preserve_lock_file_defaults_off(tmp_path: Path) -> None: assert FileLock(str(tmp_path / "a")).preserve_lock_file is False def test_preserve_lock_file_property_reflects_argument(tmp_path: Path) -> None: assert FileLock(str(tmp_path / "a"), preserve_lock_file=True).preserve_lock_file is True def test_preserve_lock_file_rejected_by_soft_lock(tmp_path: Path) -> None: with pytest.raises(ValueError, match="preserve_lock_file"): SoftFileLock(str(tmp_path / "a"), preserve_lock_file=True) def test_singleton_rejects_different_preserve_lock_file(tmp_path: Path) -> None: path = str(tmp_path / "a") first = FileLock(path, is_singleton=True, preserve_lock_file=False) try: with pytest.raises(ValueError, match="preserve_lock_file"): FileLock(path, is_singleton=True, preserve_lock_file=True) finally: first.release(force=True) @NEEDS_FCNTL # pragma: needs fcntl def test_preserve_lock_file_unix_keeps_pathname(tmp_path: Path) -> None: path = tmp_path / "a" lock = FileLock(str(path), preserve_lock_file=True) lock.acquire() lock.release() assert path.exists() # Unix leaves the native pathname in place assert type(lock).__name__ == "UnixFileLock" @NEEDS_FCNTL # pragma: needs fcntl def test_preserve_lock_file_fails_closed_on_enosys(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._unix.fcntl.flock", side_effect=OSError(ENOSYS, "no flock")) lock = FileLock(str(tmp_path / "a"), preserve_lock_file=True) # fallback_to_soft still defaults to True with pytest.raises(OSError, match="no flock"): lock.acquire() assert not lock.is_locked assert type(lock).__name__ == "UnixFileLock" # preserve overrides the soft fallback that would unlink to release @_WINDOWS_ONLY # pragma: win32 cover def test_preserve_lock_file_windows_default_removes_file(tmp_path: Path) -> None: path = tmp_path / "a" lock = FileLock(str(path)) lock.acquire() lock.release() assert not path.exists() # the default Windows cleanup unlinks the lock file @_WINDOWS_ONLY # pragma: win32 cover def test_preserve_lock_file_windows_keeps_file(tmp_path: Path) -> None: path = tmp_path / "a" lock = FileLock(str(path), preserve_lock_file=True) lock.acquire() lock.release() assert path.exists() # preserve_lock_file skips the post-release unlink @_WINDOWS_ONLY # pragma: win32 cover def test_preserve_lock_file_windows_reacquires_same_identity(tmp_path: Path) -> None: path = tmp_path / "a" lock = FileLock(str(path), preserve_lock_file=True) lock.acquire() lock.release() kept = path.stat() lock.acquire() try: current = path.stat() assert (current.st_dev, current.st_ino) == (kept.st_dev, kept.st_ino) # same file across acquisitions finally: lock.release() @_WINDOWS_ONLY # pragma: win32 cover def test_preserve_lock_file_windows_kept_after_forced_release(tmp_path: Path) -> None: path = tmp_path / "a" lock = FileLock(str(path), preserve_lock_file=True) lock.acquire() lock.acquire() # reentrant second level lock.release(force=True) # drop every level and run the release path once assert not lock.is_locked assert path.exists() # preserved even through a forced release def _noop_on_acquired(_fd: int) -> None: pass def _failing_on_acquired(_fd: int) -> None: msg = "hook failed" raise RuntimeError(msg) def test_on_acquired_defaults_none(tmp_path: Path) -> None: assert FileLock(str(tmp_path / "a")).on_acquired is None def test_on_acquired_property_reflects_argument(tmp_path: Path) -> None: assert FileLock(str(tmp_path / "a"), on_acquired=_noop_on_acquired).on_acquired is _noop_on_acquired def test_on_acquired_noop_hook_allows_acquire(tmp_path: Path) -> None: lock = FileLock(str(tmp_path / "a"), on_acquired=_noop_on_acquired) lock.acquire() # the no-op hook runs during acquisition try: assert lock.is_locked finally: lock.release() def test_on_acquired_runs_before_acquire_returns(tmp_path: Path) -> None: fd_while_held = -1 locked_during_hook = False def hook(fd: int) -> None: nonlocal fd_while_held, locked_during_hook locked_during_hook = lock.is_locked fd_while_held = fd lock = FileLock(str(tmp_path / "a"), on_acquired=hook) lock.acquire() try: assert locked_during_hook # the hook ran while the lock was held ... assert fd_while_held >= 0 # ... and got a real descriptor, all before acquire() returned finally: lock.release() def test_on_acquired_fires_once_per_physical_acquire(tmp_path: Path) -> None: calls: list[int] = [] lock = FileLock(str(tmp_path / "a"), on_acquired=calls.append) lock.acquire() lock.acquire() # recursive: the poll loop skips _acquire, so the hook does not run again try: assert len(calls) == 1 finally: lock.release() lock.release() def test_on_acquired_not_called_for_contender(tmp_path: Path) -> None: path = str(tmp_path / "a") holder = FileLock(path) holder.acquire() calls: list[int] = [] contender = FileLock(path, on_acquired=calls.append) try: with pytest.raises(Timeout): contender.acquire(timeout=0.1) assert calls == [] # a contender that never acquires never runs the hook finally: holder.release() @NEEDS_FCNTL # pragma: needs fcntl def test_on_acquired_runs_after_mode(tmp_path: Path) -> None: seen: list[int] = [] def hook(fd: int) -> None: seen.append(S_IMODE(os.fstat(fd).st_mode)) lock = FileLock(str(tmp_path / "a"), mode=0o600, on_acquired=hook) lock.acquire() try: assert seen == [0o600] finally: lock.release() @NEEDS_FCNTL # pragma: needs fcntl def test_on_acquired_writes_survive_contention(tmp_path: Path) -> None: path = str(tmp_path / "a") def write_metadata(fd: int) -> None: os.write(fd, b"holder-metadata") writer = FileLock(path, on_acquired=write_metadata) writer.acquire() try: contender = FileLock(path) with pytest.raises(Timeout): contender.acquire(timeout=0.1) # a losing contender must not truncate the holder's file assert Path(path).read_bytes() == b"holder-metadata" finally: writer.release() def test_on_acquired_failure_releases_lock(tmp_path: Path) -> None: path = str(tmp_path / "a") lock = FileLock(path, on_acquired=_failing_on_acquired) with pytest.raises(RuntimeError, match="hook failed"): lock.acquire() assert not lock.is_locked assert lock.lock_counter == 0 other = FileLock(path) other.acquire() # the native lock was released, so a fresh acquire succeeds at once other.release() @NEEDS_FCNTL # pragma: needs fcntl def test_on_acquired_and_release_failure_group(tmp_path: Path, mocker: MockerFixture) -> None: path = str(tmp_path / "a") lock = FileLock(path, on_acquired=_failing_on_acquired) mocker.patch("filelock._unix._unlock_fd", side_effect=[OSError(EIO, "unlock failed"), None]) with pytest.raises(BaseExceptionGroup) as info: lock.acquire() assert {type(error) for error in info.value.exceptions} == {RuntimeError, OSError} assert lock.is_locked # the OS unlock failed, so the lock stays held for a retry assert lock.lock_counter == 1 lock.release() # the second unlock succeeds def test_on_acquired_rollback_group_detaches_release_context( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError] ) -> None: capture, release_error, release_cause = close_failure callback_error = RuntimeError("hook failed") callback_cause = LookupError("hook cause") def fail(fd: int) -> None: capture(fd) raise callback_error from callback_cause lock = FileLock(str(tmp_path / "a"), close_error_policy="raise", on_acquired=fail) with pytest.raises(ExceptionGroup) as info: lock.acquire() assert ( info.value.exceptions, callback_error.__context__, release_error.__context__, callback_error.__cause__, release_error.__cause__, callback_error.__traceback__ is not None, release_error.__traceback__ is not None, ) == ((callback_error, release_error), None, None, callback_cause, release_cause, True, True) def test_grouped_errors_keep_a_context_that_is_a_distinct_group() -> None: # A genuinely distinct group-valued __context__ must survive the detach rather than read as a duplicate. context = ExceptionGroup("context group", [ValueError("a")]) member = ExceptionGroup("member group", [KeyError("b"), KeyError("c")]) member.__context__ = context with pytest.raises(BaseExceptionGroup) as info: _raise_grouped_errors("grouped", member, RuntimeError("other")) assert member.__context__ is context assert member in info.value.exceptions def test_grouped_errors_detach_a_context_already_nested_in_a_sibling() -> None: # A context already present in a sibling's tree is dropped so the group never carries the same leaf twice. shared = ValueError("shared leaf") sibling = ExceptionGroup("sibling", [shared]) member = RuntimeError("boom") member.__context__ = shared with pytest.raises(BaseExceptionGroup) as info: _raise_grouped_errors("grouped", sibling, member) assert member.__context__ is None assert set(info.value.exceptions) == {sibling, member} def test_rollback_failed_acquire_groups_registration_and_release_failures( tmp_path: Path, mocker: MockerFixture ) -> None: # An acquire that raised while still holding the lock re-registers the descriptor and releases; when both that # re-registration and the release then fail, every failure survives as a sibling of the group raised. lock = SoftFileLock(str(tmp_path / "a")) lock.acquire() registration_error = OSError("registration failed") release_error = OSError("release failed") mocker.patch.object(lock, "_register_context_descriptor", side_effect=registration_error) mocker.patch.object(lock, "_release_with_fork_tracking", side_effect=release_error) acquisition_error = RuntimeError("acquire failed") try: with pytest.raises(BaseExceptionGroup) as info: lock._rollback_failed_acquire(acquisition_error) assert set(info.value.exceptions) == {acquisition_error, registration_error, release_error} finally: mocker.stopall() lock.release(force=True) @NEEDS_FCNTL # pragma: needs fcntl def test_on_acquired_fails_closed_on_enosys(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._unix.fcntl.flock", side_effect=OSError(ENOSYS, "no flock")) calls: list[int] = [] lock = FileLock(str(tmp_path / "a"), on_acquired=calls.append) with pytest.raises(OSError, match="no flock"): lock.acquire() assert not lock.is_locked assert type(lock).__name__ == "UnixFileLock" # a soft lock cannot run the hook, so no fallback assert calls == [] def test_on_acquired_rejected_by_soft_lock(tmp_path: Path) -> None: with pytest.raises(ValueError, match="on_acquired"): SoftFileLock(str(tmp_path / "a"), on_acquired=_noop_on_acquired) def test_singleton_shares_same_on_acquired(tmp_path: Path) -> None: path = str(tmp_path / "a") first = FileLock(path, is_singleton=True, on_acquired=_noop_on_acquired) try: assert FileLock(path, is_singleton=True, on_acquired=_noop_on_acquired) is first finally: first.release(force=True) def test_singleton_rejects_different_on_acquired(tmp_path: Path) -> None: path = str(tmp_path / "a") first = FileLock(path, is_singleton=True, on_acquired=_noop_on_acquired) try: with pytest.raises(ValueError, match="on_acquired"): FileLock(path, is_singleton=True, on_acquired=_failing_on_acquired) finally: first.release(force=True) def test_import_succeeds_without_os_link() -> None: # Termux/Android CPython has no os.link, and the strict backend touched it at import, so `from filelock import # FileLock` crashed with AttributeError there (discussion #677). Importing in a fresh interpreter with os.link # removed must still succeed. script = ( "import os\n" "if hasattr(os, 'link'):\n" " del os.link\n" "from filelock import FileLock, StrictSoftFileLock\n" "print('ok')\n" ) run = [sys.executable, "-c", script] result = subprocess.run(run, capture_output=True, text=True, check=False) assert (result.returncode, result.stdout.strip()) == (0, "ok"), result.stderr def test_strict_lock_reports_unsupported_without_os_link(tmp_path: Path, mocker: MockerFixture) -> None: # With os.link absent the strict backend cannot publish a hard-link claim, so acquire must raise the same # unsupported-filesystem error a hard-link-refusing filesystem gets, not a bare AttributeError. mocker.patch("filelock._strict._HAS_LINK", False) lock = StrictSoftFileLock(str(tmp_path / "resource.lock")) with pytest.raises(SoftFileLockProtocolError, match="hard-link publication"): lock.acquire() assert (lock.claims, lock.is_locked) == ((), False) def test_soft_windows_unlink_stops_on_non_permission_error(tmp_path: Path, mocker: MockerFixture) -> None: path = tmp_path / "windows.lock" path.write_bytes(b"x") lock = SoftFileLock(str(path)) st = os.lstat(path) mocker.patch("filelock._soft.unlink_path", side_effect=OSError(ENOSYS, "boom")) lock._windows_unlink_if_ours((st.st_dev, st.st_ino)) assert path.exists() @pytest.mark.timeout(5) def test_append_exception_context_terminates_on_a_cycle() -> None: first = RuntimeError("first") second = RuntimeError("second") first.__context__ = second second.__context__ = first # a cycle that never reaches the context below context = ValueError("context") _append_exception_context(first, context) # The walk hit the already-seen tail and stopped instead of looping forever or threading context into the ring. assert first.__context__ is second assert second.__context__ is first def test_undo_acquire_keeps_registry_entry_while_still_held(tmp_path: Path, mocker: MockerFixture) -> None: lock = FileLock(str(tmp_path / "a"), is_singleton=False) lock.acquire() key = lock._context.lock_file_key assert _registry.held.get(key) == id(lock) # A reentrant acquire that fails while the lock reports unheld routes through _undo_acquire with the counter # still above zero, so the registry entry the outer hold owns must survive. held_fd = lock._context.lock_file_fd lock._context.lock_file_fd = None # is_locked now reports False mocker.patch.object(lock, "_poll_until_acquired", side_effect=Timeout(lock.lock_file)) with pytest.raises(Timeout): lock.acquire(timeout=0) assert lock._context.lock_counter == 1 # decremented from 2, the deeper hold remains assert _registry.held.get(key) == id(lock) # entry preserved because the counter did not reach zero lock._context.lock_file_fd = held_fd lock.release() def test_soft_marker_omits_start_line_without_a_start_token(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._soft.process_start_token", return_value=None) lock = SoftFileLock(str(tmp_path / "a")) with lock: lines = Path(lock.lock_file).read_text(encoding="utf-8").splitlines() assert len(lines) == 2 # pid and hostname only; the optional start-token line is omitted assert lines[0] == str(os.getpid()) def test_soft_is_lock_held_by_us_false_for_unparseable_marker(tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_path.write_text("garbage-not-a-marker", encoding="utf-8") lock = SoftFileLock(str(lock_path)) assert lock.is_lock_held_by_us is False # an unreadable holder is nobody, so certainly not us @NEEDS_FCNTL # pragma: needs fcntl def test_unix_acquire_without_o_nofollow(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delattr(os, "O_NOFOLLOW", raising=False) lock = FileLock(str(tmp_path / "a"), is_singleton=False) lock.acquire() try: assert lock.is_locked # acquisition still works on a platform without O_NOFOLLOW finally: lock.release() @NEEDS_FCNTL # pragma: needs fcntl def test_unix_soft_fallback_keeps_a_replaced_file(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "a" lock = FileLock(str(lock_path), is_singleton=False) target = lock.lock_file mocker.patch("filelock._unix.fcntl.flock", side_effect=OSError(ENOSYS, "no flock")) real_lstat = os.lstat def lstat_swapped_identity(path: str) -> os.stat_result: st = real_lstat(path) if str(path) != target: return st fields = list(st) fields[1] += 1 # bump st_ino so the on-disk identity no longer matches the opened fd return os.stat_result(fields) mocker.patch("filelock._unix.os.lstat", side_effect=lstat_swapped_identity) with pytest.raises(Timeout), pytest.warns(UserWarning, match="falling back to SoftFileLock"): lock.acquire(timeout=0.2) assert isinstance(lock, SoftFileLock) # fell back after refusing to unlink the mismatched file assert lock_path.exists() # the replacement file was left in place def test_version_attribute_is_the_version_string() -> None: # The submodule import above must not rebind it. assert (type(filelock.version), filelock.version) == (str, filelock.__version__) def test_has_fcntl_matches_native_unix_alias() -> None: assert filelock.has_fcntl is (filelock.FileLock is filelock.UnixFileLock) def test_public_literal_aliases_list_their_values() -> None: assert (get_args(filelock.OwnerMode), get_args(filelock.CompromiseReason)) == ( ("lease", "exclusive", "unknown"), ("marker-missing", "owner-changed", "refresh-failed", "evicted"), ) def test_on_acquired_reentrant_acquire_still_registers_holder(tmp_path: Path) -> None: lock_path: Final = tmp_path / "a.lock" def reacquire(_fd: int) -> None: lock.acquire() lock: Final = FileLock(lock_path, on_acquired=reacquire) with lock: other: Final = FileLock(lock_path) # Without the registry entry the deadlock check stays silent and only the cancel check ends the wait. with pytest.raises(RuntimeError, match="Deadlock"): other.acquire(cancel_check=lambda: True) lock.release() def test_acquire_proxy_enters_as_the_lock_type(tmp_path: Path) -> None: lock: Final = FileLock(tmp_path / "a.lock") # The attribute access type-checks only while the proxy keeps the lock's own type, not the union of lock kinds. with lock.acquire() as held: assert (held, held.lock_counter) == (lock, 1) filelock-4.2.8/tests/test_fork_backends.py0000644000000000000000000004167715272561277015646 0ustar00from __future__ import annotations import asyncio import os import subprocess # ruff:ignore[suspicious-subprocess-import] # isolated interpreter controls child callback registration order import sys from errno import EBADF from typing import TYPE_CHECKING, NoReturn, cast import pytest from filelock import BaseAsyncFileLock, BaseFileLock from tests.capability_marks import NEEDS_FORK, NEEDS_GENERATOR_EXCEPTION_CONTEXT from tests.fork_helpers import exit_child, fork_process if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path from pytest_mock import MockerFixture class _DescriptorLock(BaseFileLock): acquire_error: BaseException | None = None descriptor: int | None = None fail_release: bool = False def _acquire(self) -> None: # pragma: needs fork self.descriptor = self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) if self.acquire_error is not None: raise self.acquire_error def _release(self) -> None: # pragma: needs fork if self.fail_release: msg = "unlock failed" raise RuntimeError(msg) os.close(cast("int", self._context.lock_file_fd)) self._context.lock_file_fd = None class _CoroutineDescriptorLock(BaseAsyncFileLock): acquire_error: BaseException | None = None acquire_error_before_descriptor: BaseException | None = None descriptor: int | None = None release_error: BaseException | None = None release_error_before_close: BaseException | None = None async def _acquire(self) -> None: # ty: ignore[invalid-method-override] # coroutine backends are supported if self.acquire_error_before_descriptor is not None: raise self.acquire_error_before_descriptor self.descriptor = self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) if self.acquire_error is not None: # pragma: needs fork raise self.acquire_error async def _release(self) -> None: # ty: ignore[invalid-method-override] # coroutine backends are supported if self.release_error_before_close is not None: # pragma: needs fork raise self.release_error_before_close os.close(cast("int", self._context.lock_file_fd)) self._context.lock_file_fd = None if self.release_error is not None: raise self.release_error @NEEDS_FORK # pragma: needs fork def test_third_party_descriptor_is_closed_in_child(tmp_path: Path) -> None: descriptors: list[int] = [] lock = _DescriptorLock(str(tmp_path / "third-party.lock"), is_singleton=False, on_acquired=descriptors.append) lock.acquire() child_pid = _fork_descriptor_probe(descriptors[0], os.fstat) _, status = os.waitpid(child_pid, 0) lock.release() assert os.waitstatus_to_exitcode(status) == 0 @NEEDS_FORK # pragma: needs fork def test_unlock_failure_keeps_descriptor_registered(tmp_path: Path) -> None: descriptors: list[int] = [] lock = _DescriptorLock(str(tmp_path / "retry.lock"), is_singleton=False, on_acquired=descriptors.append) lock.acquire() lock.fail_release = True with pytest.raises(RuntimeError, match="unlock failed"): lock.release() child_pid = _fork_descriptor_probe(descriptors[0], os.fstat) _, status = os.waitpid(child_pid, 0) lock.fail_release = False lock.release() assert os.waitstatus_to_exitcode(status) == 0 @NEEDS_FORK # pragma: needs fork def test_acquisition_failure_keeps_descriptor_registered_until_rollback(tmp_path: Path) -> None: lock = _DescriptorLock(str(tmp_path / "partial.lock"), is_singleton=False) lock.acquire_error = RuntimeError("acquire failed") lock.fail_release = True with pytest.raises(BaseExceptionGroup) as info: lock.acquire(timeout=0) child_pid = _fork_descriptor_probe(cast("int", lock.descriptor), os.fstat) _, status = os.waitpid(child_pid, 0) lock.acquire_error = None lock.fail_release = False lock.release() assert ( _error_details(info.value), os.waitstatus_to_exitcode(status), ) == ( [(RuntimeError, "acquire failed"), (RuntimeError, "unlock failed")], 0, ) @pytest.mark.parametrize( ("fail_release", "expected"), [ pytest.param( False, [(RuntimeError, "acquire failed"), (OSError, "registration failed")], id="rollback-succeeds", ), pytest.param( True, [ (RuntimeError, "acquire failed"), (OSError, "registration failed"), (RuntimeError, "unlock failed"), ], id="rollback-fails", ), ], ) @NEEDS_FORK # pragma: needs fork def test_acquisition_and_registration_errors_are_grouped( tmp_path: Path, mocker: MockerFixture, *, fail_release: bool, expected: list[tuple[type[BaseException], str]], ) -> None: lock = _DescriptorLock(str(tmp_path / "registration.lock"), is_singleton=False) lock.acquire_error = RuntimeError("acquire failed") lock.fail_release = fail_release real_fstat = os.fstat def fail_registration(fd: int) -> os.stat_result: assert fd == lock.descriptor msg = "registration failed" raise OSError(msg) fstat_mock = mocker.patch("os.fstat", side_effect=fail_registration) with pytest.raises(BaseExceptionGroup) as info: lock.acquire(timeout=0) descriptor = cast("int", lock.descriptor) mocker.stop(fstat_mock) child_pid = _fork_descriptor_probe(descriptor, real_fstat) _, status = os.waitpid(child_pid, 0) lock.acquire_error = None lock.fail_release = False lock.release(force=True) with pytest.raises(OSError, match=rf"\[Errno {EBADF}\]"): real_fstat(descriptor) assert (_error_details(info.value), os.waitstatus_to_exitcode(status)) == (expected, 0) @NEEDS_FORK # pragma: needs fork def test_registration_failure_tracks_descriptor_when_rollback_fails(tmp_path: Path, mocker: MockerFixture) -> None: lock = _DescriptorLock(str(tmp_path / "group.lock"), is_singleton=False) lock.fail_release = True real_fstat = os.fstat def fail_registration(fd: int) -> os.stat_result: assert fd == lock.descriptor msg = "registration failed" raise OSError(msg) fstat_mock = mocker.patch("os.fstat", side_effect=fail_registration) with pytest.raises(BaseExceptionGroup) as info: lock.acquire(timeout=0) descriptor = cast("int", lock.descriptor) mocker.stop(fstat_mock) child_pid = _fork_descriptor_probe(descriptor, real_fstat) _, status = os.waitpid(child_pid, 0) lock.fail_release = False lock.release() with pytest.raises(OSError, match=rf"\[Errno {EBADF}\]"): real_fstat(descriptor) assert (_error_details(info.value), os.waitstatus_to_exitcode(status)) == ( [(OSError, "registration failed"), (RuntimeError, "unlock failed")], 0, ) @NEEDS_FORK # pragma: needs fork def test_unverified_descriptor_does_not_close_reused_child_fd(tmp_path: Path) -> None: script = """ from __future__ import annotations import os import sys descriptor = -1 replacement_descriptor = -1 real_fstat = os.fstat def replace_descriptor_in_child() -> None: global replacement_descriptor os.close(descriptor) replacement_descriptor = os.open(sys.argv[2], os.O_CREAT | os.O_RDWR, 0o600) os.register_at_fork(after_in_child=replace_descriptor_in_child) from filelock import BaseFileLock class FailingRollbackLock(BaseFileLock): fail_release = True def _acquire(self) -> None: global descriptor descriptor = self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) def _release(self) -> None: if self.fail_release: raise RuntimeError("rollback failed") os.close(self._context.lock_file_fd if self._context.lock_file_fd is not None else -1) self._context.lock_file_fd = None def fail_descriptor_probe(fd: int) -> os.stat_result: if fd == descriptor: raise OSError("registration failed") return real_fstat(fd) os.fstat = fail_descriptor_probe lock = FailingRollbackLock(sys.argv[1], is_singleton=False) try: lock.acquire(timeout=0) except BaseExceptionGroup: pass else: raise SystemExit(2) child_pid = os.fork() if child_pid == 0: try: real_fstat(replacement_descriptor) except OSError: os._exit(1) os._exit(0 if replacement_descriptor == descriptor else 2) _, status = os.waitpid(child_pid, 0) os.fstat = real_fstat lock.fail_release = False lock.release() raise SystemExit(os.waitstatus_to_exitcode(status)) """ result = subprocess.run( [ sys.executable, "-c", script, str(tmp_path / "lock"), str(tmp_path / "replacement"), ], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork def test_control_flow_registration_error_rolls_back_descriptor(tmp_path: Path, mocker: MockerFixture) -> None: descriptors: list[int] = [] class StopAcquire(BaseException): pass class CapturingLock(_DescriptorLock): def _acquire(self) -> None: super()._acquire() descriptors.append(cast("int", self._context.lock_file_fd)) real_fstat = os.fstat def interrupt_registration(fd: int) -> os.stat_result: if fd == descriptors[0]: raise StopAcquire return real_fstat(fd) mocker.patch("os.fstat", side_effect=interrupt_registration) with pytest.raises(StopAcquire): CapturingLock(str(tmp_path / "interrupt.lock"), is_singleton=False).acquire(timeout=0) with pytest.raises(OSError, match=rf"\[Errno {EBADF}\]"): real_fstat(descriptors[0]) @pytest.mark.asyncio @NEEDS_FORK # pragma: needs fork async def test_coroutine_registration_error_rolls_back_descriptor(tmp_path: Path, mocker: MockerFixture) -> None: lock = _CoroutineDescriptorLock(str(tmp_path / "coroutine.lock"), is_singleton=False) real_fstat = os.fstat def fail_registration(fd: int) -> os.stat_result: if fd == lock.descriptor: msg = "registration failed" raise OSError(msg) return real_fstat(fd) mocker.patch("os.fstat", side_effect=fail_registration) with pytest.raises(OSError, match="registration failed"): await lock.acquire(timeout=0) with pytest.raises(OSError, match=rf"\[Errno {EBADF}\]"): real_fstat(cast("int", lock.descriptor)) @pytest.mark.asyncio async def test_coroutine_acquisition_failure_before_descriptor_propagates(tmp_path: Path) -> None: acquisition_error = RuntimeError("acquire failed") lock = _CoroutineDescriptorLock(str(tmp_path / "coroutine-empty.lock"), is_singleton=False) lock.acquire_error_before_descriptor = acquisition_error with pytest.raises(RuntimeError, match="acquire failed") as info: await lock.acquire(timeout=0) assert (info.value is acquisition_error, lock.is_locked) == (True, False) @NEEDS_FORK # pragma: needs fork @pytest.mark.asyncio async def test_coroutine_acquisition_failure_keeps_descriptor_registered_until_rollback(tmp_path: Path) -> None: lock = _CoroutineDescriptorLock( str(tmp_path / "coroutine-partial.lock"), is_singleton=False, context_error_policy="group" ) lock.acquire_error = RuntimeError("acquire failed") lock.release_error = RuntimeError("release failed") with pytest.raises(BaseExceptionGroup) as info: await lock.acquire(timeout=0) descriptor = cast("int", lock.descriptor) child_pid = _fork_descriptor_probe(descriptor, os.fstat) _, status = await asyncio.to_thread(os.waitpid, child_pid, 0) lock.acquire_error = None lock.release_error = None await lock.release() assert (_error_details(info.value), os.waitstatus_to_exitcode(status)) == ( [(RuntimeError, "acquire failed"), (RuntimeError, "release failed")], 0, ) @pytest.mark.parametrize( ("release_error", "expected"), [ pytest.param( None, [(RuntimeError, "acquire failed"), (OSError, "registration failed")], id="rollback-succeeds", ), pytest.param( RuntimeError("release failed"), [ (RuntimeError, "acquire failed"), (OSError, "registration failed"), (RuntimeError, "release failed"), ], id="rollback-fails", ), ], ) @NEEDS_FORK # pragma: needs fork @pytest.mark.asyncio async def test_coroutine_acquisition_and_registration_errors_are_grouped( tmp_path: Path, mocker: MockerFixture, release_error: BaseException | None, expected: list[tuple[type[BaseException], str]], ) -> None: lock = _CoroutineDescriptorLock(str(tmp_path / "coroutine-registration.lock"), is_singleton=False) lock.acquire_error = RuntimeError("acquire failed") lock.release_error = release_error real_fstat = os.fstat def fail_registration(fd: int) -> os.stat_result: assert fd == lock.descriptor msg = "registration failed" raise OSError(msg) fstat_mock = mocker.patch("os.fstat", side_effect=fail_registration) with pytest.raises(BaseExceptionGroup) as info: await lock.acquire(timeout=0) descriptor = cast("int", lock.descriptor) mocker.stop(fstat_mock) # Probe the closed descriptor before the fork and waitpid below, whose own opens could reclaim its freed number. with pytest.raises(OSError, match=rf"\[Errno {EBADF}\]"): real_fstat(descriptor) child_pid = _fork_descriptor_probe(descriptor, real_fstat) _, status = await asyncio.to_thread(os.waitpid, child_pid, 0) lock.acquire_error = None lock.release_error = None await lock.release(force=True) assert (_error_details(info.value), os.waitstatus_to_exitcode(status)) == (expected, 0) @NEEDS_FORK # pragma: needs fork @pytest.mark.asyncio async def test_coroutine_registration_failure_tracks_descriptor_when_rollback_fails( tmp_path: Path, mocker: MockerFixture ) -> None: lock = _CoroutineDescriptorLock(str(tmp_path / "coroutine-group.lock"), is_singleton=False) lock.release_error_before_close = RuntimeError("rollback failed") real_fstat = os.fstat def fail_registration(fd: int) -> os.stat_result: assert fd == lock.descriptor msg = "registration failed" raise OSError(msg) fstat_mock = mocker.patch("os.fstat", side_effect=fail_registration) with pytest.raises(BaseExceptionGroup) as info: await lock.acquire(timeout=0) descriptor = cast("int", lock.descriptor) mocker.stop(fstat_mock) child_pid = _fork_descriptor_probe(descriptor, real_fstat) _, status = await asyncio.to_thread(os.waitpid, child_pid, 0) lock.release_error_before_close = None await lock.release() with pytest.raises(OSError, match=rf"\[Errno {EBADF}\]"): real_fstat(descriptor) assert (_error_details(info.value), os.waitstatus_to_exitcode(status)) == ( [(OSError, "registration failed"), (RuntimeError, "rollback failed")], 0, ) @NEEDS_GENERATOR_EXCEPTION_CONTEXT @pytest.mark.asyncio async def test_coroutine_on_acquired_error_preserves_context(tmp_path: Path) -> None: callback_error = RuntimeError("hook failed") prior_context = LookupError("prior callback failure") callback_error.__context__ = prior_context def fail_callback(_fd: int) -> None: raise callback_error lock = _CoroutineDescriptorLock( str(tmp_path / "coroutine-hook-context.lock"), is_singleton=False, on_acquired=fail_callback ) with pytest.raises(RuntimeError, match="hook failed") as info: await lock.acquire(timeout=0) assert (info.value is callback_error, callback_error.__context__, lock.is_locked) == (True, prior_context, False) @pytest.mark.asyncio async def test_coroutine_on_acquired_and_rollback_errors_are_grouped(tmp_path: Path) -> None: callback_error = RuntimeError("hook failed") def fail_callback(_fd: int) -> None: raise callback_error lock = _CoroutineDescriptorLock( str(tmp_path / "coroutine-hook.lock"), is_singleton=False, on_acquired=fail_callback ) lock.release_error = RuntimeError("release failed") with pytest.raises(BaseExceptionGroup) as info: await lock.acquire(timeout=0) assert _error_details(info.value) == [ (RuntimeError, "hook failed"), (RuntimeError, "release failed"), ] def _error_details(group: BaseExceptionGroup) -> list[tuple[type[BaseException], str]]: return [(type(error), str(error)) for error in group.exceptions] def _fork_descriptor_probe( # pragma: needs fork descriptor: int, stat_descriptor: Callable[[int], os.stat_result] ) -> int: def probe_child() -> NoReturn: with pytest.raises(OSError, match=rf"\[Errno {EBADF}\]"): stat_descriptor(descriptor) exit_child(0) return fork_process(probe_child) filelock-4.2.8/tests/test_fork_coordination.py0000644000000000000000000012244515272561277016555 0ustar00from __future__ import annotations import os import subprocess # ruff:ignore[suspicious-subprocess-import] # isolated interpreters control at-fork registration order import sys import threading from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING, Final, Literal import pytest from capabilities import CAPABILITIES from filelock import BaseFileLock, Timeout, has_fcntl from filelock._api import _fork_transition from tests.capability_marks import ( NEEDS_AUDIT_EVENTS, NEEDS_FORK, NEEDS_FORK1, NEEDS_PROMPT_FINALIZATION, NEEDS_REGISTER_AT_FORK, ) from tests.fork_helpers import exit_child, fork_process if TYPE_CHECKING: from pathlib import Path _FORK_WARNING: Final[pytest.MarkDecorator] = pytest.mark.filterwarnings( "ignore:.*multi-threaded, use of fork.*:DeprecationWarning" ) _NEEDS_SETTRACE_SAFE_AUDIT_HOOKS: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["settrace-safe-audit-hooks"], reason="a Python audit hook makes concurrent sys.settrace calls raise on this runtime, so filelock installs none", ) @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_callbacks_registered_before_filelock_can_use_locks(tmp_path: Path) -> None: script = """ from __future__ import annotations import os import sys callback_lock = None child_ok = False parent_callback_ran = False def before() -> None: global callback_lock callback_lock = FileLock(sys.argv[2], is_singleton=True) callback_lock.acquire() def parent() -> None: global parent_callback_ran if callback_lock is None or not callback_lock.is_locked: return callback_lock.release() parent_callback_ran = True def child() -> None: global child_ok if callback_lock is None: return child_singleton = FileLock(sys.argv[1], is_singleton=True) child_ok = ( not callback_lock.is_locked and callback_lock.lock_counter == 0 and child_singleton is not parent_singleton ) os.register_at_fork(before=before, after_in_parent=parent, after_in_child=child) from filelock import FileLock parent_singleton = FileLock(sys.argv[1], is_singleton=True) child_pid = os.fork() if child_pid == 0: os._exit(0 if child_ok else 1) _, status = os.waitpid(child_pid, 0) if ( os.waitstatus_to_exitcode(status) != 0 or not parent_callback_ran or callback_lock is None or callback_lock.is_locked ): sys.exit(2) """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "singleton.lock"), str(tmp_path / "callback.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.parametrize( "audit_mode", [pytest.param("installed", id="audit-installed"), pytest.param("rejected", id="audit-rejected")], ) def test_concurrent_fork_after_first_snapshot_does_not_deadlock( tmp_path: Path, audit_mode: Literal["installed", "rejected"] ) -> None: script = """ from __future__ import annotations import os import sys import threading from queue import Queue entered = threading.Event() release = threading.Event() statuses: Queue[int] = Queue() def block_before_fork() -> None: if threading.current_thread().name == "fork-owner": entered.set() if not release.wait(timeout=5): raise RuntimeError("fork owner was not released") os.register_at_fork(before=block_before_fork) if sys.argv[2] == "rejected": from typing import TYPE_CHECKING if TYPE_CHECKING: from _typeshed import Unused def reject_add_hook(event: str, _args: Unused) -> None: if event == "sys.addaudithook": raise RuntimeError sys.addaudithook(reject_add_hook) from filelock import FileLock FileLock(sys.argv[1], is_singleton=False) def fork_once() -> None: child_pid = os.fork() if child_pid == 0: os._exit(0) _, status = os.waitpid(child_pid, 0) statuses.put(os.waitstatus_to_exitcode(status)) owner = threading.Thread(target=fork_once, name="fork-owner") owner.start() if not entered.wait(timeout=5): sys.exit(1) try: child_pid = os.fork() except RuntimeError as exception: second_status = 0 if "unsafe while filelock is changing descriptor ownership" in str(exception) else 1 else: if child_pid == 0: os._exit(0) _, status = os.waitpid(child_pid, 0) second_status = os.waitstatus_to_exitcode(status) release.set() owner.join(timeout=5) if second_status != 0 or owner.is_alive() or statuses.get(timeout=1) != 0: sys.exit(2) """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "parent.lock"), audit_mode], check=False, capture_output=True, text=True, timeout=10, ) assert result.returncode == 0, result.stderr @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_fork_interrupted_while_waiting_for_a_transition_reopens_admission(tmp_path: Path) -> None: # CPython reports a signal handler that raises while the fork waits for another thread's lock call, and the fork # goes on. The other threads then blocked in their next filelock call for good, and the child kept the parent's # hold. script = """ from __future__ import annotations import os import signal import sys import threading from unittest import mock from filelock import FileLock entered = threading.Event() release = threading.Event() real_open = os.open def slow_open(*args: object, **kwargs: object) -> int: if threading.current_thread().name == "slow-open": entered.set() release.wait(timeout=5) return real_open(*args, **kwargs) def use_lock(path: str) -> None: with FileLock(path).acquire(timeout=0): pass def fork_once() -> int: child_pid = os.fork() if child_pid == 0: os._exit(1 if held.is_locked else 0) _, status = os.waitpid(child_pid, 0) return os.waitstatus_to_exitcode(status) def interrupt(_signum: int, _frame: object) -> None: raise KeyboardInterrupt held = FileLock(sys.argv[3]) held.acquire() with mock.patch("os.open", side_effect=slow_open): slow = threading.Thread(target=use_lock, args=(sys.argv[1],), name="slow-open", daemon=True) slow.start() if not entered.wait(timeout=5): sys.exit(1) signal.signal(signal.SIGALRM, interrupt) signal.setitimer(signal.ITIMER_REAL, 0.2) interrupted_status = fork_once() release.set() slow.join(timeout=5) later = threading.Thread(target=use_lock, args=(sys.argv[2],), daemon=True) later.start() later.join(timeout=5) if interrupted_status != 0 or slow.is_alive() or later.is_alive(): sys.exit(2) if fork_once() != 0: sys.exit(3) """ result = subprocess.run( [sys.executable, "-c", script, *(str(tmp_path / name) for name in ("slow.lock", "later.lock", "held.lock"))], check=False, capture_output=True, text=True, timeout=20, ) assert result.returncode == 0, result.stderr @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_fork_interrupted_while_taking_the_gate_reports_no_after_hook_error(tmp_path: Path) -> None: # CPython forks anyway when the before hook raises, and the after hook then finds no depth stored for the thread. script = """ from __future__ import annotations import os import sys import threading from types import FrameType from filelock import FileLock from filelock._api import _pin_fork_objects fired = False def hook(frame: FrameType, event: str, _arg: object) -> None: global fired if ( event == "call" and frame.f_code is threading.Condition.__enter__.__code__ and frame.f_back is not None and frame.f_back.f_code is _pin_fork_objects.__code__ and not fired ): fired = True raise KeyboardInterrupt held = FileLock(sys.argv[1]) held.acquire() sys.setprofile(hook) try: child_pid = os.fork() finally: sys.setprofile(None) if child_pid == 0: os._exit(0) os.waitpid(child_pid, 0) later = threading.Thread(target=lambda: FileLock(sys.argv[2]).acquire(timeout=0), daemon=True) later.start() later.join(timeout=5) sys.exit(0 if fired and not later.is_alive() else 1) """ result = subprocess.run( [sys.executable, "-c", script, *(str(tmp_path / name) for name in ("held.lock", "later.lock"))], check=False, capture_output=True, text=True, timeout=20, ) assert result.returncode == 0, result.stderr assert "_resume_parent_after_fork" not in result.stderr @NEEDS_REGISTER_AT_FORK # pragma: needs register-at-fork @NEEDS_PROMPT_FINALIZATION @pytest.mark.parametrize("held", [pytest.param("gate", id="gate"), pytest.param("registry_lock", id="registry-lock")]) def test_lock_finalized_at_exit_skips_fork_bookkeeping_a_frozen_daemon_thread_holds(tmp_path: Path, held: str) -> None: # Daemon threads stop wherever they are at exit; one stopped inside the fork bookkeeping keeps its lock forever. # The lock lives in a module of its own: the daemon thread's function keeps __main__'s globals alive past exit. script: Final = """ import sys, threading, types from filelock import FileLock from filelock._api import _FORK_STATE inside = threading.Event() def stop_inside_fork_bookkeeping(): with getattr(_FORK_STATE, sys.argv[2]): inside.set() threading.Event().wait() holder = sys.modules["holder"] = types.ModuleType("holder") holder.lock = FileLock(sys.argv[1]) holder.lock.acquire() threading.Thread(target=stop_inside_fork_bookkeeping, daemon=True).start() inside.wait() """ result: Final = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "a"), held], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @NEEDS_AUDIT_EVENTS def test_import_adds_audit_hook_only_where_settrace_survives_it() -> None: script = """ import sys added = [] sys.addaudithook(lambda event, _args: added.append(event) if event == "sys.addaudithook" else None) import filelock print(len(added)) """ result = subprocess.run([sys.executable, "-c", script], check=False, capture_output=True, text=True, timeout=10) assert (int(result.stdout) > 0) is CAPABILITIES["settrace-safe-audit-hooks"] @NEEDS_FORK # pragma: needs fork @_NEEDS_SETTRACE_SAFE_AUDIT_HOOKS # pragma: needs settrace-safe-audit-hooks def test_audit_hook_survives_interpreter_shutdown() -> None: script = """ from __future__ import annotations from collections.abc import Callable import filelock._api as api class LateAudit: def __del__(self, object_id: Callable[[LateAudit], int] = id) -> None: object_id(self) api.__dict__["_late_shutdown_audit"] = LateAudit() """ result = subprocess.run( [sys.executable, "-c", script], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_NEEDS_SETTRACE_SAFE_AUDIT_HOOKS # pragma: needs settrace-safe-audit-hooks @pytest.mark.parametrize( "event", [ pytest.param("os.fork", id="fork"), pytest.param("os.forkpty", id="forkpty"), pytest.param("_posixsubprocess.fork_exec", id="fork-exec"), ], ) def test_audit_rejects_process_creation_inside_descriptor_transition( tmp_path: Path, event: Literal["os.fork", "os.forkpty", "_posixsubprocess.fork_exec"], ) -> None: class AuditedLock(BaseFileLock): def _acquire(self) -> None: self._context.lock_file_fd = None if event == "_posixsubprocess.fork_exec": sys.audit(event, (), (), None) else: sys.audit(event) _release = _acquire with pytest.raises(RuntimeError, match="unsafe while filelock is changing descriptor ownership"): AuditedLock(str(tmp_path / "audit.lock"), is_singleton=False).acquire(timeout=0) @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.parametrize( "fork_mode", [ pytest.param("rejected-audit", id="rejected-audit-hook"), pytest.param( "fork1", marks=NEEDS_FORK1, id="fork1", ), ], ) def test_fork_without_audit_guard_snapshots_current_transition( tmp_path: Path, fork_mode: Literal["rejected-audit", "fork1"] ) -> None: script = """ from __future__ import annotations import os import sys from errno import EBADF if sys.argv[1] == "rejected-audit": from typing import TYPE_CHECKING if TYPE_CHECKING: from _typeshed import Unused def reject_filelock_hook(event: str, _args: Unused) -> None: if event == "sys.addaudithook": raise RuntimeError sys.addaudithook(reject_filelock_hook) from filelock import BaseFileLock fork = os.fork if sys.argv[1] == "rejected-audit" else os.fork1 class ForkingLock(BaseFileLock): child_status = -1 def _acquire(self) -> None: descriptor = self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) child_pid = fork() if child_pid == 0: try: os.fstat(descriptor) except OSError as exception: os._exit(0 if exception.errno == EBADF else 1) os._exit(1) _, status = os.waitpid(child_pid, 0) self.child_status = os.waitstatus_to_exitcode(status) def _release(self) -> None: os.close(self._context.lock_file_fd if self._context.lock_file_fd is not None else -1) self._context.lock_file_fd = None lock = ForkingLock(sys.argv[2], is_singleton=False) lock.acquire() lock.release() raise SystemExit(lock.child_status) """ result = subprocess.run( [sys.executable, "-c", script, fork_mode, str(tmp_path / "transition.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.skipif(not has_fcntl, reason="fcntl.flock required") @pytest.mark.skipif(sys.implementation.name != "cpython", reason="CPython fcntl audit event required") @pytest.mark.parametrize( "fork_mode", [ pytest.param("rejected-audit", id="rejected-audit-hook"), pytest.param( "fork1", marks=NEEDS_FORK1, id="fork1", ), ], ) def test_native_descriptor_is_visible_during_flock_audit( tmp_path: Path, fork_mode: Literal["rejected-audit", "fork1"] ) -> None: script = """ from __future__ import annotations import os import sys from errno import EBADF child_status = -1 forked = False # Typed for the fcntl.flock payload (fd, cmd); every other event returns before touching args. def audit_hook(event: str, args: tuple[int, int]) -> None: global child_status, forked if event == "sys.addaudithook" and sys.argv[1] == "rejected-audit": raise RuntimeError if event != "fcntl.flock" or forked: return forked = True descriptor = args[0] child_pid = (os.fork if sys.argv[1] == "rejected-audit" else os.fork1)() if child_pid == 0: try: os.fstat(descriptor) except OSError as exception: os._exit(0 if exception.errno == EBADF else 1) os._exit(1) _, status = os.waitpid(child_pid, 0) child_status = os.waitstatus_to_exitcode(status) sys.addaudithook(audit_hook) from filelock import FileLock lock = FileLock(sys.argv[2], is_singleton=False) lock.acquire() lock.release() raise SystemExit(child_status) """ result = subprocess.run( [sys.executable, "-c", script, fork_mode, str(tmp_path / "native.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_child_unwinds_pre_fork_transition_before_new_acquire(tmp_path: Path) -> None: script = """ from __future__ import annotations import os import sys from errno import EBADF from typing import TYPE_CHECKING if TYPE_CHECKING: from _typeshed import Unused def reject_filelock_hook(event: str, _args: Unused) -> None: if event == "sys.addaudithook": raise RuntimeError sys.addaudithook(reject_filelock_hook) from filelock import BaseFileLock class ForkingLock(BaseFileLock): child_pid = -1 def _acquire(self) -> None: self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) self.child_pid = os.fork() def _release(self) -> None: os.close(self._context.lock_file_fd if self._context.lock_file_fd is not None else -1) self._context.lock_file_fd = None first = ForkingLock(sys.argv[1], is_singleton=False) try: first.acquire() except RuntimeError as exception: if first.child_pid != 0 or "inherited across fork" not in str(exception): raise class ProbeLock(BaseFileLock): child_status = -1 def _acquire(self) -> None: descriptor = self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) child_pid = os.fork() if child_pid == 0: try: os.fstat(descriptor) except OSError as error: os._exit(0 if error.errno == EBADF else 1) os._exit(1) _, status = os.waitpid(child_pid, 0) self.child_status = os.waitstatus_to_exitcode(status) def _release(self) -> None: os.close(self._context.lock_file_fd if self._context.lock_file_fd is not None else -1) self._context.lock_file_fd = None probe = ProbeLock(sys.argv[2], is_singleton=False) probe.acquire() probe.release() os._exit(probe.child_status) _, status = os.waitpid(first.child_pid, 0) first.release() raise SystemExit(os.waitstatus_to_exitcode(status)) """ result = subprocess.run( [ sys.executable, "-c", script, str(tmp_path / "first.lock"), str(tmp_path / "probe.lock"), ], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_overlapping_coroutine_transitions_close_every_pending_descriptor(tmp_path: Path) -> None: script = """ from __future__ import annotations import asyncio import os import sys from errno import EBADF from typing import TYPE_CHECKING if TYPE_CHECKING: from _typeshed import Unused def reject_filelock_hook(event: str, _args: Unused) -> None: if event == "sys.addaudithook": raise RuntimeError sys.addaudithook(reject_filelock_hook) from filelock import BaseAsyncFileLock both_pending = asyncio.Event() allow_return = asyncio.Event() descriptors: list[int] = [] child_status = -1 class OverlapLock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] # coroutine backends are supported global child_status descriptor = self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) descriptors.append(descriptor) if len(descriptors) == 2: both_pending.set() await both_pending.wait() if self.lock_file != sys.argv[1]: await allow_return.wait() return child_pid = os.fork() if child_pid == 0: for inherited in descriptors: try: os.fstat(inherited) except OSError as exception: if exception.errno == EBADF: continue os._exit(1) os._exit(0) _, status = os.waitpid(child_pid, 0) child_status = os.waitstatus_to_exitcode(status) allow_return.set() async def _release(self) -> None: # ty: ignore[invalid-method-override] # coroutine backends are supported os.close(self._context.lock_file_fd if self._context.lock_file_fd is not None else -1) self._context.lock_file_fd = None async def main() -> None: first = OverlapLock(sys.argv[1], thread_local=False, is_singleton=False, run_in_executor=False) second = OverlapLock(sys.argv[2], thread_local=False, is_singleton=False, run_in_executor=False) await asyncio.gather(first.acquire(timeout=0), second.acquire(timeout=0)) await asyncio.gather(first.release(), second.release()) raise SystemExit(child_status) asyncio.run(main()) """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "first.lock"), str(tmp_path / "second.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_pending_descriptor_with_unknown_identity_is_preserved_in_child(tmp_path: Path) -> None: script = """ from __future__ import annotations import os import sys from typing import TYPE_CHECKING if TYPE_CHECKING: from _typeshed import Unused def reject_filelock_hook(event: str, _args: Unused) -> None: if event == "sys.addaudithook": raise RuntimeError sys.addaudithook(reject_filelock_hook) from filelock import BaseFileLock real_fstat = os.fstat class PendingLock(BaseFileLock): child_status = -1 def _acquire(self) -> None: descriptor = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) self._context.pending_lock_file_fd = descriptor def fail_pending_identity(fd: int) -> os.stat_result: if fd == descriptor: raise OSError("identity unavailable") return real_fstat(fd) os.fstat = fail_pending_identity child_pid = os.fork() os.fstat = real_fstat if child_pid == 0: real_fstat(descriptor) raise SystemExit(0) _, status = os.waitpid(child_pid, 0) self.child_status = os.waitstatus_to_exitcode(status) stat_result = real_fstat(descriptor) self._context.pending_lock_file_fd = None self._context.lock_file_fd = descriptor self._context.lock_file_fd_identity = stat_result.st_dev, stat_result.st_ino def _release(self) -> None: os.close(self._context.lock_file_fd if self._context.lock_file_fd is not None else -1) self._context.lock_file_fd = None lock = PendingLock(sys.argv[1], is_singleton=False) lock.acquire(timeout=0) lock.release() raise SystemExit(lock.child_status) """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "pending.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_child_replaces_parameter_model_mutex_held_during_construction(tmp_path: Path) -> None: script = """ from __future__ import annotations import inspect import os import sys import threading import warnings from collections.abc import Callable from queue import Queue warnings.filterwarnings("ignore", message=".*multi-threaded, use of fork.*", category=DeprecationWarning) from typing import TYPE_CHECKING if TYPE_CHECKING: from _typeshed import Unused def reject_filelock_hook(event: str, _args: Unused) -> None: if event == "sys.addaudithook": raise RuntimeError sys.addaudithook(reject_filelock_hook) from filelock import BaseFileLock parent_pid = os.getpid() forked = False paused = threading.Event() release = threading.Event() children: Queue[int] = Queue() real_signature = inspect.signature class BlockingLock(BaseFileLock): def _acquire(self) -> None: self._context.lock_file_fd = None def _release(self) -> None: self._context.lock_file_fd = None class ChildLock(BaseFileLock): def _acquire(self) -> None: self._context.lock_file_fd = None def _release(self) -> None: self._context.lock_file_fd = None def blocking_signature(target: Callable[..., BaseFileLock | None]) -> inspect.Signature: global forked if target is BlockingLock.__init__ and not forked: forked = True child_pid = os.fork() if child_pid == 0: ChildLock(sys.argv[2], is_singleton=False) os._exit(0) children.put(child_pid) paused.set() if not release.wait(timeout=5): raise RuntimeError("parent construction was not released") return real_signature(target) inspect.signature = blocking_signature def construct() -> None: BlockingLock(sys.argv[1], is_singleton=False) worker = threading.Thread(target=construct) worker.start() if not paused.wait(timeout=5): sys.exit(1) child_pid = children.get(timeout=1) _, status = os.waitpid(child_pid, 0) release.set() worker.join(timeout=5) if os.waitstatus_to_exitcode(status) != 0 or worker.is_alive() or os.getpid() != parent_pid: sys.exit(2) """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "parent.lock"), str(tmp_path / "child.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_registration_transition_completes_after_fork_closes_admission(tmp_path: Path) -> None: script = """ from __future__ import annotations import os import sys import threading import warnings from queue import Queue warnings.filterwarnings("ignore", message=".*multi-threaded, use of fork.*", category=DeprecationWarning) admission_waiting = threading.Event() real_condition_wait = threading.Condition.wait def observed_wait(condition: threading.Condition, timeout: float | None = None) -> bool: if threading.current_thread().name == "forker": admission_waiting.set() return real_condition_wait(condition, timeout) threading.Condition.wait = observed_wait from filelock import BaseFileLock entered = threading.Event() release = threading.Event() failures: Queue[str] = Queue() fork_status: Queue[int] = Queue() class BlockingLock(BaseFileLock): def _acquire(self) -> None: self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) entered.set() if not release.wait(timeout=5): raise RuntimeError("backend acquisition was not released") def _release(self) -> None: os.close(self._context.lock_file_fd if self._context.lock_file_fd is not None else -1) self._context.lock_file_fd = None lock = BlockingLock(sys.argv[1], is_singleton=False) def acquire() -> None: try: lock.acquire(timeout=0) except BaseException as exception: failures.put(f"acquire: {exception!r}") def fork() -> None: try: child_pid = os.fork() if child_pid == 0: os._exit(0) _, status = os.waitpid(child_pid, 0) fork_status.put(os.waitstatus_to_exitcode(status)) except BaseException as exception: failures.put(f"fork: {exception!r}") worker = threading.Thread(target=acquire, name="acquirer") worker.start() if not entered.wait(timeout=5): sys.exit(1) forker = threading.Thread(target=fork, name="forker") forker.start() if not admission_waiting.wait(timeout=5): sys.exit(2) release.set() worker.join(timeout=5) forker.join(timeout=5) if worker.is_alive() or forker.is_alive() or not failures.empty() or fork_status.get(timeout=1) != 0: sys.exit(3) lock.release() """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "admission.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.skipif(not has_fcntl, reason="fcntl.flock required") def test_cancelled_async_acquire_unregisters_reused_descriptor(tmp_path: Path) -> None: script = """ from __future__ import annotations import asyncio import faulthandler import os import signal import sys import threading import time import warnings from filelock import AsyncFileLock warnings.filterwarnings("ignore", message=".*multi-threaded, use of fork.*", category=DeprecationWarning) child_pid = 0 def report_stall(_signum: int, _frame: object) -> None: # Every wait below is bounded, so reaching this means something wedged: the NetBSD fork deadlock (#707, #689) # arrived as a bare SIGKILL naming nothing. Print every stack, then kill the child, which holds the captured # pipes: subprocess.run() waits for those to close even after it has killed this process. faulthandler.dump_traceback(all_threads=True) if child_pid: os.kill(child_pid, signal.SIGKILL) os._exit(7) faulthandler.enable() signal.signal(signal.SIGALRM, report_stall) signal.alarm(15) async def main() -> tuple[int, tuple[int, int]]: callback_started = asyncio.Event() finish_callback = threading.Event() loop = asyncio.get_running_loop() descriptor = -1 identity = (-1, -1) def block_after_acquire(fd: int) -> None: nonlocal descriptor, identity descriptor = fd stat_result = os.fstat(fd) identity = stat_result.st_dev, stat_result.st_ino loop.call_soon_threadsafe(callback_started.set) if not finish_callback.wait(timeout=5): raise RuntimeError("acquisition callback was not released") lock = AsyncFileLock(sys.argv[1], on_acquired=block_after_acquire) acquire_task = asyncio.create_task(lock.acquire()) await asyncio.wait_for(callback_started.wait(), timeout=5) acquire_task.cancel() finish_callback.set() try: await acquire_task except asyncio.CancelledError: pass else: raise SystemExit(1) if lock.is_locked or lock.lock_counter != 0: raise SystemExit(2) return descriptor, identity descriptor, identity = asyncio.run(main()) path_stat = os.stat(sys.argv[1]) if (path_stat.st_dev, path_stat.st_ino) != identity: raise SystemExit(3) occupant = os.open(os.devnull, os.O_RDONLY) if occupant != descriptor: os.dup2(occupant, descriptor) source = os.open(sys.argv[1], os.O_RDWR) if source == descriptor: raise SystemExit(4) if (source_stat := os.fstat(source)).st_dev != identity[0] or source_stat.st_ino != identity[1]: raise SystemExit(5) os.dup2(source, descriptor) os.close(source) if occupant != descriptor: os.close(occupant) # The canceled acquire ran the flock backend in the default executor. asyncio.run() joins that worker on the way out, # but Thread.join() returns while the OS thread is still unwinding, and NetBSD deadlocks a child forked from a process # that still carries one (#707). So fork from module scope with the loop closed, leave the worker time to go, and # report a thread that is still counted rather than wedging the fork on it. time.sleep(0.5) if threading.active_count() != 1: raise SystemExit(6) child_pid = os.fork() if child_pid == 0: try: replacement_stat = os.fstat(descriptor) except OSError: os._exit(1) os._exit(0 if (replacement_stat.st_dev, replacement_stat.st_ino) == identity else 2) _, status = os.waitpid(child_pid, 0) signal.alarm(0) os.close(descriptor) raise SystemExit(os.waitstatus_to_exitcode(status)) """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "canceled.lock")], check=False, capture_output=True, text=True, timeout=30, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_parent_callback_rejects_reentrant_singleton_construction(tmp_path: Path) -> None: script = """ from __future__ import annotations import os import sys from collections.abc import Callable active_constructor: Callable[[], BaseFileLock | SoftReadWriteLock] | None = None callback_errors: list[str] = [] callback_instances: list[BaseFileLock | SoftReadWriteLock] = [] def construct_in_parent_callback() -> None: if active_constructor is None: return try: callback_instances.append(active_constructor()) except RuntimeError as exception: callback_errors.append(str(exception)) os.register_at_fork(after_in_parent=construct_in_parent_callback) from filelock import BaseFileLock, SoftReadWriteLock fork_in_progress = False children: list[int] = [] def fork_once() -> None: global fork_in_progress if fork_in_progress: return fork_in_progress = True child_pid = os.fork() if child_pid == 0: os._exit(0) children.append(child_pid) fork_in_progress = False class ForkingFileLock(BaseFileLock): def __init__( self, lock_file: str, timeout: float = -1, thread_local: bool = True, *, is_singleton: bool = False ) -> None: fork_once() super().__init__(lock_file, timeout, thread_local=thread_local, is_singleton=is_singleton) def _acquire(self) -> None: self._context.lock_file_fd = None def _release(self) -> None: self._context.lock_file_fd = None class ForkingSoftReadWriteLock(SoftReadWriteLock): def __init__( self, lock_file: str, timeout: float = -1, *, blocking: bool = True, is_singleton: bool = True, heartbeat_interval: float = 30.0, stale_threshold: float | None = None, poll_interval: float = 0.25, ) -> None: fork_once() super().__init__( lock_file, timeout, blocking=blocking, is_singleton=is_singleton, heartbeat_interval=heartbeat_interval, stale_threshold=stale_threshold, poll_interval=poll_interval, ) file_path, read_write_path = sys.argv[1:] active_constructor = lambda: ForkingFileLock(file_path, thread_local=False, is_singleton=True) file_lock = ForkingFileLock(file_path, thread_local=False, is_singleton=True) active_constructor = lambda: ForkingSoftReadWriteLock(read_write_path, is_singleton=True) read_write_lock = ForkingSoftReadWriteLock(read_write_path, is_singleton=True) active_constructor = None statuses = [os.waitstatus_to_exitcode(os.waitpid(child_pid, 0)[1]) for child_pid in children] if ( statuses != [0, 0] or callback_instances or len(callback_errors) != 2 or not all("Singleton lock construction is already active" in error for error in callback_errors) or ForkingFileLock(file_path, thread_local=False, is_singleton=True) is not file_lock or ForkingSoftReadWriteLock(read_write_path, is_singleton=True) is not read_write_lock ): sys.exit(1) """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "file.lock"), str(tmp_path / "read-write.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_singleton_construction_crossing_fork_does_not_poison_child_cache(tmp_path: Path) -> None: script = """ from __future__ import annotations import os import sys from filelock import BaseFileLock parent_pid = os.getpid() forked = False children: list[int] = [] class ForkingLock(BaseFileLock): def __init__(self, lock_file: str, thread_local: bool = True, *, is_singleton: bool = False) -> None: global forked if not forked: forked = True child_pid = os.fork() if child_pid != 0: children.append(child_pid) super().__init__(lock_file, thread_local=thread_local, is_singleton=is_singleton) def _acquire(self) -> None: self._context.lock_file_fd = None def _release(self) -> None: self._context.lock_file_fd = None try: parent_lock = ForkingLock(sys.argv[1], thread_local=False, is_singleton=True) except RuntimeError as exception: if os.getpid() == parent_pid or "Lock construction cannot continue after fork" not in str(exception): raise child_lock = ForkingLock(sys.argv[1], thread_local=False, is_singleton=True) raise SystemExit(0 if ForkingLock(sys.argv[1], thread_local=False, is_singleton=True) is child_lock else 1) _, status = os.waitpid(children[0], 0) if ( os.waitstatus_to_exitcode(status) != 0 or ForkingLock(sys.argv[1], thread_local=False, is_singleton=True) is not parent_lock ): sys.exit(2) """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "singleton.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_soft_read_write_construction_crossing_fork_does_not_poison_child_cache( tmp_path: Path, ) -> None: script = """ from __future__ import annotations import os import sys from filelock import SoftReadWriteLock parent_pid = os.getpid() forked = False children: list[int] = [] class ForkingLock(SoftReadWriteLock): def __init__( self, lock_file: str, timeout: float = -1, *, blocking: bool = True, is_singleton: bool = True, heartbeat_interval: float = 30.0, stale_threshold: float | None = None, poll_interval: float = 0.25, ) -> None: global forked if not forked: forked = True child_pid = os.fork() if child_pid != 0: children.append(child_pid) super().__init__( lock_file, timeout, blocking=blocking, is_singleton=is_singleton, heartbeat_interval=heartbeat_interval, stale_threshold=stale_threshold, poll_interval=poll_interval, ) try: parent_lock = ForkingLock(sys.argv[1], is_singleton=True) except RuntimeError as exception: if os.getpid() == parent_pid or "Lock construction cannot continue after fork" not in str(exception): raise child_lock = ForkingLock(sys.argv[1], is_singleton=True) child_cached = ForkingLock(sys.argv[1], is_singleton=True) child_lock.close() raise SystemExit(0 if child_cached is child_lock else 1) _, status = os.waitpid(children[0], 0) parent_cached = ForkingLock(sys.argv[1], is_singleton=True) parent_lock.close() if os.waitstatus_to_exitcode(status) != 0 or parent_cached is not parent_lock: sys.exit(2) """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "singleton.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") def test_fork_exec_from_another_thread_remains_available(tmp_path: Path) -> None: entered, release, finished = threading.Event(), threading.Event(), threading.Event() class BlockingTransitionLock(BaseFileLock): def _acquire(self) -> None: entered.set() release.wait(timeout=5) self._context.lock_file_fd = None _release = _acquire def acquire() -> None: try: BlockingTransitionLock(str(tmp_path / "transition.lock"), is_singleton=False).acquire(timeout=0) except Timeout: finished.set() worker = threading.Thread(target=acquire) worker.start() assert entered.wait(timeout=5) sys.audit("_posixsubprocess.fork_exec", (), (), None) result = subprocess.run([sys.executable, "-c", ""], check=False, timeout=5) release.set() worker.join(timeout=5) assert (result.returncode, finished.is_set(), worker.is_alive()) == (0, True, False) @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_child_replaces_singleton_mutex_held_by_vanished_thread(tmp_path: Path) -> None: entered, release = threading.Event(), threading.Event() parent_pid = os.getpid() class BlockingLock(BaseFileLock): def __init__(self, lock_file: str, *, is_singleton: bool = False) -> None: if os.getpid() == parent_pid: entered.set() release.wait(timeout=5) super().__init__(lock_file, thread_local=False, is_singleton=is_singleton) def _acquire(self) -> None: self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) def _release(self) -> None: os.close(self._context.lock_file_fd if self._context.lock_file_fd is not None else -1) self._context.lock_file_fd = None worker = threading.Thread(target=BlockingLock, args=(str(tmp_path / "mutex.lock"),), kwargs={"is_singleton": True}) worker.start() assert entered.wait(timeout=5) if (child_pid := fork_process()) == 0: # pragma: forked child child_lock = BlockingLock(str(tmp_path / "mutex.lock"), is_singleton=True) child_lock.acquire(timeout=0) child_lock.release() exit_child(0) _, status = os.waitpid(child_pid, 0) release.set() worker.join(timeout=5) assert (os.waitstatus_to_exitcode(status), worker.is_alive()) == (0, False) @NEEDS_FORK def test_fork_transition_finished_on_another_thread_releases_the_entering_thread() -> None: # pragma: needs fork # A generator suspended inside a transition can be finalized on any thread. transition: Final = _fork_transition() transition.__enter__() # ruff:ignore[unnecessary-dunder-call] # entered and left on different threads with ThreadPoolExecutor(max_workers=1) as pool: pool.submit(transition.__exit__, None, None, None).result() filelock-4.2.8/tests/test_fork_ownership.py0000644000000000000000000003615115272561277016101 0ustar00from __future__ import annotations import asyncio import gc import os import subprocess # ruff:ignore[suspicious-subprocess-import] # fresh interpreter must register the callback before importing filelock import sys import threading from errno import EBADF from queue import Queue from typing import TYPE_CHECKING, Final, Literal, NoReturn import pytest from filelock import ( AcquireReturnProxy, AsyncFileLock, AsyncSoftFileLock, BaseAsyncFileLock, BaseFileLock, FileLock, SoftFileLock, SoftReadWriteLock, Timeout, ) from tests.capability_marks import NEEDS_FORK, NEEDS_REGISTER_AT_FORK from tests.fork_helpers import exit_child, fork_process if TYPE_CHECKING: from pathlib import Path from pytest_mock import MockerFixture _FORK_WARNING: Final[pytest.MarkDecorator] = pytest.mark.filterwarnings( "ignore:.*multi-threaded, use of fork.*:DeprecationWarning" ) @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.parametrize( ("lock_type", "action"), [ pytest.param(FileLock, "release", id="native-release"), pytest.param(FileLock, "context", id="native-context"), pytest.param(FileLock, "collect", id="native-collect"), pytest.param(SoftFileLock, "release", id="soft-release"), pytest.param(SoftFileLock, "context", id="soft-context"), pytest.param(SoftFileLock, "collect", id="soft-collect"), ], ) def test_child_cleanup_preserves_parent_lock( tmp_path: Path, lock_type: type[BaseFileLock], action: Literal["release", "context", "collect"], ) -> None: path = str(tmp_path / "parent.lock") lock = lock_type(path, thread_local=False, is_singleton=False) proxy = lock.acquire() def cleanup_child() -> NoReturn: _run_child_cleanup(lock, proxy, action) child_pid = fork_process(cleanup_child) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 assert not _probe_lock(lock_type, path) lock.release() assert _probe_lock(lock_type, path) @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.parametrize( ("async_lock_type", "sync_lock_type"), [ pytest.param(AsyncFileLock, FileLock, id="native"), pytest.param(AsyncSoftFileLock, SoftFileLock, id="soft"), ], ) def test_async_child_release_preserves_parent_lock( tmp_path: Path, async_lock_type: type[BaseAsyncFileLock], sync_lock_type: type[BaseFileLock], ) -> None: path = str(tmp_path / "parent.lock") lock = async_lock_type(path, thread_local=False, is_singleton=False) asyncio.run(lock.acquire()) def release_child() -> NoReturn: state = lock.is_locked, lock.lock_counter asyncio.run(lock.release(force=True)) exit_child(0 if state == (False, 0) else 1) child_pid = fork_process(release_child) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 assert not _probe_lock(sync_lock_type, path) asyncio.run(lock.release()) assert _probe_lock(sync_lock_type, path) @pytest.mark.requires_hard_links @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_soft_read_write_resets_older_same_path_instance(tmp_path: Path) -> None: # pragma: needs hard-link path = str(tmp_path / "parent.lock") older = SoftReadWriteLock(path, is_singleton=False, heartbeat_interval=0.1, stale_threshold=0.5) newer = SoftReadWriteLock(path, is_singleton=False, heartbeat_interval=0.1, stale_threshold=0.5) del newer gc.collect() older.acquire_write() def inherited_child() -> NoReturn: with pytest.raises(RuntimeError, match="invalidated by fork"): older.acquire_read(timeout=0) older.release() exit_child(0) child_pid = fork_process(inherited_child) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 contender = SoftReadWriteLock(path, is_singleton=False, heartbeat_interval=0.1, stale_threshold=0.5) with pytest.raises(Timeout): contender.acquire_write(timeout=0) contender.close() older.release() older.close() @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_child_closes_descriptor_held_by_vanished_thread(tmp_path: Path) -> None: path = str(tmp_path / "parent.lock") descriptor: Queue[int] = Queue() acquired, release = threading.Event(), threading.Event() def hold_lock() -> None: lock = FileLock(path, thread_local=True, is_singleton=False, on_acquired=descriptor.put) with lock: acquired.set() release.wait() worker = threading.Thread(target=hold_lock) worker.start() assert acquired.wait(timeout=5) fd = descriptor.get(timeout=5) def descriptor_child() -> NoReturn: _assert_descriptor_closed(fd) child_pid = fork_process(descriptor_child) _, status = os.waitpid(child_pid, 0) release.set() worker.join(timeout=5) assert (os.waitstatus_to_exitcode(status), worker.is_alive()) == (0, False) @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_fork_waits_for_descriptor_registration(tmp_path: Path, mocker: MockerFixture) -> None: path = str(tmp_path / "parent.lock") lock = FileLock(path, thread_local=False, is_singleton=False) entered, proceed, acquired = threading.Event(), threading.Event(), threading.Event() descriptors: list[int] = [] real_fstat = os.fstat def delayed_fstat(fd: int) -> os.stat_result: if threading.current_thread() is worker and not entered.is_set(): descriptors.append(fd) entered.set() proceed.wait() return real_fstat(fd) mocker.patch("os.fstat", side_effect=delayed_fstat) def acquire_lock() -> None: lock.acquire() acquired.set() worker = threading.Thread(target=acquire_lock) worker.start() assert entered.wait(timeout=5) timer = threading.Timer(0.05, proceed.set) timer.start() def descriptor_child() -> NoReturn: _assert_descriptor_closed(descriptors[0]) child_pid = fork_process(descriptor_child) _, status = os.waitpid(child_pid, 0) timer.join(timeout=5) worker.join(timeout=5) assert (os.waitstatus_to_exitcode(status), acquired.is_set(), worker.is_alive()) == (0, True, False) lock.release() @NEEDS_REGISTER_AT_FORK def test_unrelated_acquisitions_reach_filesystem_boundary_concurrently( # pragma: needs fork tmp_path: Path, mocker: MockerFixture ) -> None: boundary = threading.Barrier(3, timeout=5) real_fstat = os.fstat def wait_at_boundary(fd: int) -> os.stat_result: # Only the two named workers stat while the patch is installed, so the guard never takes its false arc. if threading.current_thread().name.startswith("concurrent-acquire-"): # pragma: no branch boundary.wait() return real_fstat(fd) mocker.patch("os.fstat", side_effect=wait_at_boundary) def acquire_lock(path: str) -> None: with FileLock(path, is_singleton=False): pass workers = [ threading.Thread( target=acquire_lock, args=(str(tmp_path / f"{index}.lock"),), name=f"concurrent-acquire-{index}", ) for index in range(2) ] for worker in workers: worker.start() try: boundary.wait() finally: for worker in workers: worker.join(timeout=5) assert [worker.is_alive() for worker in workers] == [False, False] @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.parametrize("lock_type", [pytest.param(FileLock, id="native"), pytest.param(SoftFileLock, id="soft")]) def test_child_singleton_registry_drops_parent_instance(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: path = str(tmp_path / "parent.lock") parent_lock = lock_type(path, is_singleton=True) def singleton_child() -> NoReturn: child_lock = lock_type(path, is_singleton=True) exit_child(0 if child_lock is not parent_lock else 1) child_pid = fork_process(singleton_child) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.parametrize("lock_type", [pytest.param(FileLock, id="native"), pytest.param(SoftFileLock, id="soft")]) def test_inherited_idle_lock_rejects_acquire(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock = lock_type(str(tmp_path / "parent.lock"), is_singleton=False) def inherited_child() -> NoReturn: try: lock.acquire(timeout=0) except RuntimeError as exception: exit_child(0 if "inherited across fork" in str(exception) else 1) exit_child(1) child_pid = fork_process(inherited_child) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.parametrize( "lock_type", [pytest.param(AsyncFileLock, id="native"), pytest.param(AsyncSoftFileLock, id="soft")] ) def test_inherited_idle_async_lock_rejects_acquire(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: lock = lock_type(str(tmp_path / "parent.lock"), is_singleton=False) def inherited_child() -> NoReturn: try: asyncio.run(lock.acquire(timeout=0)) except RuntimeError as exception: exit_child(0 if "inherited across fork" in str(exception) else 1) exit_child(1) child_pid = fork_process(inherited_child) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_fork_from_on_acquired_invalidates_child_acquire(tmp_path: Path) -> None: path = str(tmp_path / "parent.lock") fork_result = -1 lock: FileLock def inherited_child() -> NoReturn: try: lock.acquire(timeout=0) except RuntimeError as exception: exit_child(0 if "inherited across fork" in str(exception) else 1) exit_child(1) def fork_from_hook(_fd: int) -> None: nonlocal fork_result fork_result = fork_process(inherited_child) lock = FileLock(path, thread_local=False, is_singleton=False, on_acquired=fork_from_hook) lock.acquire() _, status = os.waitpid(fork_result, 0) assert (os.waitstatus_to_exitcode(status), _probe_lock(FileLock, path)) == (0, False) lock.release() @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_child_callback_registered_before_filelock_can_acquire(tmp_path: Path) -> None: script = """ import os import sys parent_path, child_path = sys.argv[1:] callback_succeeded = False def acquire_in_early_child_callback() -> None: global callback_succeeded with FileLock(child_path, is_singleton=False): pass callback_succeeded = True os.register_at_fork(after_in_child=acquire_in_early_child_callback) from filelock import FileLock, Timeout parent = FileLock(parent_path, is_singleton=False) parent.acquire() child_pid = os.fork() if child_pid == 0: os._exit(0 if callback_succeeded else 3) _, status = os.waitpid(child_pid, 0) if os.waitstatus_to_exitcode(status) != 0: sys.exit(1) contender = FileLock(parent_path, is_singleton=False) try: contender.acquire(timeout=0) except Timeout: pass else: sys.exit(2) parent.release() """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "parent.lock"), str(tmp_path / "child.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.parametrize( "kind", [ pytest.param("native", id="native"), pytest.param("soft", id="soft"), pytest.param("soft-rw", id="soft-read-write"), ], ) @pytest.mark.requires_hard_links def test_child_interpreter_exit_preserves_parent_lock( tmp_path: Path, kind: Literal["native", "soft", "soft-rw"] ) -> None: # pragma: needs hard-link script = """ from __future__ import annotations import os import sys from filelock import BaseFileLock, FileLock, SoftFileLock, SoftReadWriteLock, Timeout def acquire(lock: BaseFileLock | SoftReadWriteLock, timeout: float = -1) -> None: if isinstance(lock, SoftReadWriteLock): lock.acquire_write(timeout=timeout) else: lock.acquire(timeout=timeout) def release(lock: BaseFileLock | SoftReadWriteLock) -> None: if isinstance(lock, SoftReadWriteLock): lock.release() lock.close() else: lock.release() kind, path = sys.argv[1:] lock: BaseFileLock | SoftReadWriteLock if kind == "soft-rw": lock = SoftReadWriteLock(path, is_singleton=False, heartbeat_interval=0.1, stale_threshold=30) elif kind == "soft": lock = SoftFileLock(path, is_singleton=False) else: lock = FileLock(path, is_singleton=False) acquire(lock) child_pid = os.fork() if child_pid == 0: sys.exit(0) _, status = os.waitpid(child_pid, 0) if os.waitstatus_to_exitcode(status) != 0: sys.exit(1) contender_pid = os.fork() if contender_pid == 0: contender: BaseFileLock | SoftReadWriteLock if kind == "soft-rw": contender = SoftReadWriteLock(path, is_singleton=False, heartbeat_interval=0.1, stale_threshold=30) elif kind == "soft": contender = SoftFileLock(path, is_singleton=False) else: contender = FileLock(path, is_singleton=False) try: acquire(contender, timeout=0) except Timeout: os._exit(0) release(contender) os._exit(2) _, status = os.waitpid(contender_pid, 0) release(lock) sys.exit(os.waitstatus_to_exitcode(status)) """ result = subprocess.run( [sys.executable, "-c", script, kind, str(tmp_path / "parent.lock")], check=False, capture_output=True, text=True, timeout=15, ) assert result.returncode == 0, result.stderr def _run_child_cleanup( lock: BaseFileLock, proxy: AcquireReturnProxy, action: Literal["release", "context", "collect"], ) -> NoReturn: assert (lock.is_locked, lock.lock_counter) == (False, 0) if action == "release": lock.release(force=True) elif action == "context": proxy.__exit__(None, None, None) else: del proxy del lock gc.collect() exit_child(0) def _probe_lock(lock_type: type[BaseFileLock], path: str) -> bool: # pragma: needs fork read_fd, write_fd = os.pipe() def probe_child() -> NoReturn: os.close(read_fd) candidate = lock_type(path, is_singleton=False) try: candidate.acquire(timeout=0) except Timeout: acquired = False else: acquired = True candidate.release() os.write(write_fd, b"1" if acquired else b"0") os.close(write_fd) exit_child(0) child_pid = fork_process(probe_child) os.close(write_fd) result = os.read(read_fd, 1) os.close(read_fd) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 return result == b"1" def _assert_descriptor_closed(fd: int) -> NoReturn: try: os.fstat(fd) except OSError as exception: exit_child(0 if exception.errno == EBADF else 1) exit_child(1) filelock-4.2.8/tests/test_fork_registries.py0000644000000000000000000001412515272561277016240 0ustar00from __future__ import annotations import gc import os import signal import subprocess # ruff:ignore[suspicious-subprocess-import] # interpreter exit exercises the atexit registry import sys import threading import weakref from concurrent.futures import ThreadPoolExecutor from contextlib import closing from dataclasses import dataclass from typing import TYPE_CHECKING, Any, NoReturn import pytest from filelock import BaseFileLock, SoftReadWriteLock from filelock._soft_rw._protocol import GenerationLog from filelock._soft_rw._storage import OsFiles from tests.capability_marks import NEEDS_CLASS_COLLECTION, NEEDS_FORK from tests.fork_helpers import exit_child, fork_process if TYPE_CHECKING: from pathlib import Path @NEEDS_FORK def test_equal_unhashable_locks_reset_independently(tmp_path: Path) -> None: # pragma: win32 no cover @dataclass(eq=True, init=False) # pragma: win32 no cover class EqualLock(BaseFileLock): # pragma: win32 no cover def _acquire(self) -> None: # pragma: win32 no cover self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) def _release(self) -> None: # pragma: win32 no cover os.close(self._context.lock_file_fd if self._context.lock_file_fd is not None else -1) self._context.lock_file_fd = None first = EqualLock(str(tmp_path / "first.lock"), is_singleton=False) second = EqualLock(str(tmp_path / "second.lock"), is_singleton=False) first.acquire() second.acquire() def reset_child() -> NoReturn: state = first.is_locked, first.lock_counter, second.is_locked, second.lock_counter exit_child(0 if state == (False, 0, False, 0) else 1) child_pid = fork_process(reset_child) _, status = os.waitpid(child_pid, 0) first.release() second.release() assert os.waitstatus_to_exitcode(status) == 0 @NEEDS_FORK def test_soft_read_write_subclass_singleton_is_fresh_after_fork(tmp_path: Path) -> None: # pragma: win32 no cover class CustomLock(SoftReadWriteLock): # pragma: win32 no cover pass parent_lock = CustomLock(tmp_path / "test.lock") def reset_child() -> NoReturn: child_lock = CustomLock(parent_lock.lock_file) fresh = type(child_lock) is CustomLock and child_lock is not parent_lock child_lock.close() exit_child(0 if fresh else 1) try: child_pid = fork_process(reset_child) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 assert CustomLock(parent_lock.lock_file) is parent_lock finally: parent_lock.close() @NEEDS_FORK @pytest.mark.filterwarnings("ignore:.*multi-threaded, use of fork.*:DeprecationWarning") def test_soft_read_write_subclass_construction_state_is_reset_after_fork(tmp_path: Path) -> None: # pragma: needs fork entered, release = threading.Event(), threading.Event() parent_pid = os.getpid() path = str(tmp_path / "construction.lock") class CustomLock(SoftReadWriteLock): def __init__(self, *args: Any, **kwargs: Any) -> None: # ruff:ignore[any-type] # forwards the public constructor unchanged if type(self) is CustomLock and os.getpid() == parent_pid: entered.set() assert release.wait(timeout=10) super().__init__(*args, **kwargs) class ChildLock(CustomLock): pass class SiblingLock(SoftReadWriteLock): pass lock_types = (SoftReadWriteLock, ChildLock, SiblingLock) parents = [lock_type(path) for lock_type in lock_types] def reset_child() -> NoReturn: assert sys.platform != "win32" # NEEDS_FORK enforces this; narrow signal's platform-specific API for ty. # A stale mutex or construction marker must fail within the parent's wait budget. signal.alarm(5) status = 1 try: for lock_type in (*lock_types, CustomLock): with closing(lock_type(path)) as child: assert type(child) is lock_type assert all(child is not parent for parent in parents) assert lock_type(path) is child status = 0 finally: exit_child(status) try: with ThreadPoolExecutor(max_workers=1) as pool: pending = pool.submit(CustomLock, path) try: assert entered.wait(timeout=5) child_pid = fork_process(reset_child) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 finally: release.set() with closing(pending.result(timeout=5)) as parent: assert CustomLock(path) is parent assert all(lock_type(path) is parent for lock_type, parent in zip(lock_types, parents, strict=True)) finally: for parent in parents: parent.close() @pytest.mark.requires_hard_links def test_equal_unhashable_soft_read_write_locks_survive_atexit_registry( tmp_path: Path, ) -> None: # pragma: needs hard-link script = """ from __future__ import annotations import sys from dataclasses import dataclass from filelock import SoftReadWriteLock @dataclass(eq=True, init=False) class EqualLock(SoftReadWriteLock): pass locks = [EqualLock(path, is_singleton=False, heartbeat_interval=1, stale_threshold=30) for path in sys.argv[1:]] for lock in locks: lock.acquire_write() """ paths = [tmp_path / "first.lock", tmp_path / "second.lock"] result = subprocess.run( [sys.executable, "-c", script, *(str(path) for path in paths)], check=False, capture_output=True, text=True, timeout=10, ) writers = [GenerationLog(OsFiles(str(path)), str(path), f"{path}.rw").latest().writer for path in paths] assert (result.returncode, writers) == (0, [None, None]) @NEEDS_CLASS_COLLECTION def test_dynamic_lock_class_can_be_collected() -> None: class EphemeralLock(BaseFileLock): _acquire = _release = BaseFileLock._acquire class_ref = weakref.ref(EphemeralLock) del EphemeralLock gc.collect() assert class_ref() is None filelock-4.2.8/tests/test_host_name_grammar.py0000644000000000000000000000345215272561277016523 0ustar00from __future__ import annotations from typing import TYPE_CHECKING import pytest from filelock import SoftFileLease, SoftFileLock, StrictSoftFileLock from filelock._identity import host_name if TYPE_CHECKING: from pathlib import Path from pytest_mock import MockerFixture @pytest.fixture( params=[ pytest.param("host with space", id="space"), pytest.param("host\nname", id="newline"), pytest.param("wörks", id="non-ascii"), pytest.param("b\udcffd", id="undecodable-byte"), ] ) def out_of_grammar_host(request: pytest.FixtureRequest, mocker: MockerFixture) -> None: # Kernel hostnames that every marker format used to publish verbatim and then read back as malformed. The # surrogate is how Python surfaces a hostname that is not valid UTF-8. mocker.patch("filelock._identity.socket.gethostname", return_value=request.param) @pytest.mark.usefixtures("out_of_grammar_host") def test_soft_file_lock_recognizes_its_own_marker(tmp_path: Path) -> None: with SoftFileLock(tmp_path / "resource.lock", timeout=2) as lock: assert lock.is_lock_held_by_us is True @pytest.mark.requires_hard_links @pytest.mark.usefixtures("out_of_grammar_host") def test_strict_soft_file_lock_reads_back_its_own_claim(tmp_path: Path) -> None: # pragma: needs hard-link with StrictSoftFileLock(tmp_path / "resource.lock", timeout=2) as lock: assert {claim.hostname for claim in lock.claims} == {host_name()} @pytest.mark.usefixtures("out_of_grammar_host") def test_soft_file_lease_reads_back_its_own_owner(tmp_path: Path) -> None: lease = SoftFileLease(str(tmp_path / "resource.lock"), timeout=2, lease_duration=30, heartbeat_interval=1) with lease: owner = lease.owner assert owner is not None assert owner.hostname == host_name() filelock-4.2.8/tests/test_lazy_import.py0000644000000000000000000002173215272561277015412 0ustar00from __future__ import annotations import os import pickle # ruff:ignore[suspicious-pickle-import] # round-trips classes this package defines import subprocess # ruff:ignore[suspicious-subprocess-import] # a clean interpreter shows what the import itself loads import sys from textwrap import dedent from typing import TYPE_CHECKING import pytest from capabilities import CAPABILITIES import filelock if TYPE_CHECKING: from pathlib import Path from typing import Final def _run(script: str, *, measured: bool = True) -> str: # A measured child has asyncio and sqlite3 loaded by coverage before filelock, hiding what the import itself loads. env = os.environ if measured else {k: v for k, v in os.environ.items() if k != "COVERAGE_PROCESS_CONFIG"} return subprocess.run( [sys.executable, "-c", dedent(script)], check=True, capture_output=True, text=True, env=env ).stdout def test_lazy_import_skips_asyncio_and_sqlite3() -> None: # A site hook (a .pth file or sitecustomize) may load either module first; only what the import adds counts. script = """ import sys preloaded = set(sys.modules) import filelock print(sorted({"asyncio", "sqlite3"} & sys.modules.keys() - preloaded)) """ assert _run(script, measured=False) == "[]\n" @pytest.mark.parametrize( "script", [ pytest.param( """ import filelock print([name for name in filelock.__all__ if getattr(filelock, name) is None]) """, id="attribute", ), pytest.param( """ import filelock namespace = {} for name in filelock.__all__: exec(f"from filelock import {name}", namespace) print([name for name in filelock.__all__ if namespace[name] is None]) """, id="from-import", ), ], ) def test_lazy_import_resolves_every_public_name(script: str) -> None: assert _run(script) == "[]\n" def test_lazy_import_resolves_asyncio_submodule() -> None: assert _run("from filelock import asyncio; print(asyncio.__name__)") == "filelock.asyncio\n" def _hidden_module_script(blocked: str, missing: str, attribute: str) -> str: # A finder only sees imports not yet cached, so a sqlite3 a site hook already loaded is evicted first. return f""" import sys for name in [name for name in sys.modules if name.partition(".")[0] in {{"sqlite3", "_sqlite3"}}]: del sys.modules[name] class Hide: def find_spec(self, name, path=None, target=None): if name == {blocked!r}: raise ModuleNotFoundError(name={missing!r}) sys.meta_path.insert(0, Hide()) import filelock try: value = getattr(filelock, {attribute!r}) except ImportError as error: print(f"error:{{error.name}}") else: print("none" if value is None else "resolved") """ @pytest.mark.parametrize( ("blocked", "missing", "attribute"), [ pytest.param("sqlite3", "sqlite3", "ReadWriteLock", id="package"), pytest.param("_sqlite3", "_sqlite3", "AsyncReadWriteLock", id="cpython-binding"), pytest.param("_sqlite3", "_sqlite3_cffi", "ReadWriteLock", id="pypy-binding"), ], ) def test_lazy_import_missing_sqlite3_leaves_read_write_lock_none(blocked: str, missing: str, attribute: str) -> None: # coverage loads sqlite3 before filelock, so only an unmeasured child can miss it assert _run(_hidden_module_script(blocked, missing, attribute), measured=False) == "none\n" @pytest.mark.parametrize( ("module", "attribute"), [ pytest.param("filelock._read_write", "ReadWriteLock", id="sync-read-write"), pytest.param("filelock._async_read_write", "AsyncReadWriteLock", id="async-read-write"), pytest.param("filelock._soft", "SoftFileLock", id="other-lazy-module"), ], ) def test_lazy_import_missing_filelock_module_raises(module: str, attribute: str) -> None: assert _run(_hidden_module_script(module, module, attribute)) == f"error:{module}\n" def test_lazy_import_async_file_lock_subclasses_file_lock() -> None: assert {filelock.FileLock, filelock.BaseAsyncFileLock} <= set(filelock.AsyncFileLock.__mro__) def test_lazy_import_class_pickles_by_reference() -> None: payload = pickle.dumps(filelock.AsyncFileLock) assert pickle.loads(payload) is filelock.AsyncFileLock # ruff:ignore[suspicious-pickle-usage] # payload made here def test_lazy_import_dir_lists_every_public_name() -> None: assert _run("import filelock; print(sorted(set(filelock.__all__) - set(dir(filelock))))") == "[]\n" def test_lazy_import_unknown_name_raises() -> None: with pytest.raises(AttributeError, match="module 'filelock' has no attribute 'missing'"): _ = filelock.missing @pytest.mark.skipif(sys.version_info < (3, 15), reason="lazy imports start with Python 3.15") def test_lazy_import_missing_runtime_type_does_not_raise_at_exit() -> None: # pragma: >=3.15 cover assert not subprocess.run( [ sys.executable, "-c", "import types; types.__dict__.pop('LazyImportType', None); import filelock; filelock.FileLock('a')", ], check=True, capture_output=True, text=True, ).stderr _DAEMON_LOOP: Final = ( "loop = asyncio.new_event_loop(); threading.Thread(target=loop.run_forever, daemon=True).start()\n" ) @pytest.mark.skipif(not CAPABILITIES["lazy-imports"], reason="needs PEP 810 lazy imports") @pytest.mark.parametrize( "lazy_imports", [ pytest.param("normal", id="normal"), # pipx and other applications switch every import in the process to lazy pytest.param("all", id="all"), ], ) @pytest.mark.parametrize( "hold", [ pytest.param("lock = filelock.FileLock('a'); lock.acquire()", id="FileLock"), pytest.param("lock = filelock.SoftFileLock('a'); lock.acquire()", id="SoftFileLock"), pytest.param("lock = filelock.StrictSoftFileLock('a'); lock.acquire()", id="Strict"), pytest.param("lock = filelock.ReadWriteLock('a'); lock.acquire_write()", id="ReadWriteLock"), pytest.param( "loop = asyncio.new_event_loop(); lock = filelock.AsyncFileLock('a', loop=loop)\n" "loop.run_until_complete(lock.acquire())", id="AsyncFileLock", ), pytest.param( "loop = asyncio.new_event_loop(); lock = filelock.AsyncSoftFileLock('a', loop=loop)\n" "loop.run_until_complete(lock.acquire())", id="AsyncSoftFileLock", ), pytest.param( f"{_DAEMON_LOOP}lock = filelock.AsyncSoftFileLock('a', loop=loop, thread_local=False, " "run_in_executor=True)\nasyncio.run_coroutine_threadsafe(lock.acquire(), loop).result()", id="AsyncSoftFileLock-daemon-loop-shared", ), pytest.param( f"{_DAEMON_LOOP}lock = filelock.AsyncSoftFileLock('a', loop=loop, thread_local=True, " "run_in_executor=False)\nasyncio.run_coroutine_threadsafe(lock.acquire(), loop).result()", id="AsyncSoftFileLock-daemon-loop-thread-local", ), pytest.param( "loop = asyncio.new_event_loop(); lock = filelock.AsyncReadWriteLock('a')\n" "loop.run_until_complete(lock.acquire_write())", id="AsyncReadWriteLock", ), ], ) def test_lazy_import_lock_held_at_exit_releases_as_eager( tmp_path: Path, hold: str, lazy_imports: str ) -> None: # pragma: needs lazy-imports def held_at_exit(name: str, before_exit: str) -> tuple[list[str], list[str]]: (folder := tmp_path / name).mkdir() script = ( f"import sys, types\nsys.set_lazy_imports({lazy_imports!r})\nimport asyncio, threading, filelock\n{hold}" ) stderr = subprocess.run( [sys.executable, "-W", "ignore", "-c", f"{script}\n{before_exit}"], cwd=folder, check=True, capture_output=True, text=True, timeout=30, ).stderr # Under "all" the standard library's own finalizers (asyncio's proactor loop on Windows) can fail at shutdown # the same way; only frames from this package count against it. frames = [line for line in stderr.splitlines() if f"{os.sep}filelock{os.sep}" in line] return sorted(entry.name for entry in folder.iterdir()), frames # Resolving every lazy filelock binding before exit gives the eager behavior on this platform to compare against; # a filter that forces imports eager is no baseline, as installing one changes what a held lock does at exit. eager = held_at_exit( "eager", "for module in [m for n, m in list(sys.modules.items()) if n.partition('.')[0] == 'filelock']:\n" " for attribute, value in list(vars(module).items()):\n" " if type(value) is types.LazyImportType: getattr(module, attribute)", ) assert held_at_exit("lazy", "") == (eager[0], []) filelock-4.2.8/tests/test_lifetime_validation.py0000644000000000000000000001214015272561277017042 0ustar00from __future__ import annotations from decimal import Decimal from fractions import Fraction from typing import TYPE_CHECKING, Final, Literal, cast import pytest from filelock import AsyncFileLock, AsyncSoftFileLock, FileLock, SoftFileLock if TYPE_CHECKING: from pathlib import Path pytestmark: Final[pytest.MarkDecorator] = pytest.mark.filterwarnings("ignore::filelock.SoftFileLockLifetimeWarning") @pytest.mark.parametrize( "lock_type", [ pytest.param(FileLock, id="native-sync"), pytest.param(SoftFileLock, id="soft-sync"), pytest.param(AsyncFileLock, id="native-async"), pytest.param(AsyncSoftFileLock, id="soft-async"), ], ) @pytest.mark.parametrize( "entry_point", [pytest.param("constructor", id="constructor"), pytest.param("setter", id="setter")], ) @pytest.mark.parametrize( ("bad_value", "error_type", "message"), [ pytest.param(-1, ValueError, "finite and non-negative", id="negative-int"), pytest.param(-0.5, ValueError, "finite and non-negative", id="negative-float"), pytest.param(float("nan"), ValueError, "finite and non-negative", id="nan"), pytest.param(float("inf"), ValueError, "finite and non-negative", id="positive-infinity"), pytest.param(float("-inf"), ValueError, "finite and non-negative", id="negative-infinity"), pytest.param(Decimal(-1), ValueError, "finite and non-negative", id="negative-decimal"), pytest.param(Decimal("NaN"), ValueError, "finite and non-negative", id="decimal-nan"), pytest.param(Decimal("sNaN"), ValueError, "finite and non-negative", id="decimal-signaling-nan"), pytest.param(Decimal("Infinity"), ValueError, "finite and non-negative", id="decimal-infinity"), pytest.param(Fraction(10**400), ValueError, "too large", id="fraction-past-float"), pytest.param(Decimal("-1E-400"), ValueError, "finite and non-negative", id="decimal-below-float-negative"), pytest.param(Fraction(-1, 10**400), ValueError, "finite and non-negative", id="fraction-below-float-negative"), pytest.param(True, TypeError, "lifetime must be", id="true"), pytest.param(False, TypeError, "lifetime must be", id="false"), pytest.param("5", TypeError, "lifetime must be", id="string"), pytest.param(b"5", TypeError, "lifetime must be", id="bytes"), pytest.param([1], TypeError, "lifetime must be", id="list"), pytest.param({1: 2}, TypeError, "lifetime must be", id="dict"), pytest.param(complex(1, 0), TypeError, "lifetime must be", id="complex"), ], ) def test_lifetime_rejects_invalid_value( lock_type: type[FileLock | AsyncFileLock], entry_point: Literal["constructor", "setter"], bad_value: str | bytes | list[int] | dict[int, int] | complex | Decimal | Fraction, error_type: type[ValueError | TypeError], message: str, tmp_path: Path, ) -> None: lock_path = tmp_path / "test.lock" lock = lock_type(lock_path) with pytest.raises(error_type, match=message): _set_lifetime(lock_type, entry_point, lock, cast("float", bad_value)) assert (lock.lifetime, lock_path.exists()) == (None, False) @pytest.mark.parametrize( "lock_type", [pytest.param(SoftFileLock, id="sync"), pytest.param(AsyncSoftFileLock, id="async")], ) @pytest.mark.parametrize( "entry_point", [pytest.param("constructor", id="constructor"), pytest.param("setter", id="setter")], ) @pytest.mark.parametrize( "value", [ pytest.param(None, id="none"), pytest.param(0, id="zero-int"), pytest.param(0.0, id="zero-float"), pytest.param(2.5, id="positive-float"), pytest.param(10**1000, id="large-int"), pytest.param(Decimal("2.5"), id="decimal"), pytest.param(Fraction(5, 2), id="fraction"), ], ) def test_lifetime_accepts_supported_value( lock_type: type[SoftFileLock | AsyncSoftFileLock], entry_point: Literal["constructor", "setter"], value: float | None, tmp_path: Path, ) -> None: lock_path = tmp_path / "test.lock" if entry_point == "constructor": lock = lock_type(lock_path, lifetime=value) else: lock = lock_type(lock_path) lock.lifetime = value assert lock.lifetime == value @pytest.mark.parametrize( "bad_value", [ pytest.param(-1, id="negative"), pytest.param(float("nan"), id="nan"), pytest.param(float("inf"), id="infinity"), ], ) def test_lifetime_singleton_rejection_preserves_instance(bad_value: float, tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" lock = SoftFileLock(lock_path, is_singleton=True, lifetime=10) with pytest.raises(ValueError, match="finite and non-negative"): SoftFileLock(lock_path, is_singleton=True, lifetime=bad_value) assert SoftFileLock(lock_path, is_singleton=True, lifetime=10) is lock def _set_lifetime( lock_type: type[FileLock | AsyncFileLock], entry_point: Literal["constructor", "setter"], lock: FileLock | AsyncFileLock, value: float, ) -> None: if entry_point == "constructor": lock_type(lock.lock_file, lifetime=value) else: lock.lifetime = value filelock-4.2.8/tests/test_lock_expiry.py0000644000000000000000000002003415272561277015363 0ustar00from __future__ import annotations import os import socket import sys import time from typing import TYPE_CHECKING, Final import pytest from filelock import AsyncFileLock, AsyncSoftFileLock, FileLock, SoftFileLock, Timeout from tests.capability_marks import NEEDS_STALE_RECLAIM if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path from pytest_mock import MockerFixture _NATIVE_IGNORES: Final[str] = "lifetime is ignored" pytestmark: Final[pytest.MarkDecorator] = pytest.mark.filterwarnings("ignore::filelock.SoftFileLockLifetimeWarning") @NEEDS_STALE_RECLAIM def test_expired_soft_lock_is_broken(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" lock_path.touch() os.utime(lock_path, (0, 0)) lock = SoftFileLock(lock_path, lifetime=0.1, timeout=1) with lock: assert lock.is_locked @pytest.mark.parametrize("lock_type", [FileLock, AsyncFileLock]) def test_native_lifetime_warns_and_is_ignored(lock_type: type[FileLock | AsyncFileLock], tmp_path: Path) -> None: with pytest.warns(UserWarning, match=_NATIVE_IGNORES): lock = lock_type(tmp_path / "test.lock", lifetime=10.0) assert lock.lifetime is None def test_native_lifetime_setter_warns_and_is_ignored(tmp_path: Path) -> None: lock = FileLock(tmp_path / "test.lock") with pytest.warns(UserWarning, match=_NATIVE_IGNORES): lock.lifetime = 10.0 assert lock.lifetime is None def test_native_lifetime_does_not_break_live_holder(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" with FileLock(lock_path, timeout=0): os.utime(lock_path, (0, 0)) # a broken age-based break would evict this live holder with pytest.warns(UserWarning, match=_NATIVE_IGNORES): contender = FileLock(lock_path, lifetime=0.1, timeout=0.3) with pytest.raises(Timeout): contender.acquire() def test_soft_non_expired_lock_not_broken(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" lock_path.write_text(f"{os.getpid()}\n{socket.gethostname()}\n", encoding="utf-8") with pytest.raises(TimeoutError): SoftFileLock(lock_path, lifetime=9999, timeout=0.2).acquire() def test_soft_lifetime_none_no_expiry(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" lock_path.write_text(f"{os.getpid()}\n{socket.gethostname()}\n", encoding="utf-8") os.utime(lock_path, (0, 0)) with pytest.raises(TimeoutError): SoftFileLock(lock_path, lifetime=None, timeout=0.2).acquire() @pytest.mark.usefixtures("break_route") @pytest.mark.parametrize("break_route", ["no-exchange"], indirect=True) def test_expired_lock_race_rename_fails(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "test.lock" lock_path.touch() os.utime(lock_path, (0, 0)) mocker.patch("filelock._util.Path.rename", side_effect=FileNotFoundError) with pytest.raises(TimeoutError): SoftFileLock(lock_path, lifetime=0.1, timeout=0.5).acquire() @pytest.mark.skipif(sys.platform == "win32", reason="Windows pins nothing") def test_expired_break_spares_a_successor_on_the_freed_inode_number( # pragma: win32 no cover tmp_path: Path, mocker: MockerFixture, reuse_inode: Callable[[Path, os.stat_result], None] ) -> None: (lock := tmp_path / "test.lock").write_text("expired\n", encoding="utf-8") os.utime(lock, (0, 0)) expired: Final = lock.lstat() lstat: Final = os.lstat seen: list[bool] = [] def peer_breaks_then_successor_acquires( path: str | os.PathLike[str], *, dir_fd: int | None = None ) -> os.stat_result: found: Final = lstat(path, dir_fd=dir_fd) if os.fspath(path) == os.fspath(lock) and not seen: # After the age check: a peer broke the expired marker and the next holder's marker took its freed inode # number, created with the same modification time, so only the pinned inode tells the two apart. seen.append(True) lock.unlink() lock.write_text("live", encoding="utf-8") os.utime(lock, ns=(expired.st_atime_ns, expired.st_mtime_ns)) reuse_inode(lock, expired) return found mocker.patch.object(os, "lstat", new=peer_breaks_then_successor_acquires) with pytest.raises(Timeout): SoftFileLock(lock, lifetime=0.1).acquire(blocking=False) mocker.stopall() assert lock.read_text(encoding="utf-8") == "live" def test_lifetime_property_getter_setter(tmp_path: Path) -> None: lock = SoftFileLock(tmp_path / "test.lock", lifetime=10.0) assert lock.lifetime == pytest.approx(10.0) lock.lifetime = 20.0 assert lock.lifetime == pytest.approx(20.0) lock.lifetime = None assert lock.lifetime is None def test_lifetime_default_none(tmp_path: Path) -> None: assert FileLock(tmp_path / "test.lock").lifetime is None @pytest.mark.parametrize("bad_value", [-1, -0.5, -1e9]) def test_lifetime_setter_rejects_negative_number(bad_value: float, tmp_path: Path) -> None: lock = FileLock(tmp_path / "test.lock") with pytest.raises(ValueError, match="non-negative"): lock.lifetime = bad_value @pytest.mark.parametrize("bad_value", ["5", b"5", [1], {1: 2}, complex(1, 0)]) def test_lifetime_setter_rejects_non_numeric( bad_value: str | bytes | list[int] | dict[int, int] | complex, tmp_path: Path ) -> None: lock = FileLock(tmp_path / "test.lock") with pytest.raises(TypeError, match="lifetime must be"): lock.lifetime = bad_value # ty: ignore[invalid-assignment] # non-numeric input to hit the setter's TypeError @pytest.mark.parametrize("bad_value", [True, False]) # bool is an int subclass; reject it so it can't read as 1s/0s def test_lifetime_setter_rejects_bool(bad_value: bool, tmp_path: Path) -> None: lock = FileLock(tmp_path / "test.lock") with pytest.raises(TypeError, match="lifetime must be"): lock.lifetime = bad_value @pytest.mark.parametrize("value", [0, 0.0]) def test_lifetime_setter_accepts_zero(value: float, tmp_path: Path) -> None: lock = SoftFileLock(tmp_path / "test.lock") lock.lifetime = value assert lock.lifetime == 0 def test_lifetime_singleton_mismatch(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" lock1 = SoftFileLock(lock_path, is_singleton=True, lifetime=10.0) assert lock1.lifetime == pytest.approx(10.0) with pytest.raises(ValueError, match="lifetime"): SoftFileLock(lock_path, is_singleton=True, lifetime=20.0) def test_lifetime_singleton_match(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" lock1 = SoftFileLock(lock_path, is_singleton=True, lifetime=10.0) lock2 = SoftFileLock(lock_path, is_singleton=True, lifetime=10.0) assert lock1 is lock2 def test_lock_file_missing_during_expiry_check(tmp_path: Path) -> None: lock = SoftFileLock(tmp_path / "test.lock", lifetime=0.1, timeout=1) with lock: assert lock.is_locked @NEEDS_STALE_RECLAIM def test_expired_lock_becomes_acquirable(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" lock_path.touch() os.utime(lock_path, (0, 0)) lock = SoftFileLock(lock_path, lifetime=0.5, timeout=1) with lock: assert lock.is_locked assert not lock.is_locked @pytest.mark.asyncio @NEEDS_STALE_RECLAIM async def test_async_expired_lock_is_broken(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" lock_path.touch() os.utime(lock_path, (0, 0)) lock = AsyncSoftFileLock(lock_path, lifetime=0.1, timeout=1) async with lock: assert lock.is_locked @pytest.mark.asyncio async def test_async_soft_non_expired_lock_not_broken(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" lock_path.touch() with pytest.raises(TimeoutError): await AsyncSoftFileLock(lock_path, lifetime=9999, timeout=0.2).acquire() def test_lock_mtime_updated_on_acquire(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" before = time.time() with SoftFileLock(lock_path, lifetime=60): assert lock_path.exists() # the soft marker exists while held assert lock_path.stat().st_mtime >= before - 1 filelock-4.2.8/tests/test_marker_records.py0000644000000000000000000003504215272561277016042 0ustar00from __future__ import annotations import itertools import os import time from contextlib import suppress from multiprocessing import get_context from types import SimpleNamespace from typing import TYPE_CHECKING, Final import pytest from filelock import MarkerSoftFileLock, OwnerRecord, SoftFileLease, Timeout from filelock._identity import host_name, process_start_token from filelock._marker import encode_marker from filelock._soft import MALFORMED_LOCK_AGE_THRESHOLD from tests.capability_marks import NEEDS_STALE_RECLAIM from tests.process_helpers import cleanup_processes if TYPE_CHECKING: from multiprocessing.process import BaseProcess from pathlib import Path from pytest_mock import MockerFixture #: A spawned GraalPy interpreter alone can take seconds to start on a loaded runner. _PROCESS_DEADLINE: Final[int] = 30 @pytest.mark.parametrize( "record", [ pytest.param("filelock/1\npid=1\nhost=h\nmode=lease\n", id="wrong-protocol"), pytest.param("filelock/2\npid=1\nhost=h\n", id="no-mode"), pytest.param("filelock/2\npid=1\nmode=lease\n", id="no-host"), pytest.param("filelock/2\npid=1\nhost=\nmode=lease\n", id="empty-host"), pytest.param("filelock/2\nhost=h\nmode=lease\n", id="no-pid"), pytest.param("filelock/2\npid=nine\nhost=h\nmode=lease\n", id="pid-not-a-number"), pytest.param("filelock/2\npid=0\nhost=h\nmode=lease\n", id="pid-below-range"), pytest.param("filelock/2\npid=99999999999\nhost=h\nmode=lease\n", id="pid-above-range"), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\nbare-line\n", id="field-without-value"), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\nduration=5\n", id="lease-without-token"), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\ntoken=t\n", id="lease-without-duration"), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\ntoken=t\nduration=0\n", id="lease-duration-zero"), pytest.param( "filelock/2\npid=1\nhost=h\nmode=lease\ntoken=t\nduration=soon\n", id="lease-duration-not-a-number" ), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\ntoken=t\nduration=nan\n", id="lease-duration-nan"), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\ntoken=t\nduration=inf\n", id="lease-duration-inf"), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\ntoken=t\nduration=5\nstart=later\n", id="start-nan"), pytest.param("filelock/2\npid=1\nhost=h\nmode=exclu", id="truncated-mode"), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\ntoken=t\nduration=1", id="truncated-duration"), pytest.param("filelock/2\npid=1_000\nhost=h\nmode=exclusive\n", id="pid-underscore"), pytest.param("filelock/2\npid=\u0661\u0662\u0663\nhost=h\nmode=exclusive\n", id="pid-arabic-indic-digits"), pytest.param("filelock/2\npid= 1\nhost=h\nmode=exclusive\n", id="pid-whitespace"), pytest.param("filelock/2\npid=+1\nhost=h\nmode=exclusive\n", id="pid-sign"), pytest.param("filelock/2\npid=1\nhost=h\nmode=exclusive\nstart=1_0\n", id="start-underscore"), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\ntoken=t\nduration= 1\n", id="duration-whitespace"), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\ntoken=t\nduration=1_0\n", id="duration-underscore"), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\ntoken=t\nduration=\u0665\n", id="duration-non-ascii"), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\ntoken=t\nduration=-1.0\n", id="duration-negative"), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\ntoken=t\nduration=1e400\n", id="duration-overflow"), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\ntoken=\nduration=1\n", id="lease-empty-token"), ], ) def test_unreadable_record_names_no_owner(tmp_path: Path, record: str) -> None: marker = tmp_path / "a.lock" marker.write_text(record, encoding="utf-8") assert SoftFileLease(str(marker), lease_duration=1).owner is None def test_encode_marker_omits_absent_lease_fields() -> None: # A non-lease owner carries no token or duration, so those lines never appear in the rendered marker. rendered = encode_marker(OwnerRecord(pid=7, hostname="h", mode="unknown")) assert rendered == b"filelock/2\npid=7\nhost=h\nmode=unknown\n" assert b"token=" not in rendered assert b"duration=" not in rendered @pytest.mark.parametrize("duration", [0.9, 30, 30.0, 1e-05, 1.5e20]) def test_record_duration_reads_back(marker: Path, duration: float) -> None: record = OwnerRecord(4242, "h", "lease", "t", duration) marker.write_bytes(encode_marker(record)) assert SoftFileLease(str(marker), lease_duration=1).owner == record def test_record_start_token_reads_back(tmp_path: Path) -> None: marker = tmp_path / "a.lock" record = "filelock/2\npid=4242\nhost=somehost\nmode=lease\ntoken=t\nduration=5\nstart=987654\n" marker.write_text(record, encoding="utf-8") owner = SoftFileLease(str(marker), lease_duration=1).owner assert owner is not None assert (owner.pid, owner.hostname, owner.mode, owner.start) == (4242, "somehost", "lease", 987654) def test_record_from_a_newer_filelock_still_names_its_owner(tmp_path: Path) -> None: # An unknown key is a field this version does not model yet; the owner it publishes must still read back. marker = tmp_path / "a.lock" record = "filelock/2\npid=4242\nhost=somehost\nmode=lease\ntoken=t\nduration=5\nstart-id=17\n" marker.write_text(record, encoding="utf-8") owner = SoftFileLease(str(marker), lease_duration=1).owner assert owner is not None assert (owner.pid, owner.hostname, owner.mode) == (4242, "somehost", "lease") @pytest.mark.parametrize("published", ["strict", "banana"]) def test_record_with_an_unknown_mode_names_its_owner(tmp_path: Path, published: str) -> None: # A mode this version does not implement states a contract a newer filelock published, not a corrupt record. marker = tmp_path / "a.lock" marker.write_text(f"filelock/2\npid=4242\nhost=somehost\nmode={published}\n", encoding="utf-8") owner = SoftFileLease(str(marker), lease_duration=1).owner assert owner is not None assert (owner.pid, owner.hostname, owner.mode) == (4242, "somehost", "unknown") def test_held_lease_records_the_process_start_token(tmp_path: Path) -> None: marker = tmp_path / "a.lock" with SoftFileLease(str(marker), lease_duration=5) as lease: assert lease.owner is not None assert lease.owner.start == process_start_token(os.getpid()) def test_marker_without_start_token_omits_it(tmp_path: Path, mocker: MockerFixture) -> None: # A platform without a proven start time publishes no start field, and the record still reads back cleanly. mocker.patch("filelock._marker.process_start_token", return_value=None) marker = tmp_path / "a.lock" with SoftFileLease(str(marker), lease_duration=5) as lease: assert lease.owner is not None assert lease.owner.start is None assert "start=" not in marker.read_text(encoding="utf-8") def test_lease_treats_an_unreadable_record_as_contention(tmp_path: Path) -> None: # Only a peer that published a lease agreed to be superseded by age, so an unreadable record is never reclaimed. marker = tmp_path / "a.lock" marker.write_text("filelock/2\npid=1\nhost=h\nmode=banana\n", encoding="utf-8") with pytest.raises(Timeout): SoftFileLease(str(marker), lease_duration=0.3, heartbeat_interval=0.1, timeout=0.5).acquire() assert marker.exists() def test_lease_never_ages_out_an_unknown_contract(tmp_path: Path) -> None: # A marker naming a contract this version cannot interpret must outlive the malformed grace window: aging it out # would delete a lock whose owner never agreed to expire. marker = tmp_path / "a.lock" marker.write_text("filelock/2\npid=999999\nhost=nowhere\nmode=fenced\n", encoding="utf-8") stale = time.time() - 3600 os.utime(marker, (stale, stale)) with pytest.raises(Timeout): SoftFileLease(str(marker), lease_duration=0.3, heartbeat_interval=0.1, timeout=0.5).acquire() assert marker.exists() @NEEDS_STALE_RECLAIM def test_lease_ages_out_a_malformed_record(tmp_path: Path, mocker: MockerFixture) -> None: # The self-heal still applies to a record that states no contract at all, so corruption cannot wedge contenders. mocker.patch( "filelock._soft.time", new=SimpleNamespace( monotonic=mocker.create_autospec( time.monotonic, side_effect=itertools.count(step=MALFORMED_LOCK_AGE_THRESHOLD) ), sleep=time.sleep, ), ) marker = tmp_path / "a.lock" marker.write_text("filelock/2\nnot-a-record\n", encoding="utf-8") lease = SoftFileLease(str(marker), lease_duration=5) _acquire_after_reclaim(lease) try: assert lease.is_lock_held_by_us finally: lease.release() def test_marker_pid_and_owner_are_none_without_marker(tmp_path: Path) -> None: lease = SoftFileLease(tmp_path / "a") assert (lease.pid, lease.owner, lease.is_lock_held_by_us) == (None, None, False) @pytest.mark.skipif(process_start_token(os.getpid()) is None, reason="platform exposes no proven process start time") @pytest.mark.parametrize( ("offset", "expected"), [pytest.param(0, True, id="matching-start"), pytest.param(1, False, id="recycled-pid")], ) def test_marker_is_lock_held_by_us_checks_start(marker: Path, offset: int, *, expected: bool) -> None: # Same PID and host as ours; a different start token means an earlier process reused this PID. start = process_start_token(os.getpid()) assert start is not None record = OwnerRecord(pid=os.getpid(), hostname=host_name(), mode="exclusive", start=start + offset) marker.write_bytes(encode_marker(record)) assert MarkerSoftFileLock(marker).is_lock_held_by_us is expected def test_marker_force_break_removes_the_marker(tmp_path: Path) -> None: # force_break clears a marker whose holder is gone, so the file is on disk but not held open — the one case where # an unlink also succeeds on Windows, which refuses to remove a descriptor another handle still holds. marker = tmp_path / "a" marker.write_text("filelock/2\npid=1\nhost=h\nmode=lease\n", encoding="utf-8") assert marker.exists() SoftFileLease(marker).force_break() assert not marker.exists() @pytest.mark.parametrize("acquire", [pytest.param(True, id="acquire"), pytest.param(False, id="context")]) def test_marker_lock_publishes_exclusive(marker: Path, *, acquire: bool) -> None: lock: Final[MarkerSoftFileLock] = MarkerSoftFileLock(marker) with lock.acquire() if acquire else lock: owner: Final[OwnerRecord | None] assert (owner := MarkerSoftFileLock(lock.lock_file).owner) is not None assert (lock.is_locked, lock.is_lock_held_by_us, lock.pid, owner.mode) == (True, True, os.getpid(), "exclusive") assert (lock.is_locked, lock.pid, lock.owner) == (False, None, None) @pytest.mark.parametrize( "contender_type", [pytest.param(MarkerSoftFileLock, id="marker"), pytest.param(SoftFileLease, id="lease")], ) def test_marker_lock_excludes_contenders(marker: Path, contender_type: type[MarkerSoftFileLock]) -> None: with MarkerSoftFileLock(marker): os.utime(marker, (0, 0)) with pytest.raises(Timeout): contender_type(marker, timeout=0.1).acquire() @pytest.mark.parametrize( "mode", [ pytest.param("exclusive", id="exclusive"), pytest.param("lease", id="lease"), pytest.param("future", id="unknown"), ], ) def test_marker_lock_preserves_live_records(marker: Path, mode: str) -> None: with MarkerSoftFileLock(marker): record: Final[str] = marker.read_text(encoding="utf-8").replace("mode=exclusive", f"mode={mode}") marker.write_text(record + "token=t\nduration=1\n", encoding="utf-8") os.utime(marker, (0, 0)) with pytest.raises(Timeout): MarkerSoftFileLock(marker, timeout=0.1).acquire() @pytest.mark.timeout(_PROCESS_DEADLINE * 2) @NEEDS_STALE_RECLAIM def test_marker_lock_reclaims_dead_owner(marker: Path) -> None: process: Final[BaseProcess] = get_context("spawn").Process(target=_leave_marker, args=(marker,)) with cleanup_processes([process]): process.start() process.join(timeout=_PROCESS_DEADLINE) assert process.exitcode == 0 lock = MarkerSoftFileLock(marker) _acquire_after_reclaim(lock) try: assert lock.pid == os.getpid() finally: lock.release() @pytest.mark.timeout(_PROCESS_DEADLINE * 2) @NEEDS_STALE_RECLAIM def test_marker_lock_reclaims_dead_owner_before_its_process_is_closed(marker: Path) -> None: # Until close(), the joined Process holds the owner's handle, which on Windows keeps the exited owner openable. process: Final[BaseProcess] = get_context("spawn").Process(target=_leave_marker, args=(marker,)) with cleanup_processes([process]): process.start() process.join(timeout=_PROCESS_DEADLINE) assert process.exitcode == 0 lock = MarkerSoftFileLock(marker) _acquire_after_reclaim(lock) lock.release() @pytest.mark.parametrize( "record", [ pytest.param("broken", id="broken"), pytest.param("filelock/2\npid=1\nhost=h\nmode=exclu", id="truncated-mode"), ], ) @NEEDS_STALE_RECLAIM def test_marker_lock_reclaims_malformed_record(marker: Path, record: str, mocker: MockerFixture) -> None: mocker.patch( "filelock._soft.time", new=SimpleNamespace( monotonic=mocker.create_autospec( time.monotonic, side_effect=itertools.count(step=MALFORMED_LOCK_AGE_THRESHOLD) ), sleep=time.sleep, ), ) marker.write_text(record, encoding="utf-8") lock = MarkerSoftFileLock(marker) _acquire_after_reclaim(lock) try: assert lock.pid == os.getpid() finally: lock.release() def _acquire_after_reclaim(lock: MarkerSoftFileLock) -> None: # One poll per call: the first sees the marker, the second evicts a stale or aged-out one and the third takes the # free path. Counting polls instead of timing them keeps a stalled runner from failing a reclaim that happens. assert any(_polled_once_and_holds(lock) for _ in range(3)), "the marker was not reclaimed within three polls" def _polled_once_and_holds(lock: MarkerSoftFileLock) -> bool: with suppress(Timeout): lock.acquire(blocking=False) return lock.is_locked @pytest.fixture def marker(tmp_path: Path) -> Path: return tmp_path / "a" def _leave_marker(marker: Path) -> None: with MarkerSoftFileLock(marker): os._exit(0) filelock-4.2.8/tests/test_process_identity.py0000644000000000000000000003415615272561277016434 0ustar00from __future__ import annotations import importlib import os import subprocess # ruff:ignore[suspicious-subprocess-import] # an exited child that a Popen still references import sys from errno import EACCES, ENODEV, ENOENT, EPERM from pathlib import Path from typing import TYPE_CHECKING, Final import pytest import filelock._identity from filelock._identity import host_name, owner_is_stale, process_alive, process_start_token from tests.pid_namespace_helpers import ( INITIAL_PID_NAMESPACE, INITIAL_TIME_NAMESPACE, pin_pid_namespace, pin_time_namespace, ) if TYPE_CHECKING: from collections.abc import Callable, Iterator from pytest_mock import MockerFixture _DEAD_PID: Final[int] = 2**22 + 1 _POSIX_ONLY: Final[pytest.MarkDecorator] = pytest.mark.skipif(sys.platform == "win32", reason="posix kill semantics") #: NetBSD and other POSIX platforms without a proven start-time source expose no token, so an owner carries none there. _NEEDS_START_TOKEN: Final[pytest.MarkDecorator] = pytest.mark.skipif( process_start_token(os.getpid()) is None, reason="this platform exposes no process start-time source" ) _LINUX_ONLY: Final[pytest.MarkDecorator] = pytest.mark.skipif( sys.platform not in {"linux", "android"}, reason="PID namespaces exist only on Linux" ) _ANOTHER_TIME_NAMESPACE: Final[int] = 0xF0000A1C _BOOTTIME_SHIFTED: Final[str] = "monotonic 0 0\nboottime 1000 0\n" _BOOTTIME_UNSHIFTED: Final[str] = "monotonic 0 0\nboottime 0 0\n" @pytest.fixture def hide_boot_id(mocker: MockerFixture) -> Iterator[Callable[[], None]]: # pragma: linux cover """Re-import the identity module as a process that cannot read the boot id, as under systemd's ProcSubset=pid.""" def hide() -> None: # The boot id is the only text file the module reads at import. hidden: Final = mocker.patch.object( Path, "read_text", autospec=True, side_effect=FileNotFoundError(ENOENT, "No such file or directory") ) importlib.reload(filelock._identity) mocker.stop(hidden) yield hide importlib.reload(filelock._identity) @pytest.mark.parametrize( ("raw", "expected"), [ pytest.param("build-01.example.com", "build-01.example.com", id="plain"), pytest.param("build 01", "build?2001", id="space"), pytest.param("build\n01", "build?0a01", id="newline"), pytest.param("wörks", "w?c3?b6rks", id="non-ascii"), pytest.param("who?", "who?3f", id="escape-character"), pytest.param("b\udcffd", "b?ffd", id="undecodable-byte"), pytest.param("x" * 253, "x" * 253, id="at-limit"), pytest.param("x" * 300, "x" * 244 + "-04c26261", id="over-long"), pytest.param("ä" * 200, "?c3?a4" * 40 + "?c3?-de6b44df", id="over-long-escaped"), pytest.param("", "?", id="empty"), ], ) def test_host_name_escapes_out_of_grammar_bytes(raw: str, expected: str, mocker: MockerFixture) -> None: pin_pid_namespace(mocker, INITIAL_PID_NAMESPACE) mocker.patch("filelock._identity.socket.gethostname", return_value=raw) assert host_name() == expected def test_host_name_keeps_over_long_names_apart_past_the_limit(mocker: MockerFixture) -> None: pin_pid_namespace(mocker, INITIAL_PID_NAMESPACE) mocker.patch("filelock._identity.socket.gethostname", side_effect=["a" * 253 + "-alpha", "a" * 253 + "-bravo"]) alpha, bravo = host_name(), host_name() assert (alpha != bravo, len(alpha), len(bravo)) == (True, 253, 253) @pytest.mark.parametrize( ("namespace", "expected"), [ pytest.param(INITIAL_PID_NAMESPACE, "build-01", id="initial"), pytest.param(0xF0000A1C, "build-01?pidns-f0000a1c", id="container"), pytest.param(PermissionError(EACCES, "Permission denied"), "build-01", id="unreadable"), ], ) def test_host_name_names_the_pid_namespace(namespace: int | OSError, expected: str, mocker: MockerFixture) -> None: pin_pid_namespace(mocker, namespace) mocker.patch("filelock._identity.socket.gethostname", return_value="build-01") assert host_name() == expected def test_host_name_keeps_a_namespace_apart_from_a_lookalike_host(mocker: MockerFixture) -> None: mocker.patch("filelock._identity.socket.gethostname", side_effect=["a", "a?pidns-f0000001"]) pin_pid_namespace(mocker, 0xF0000001) in_namespace: Final[str] = host_name() pin_pid_namespace(mocker, INITIAL_PID_NAMESPACE) assert in_namespace != host_name() def test_process_alive_true_for_self() -> None: assert process_alive(os.getpid()) is True def test_process_alive_false_for_dead() -> None: assert process_alive(_DEAD_PID) is False def test_process_alive_false_for_exited_child_still_referenced() -> None: # After wait() the Popen still holds the child's handle, which on Windows keeps the exited process openable. with subprocess.Popen([sys.executable, "-c", ""]) as child: child.wait() assert process_alive(child.pid) is False @_POSIX_ONLY def test_process_alive_reads_permission_denied_as_alive(mocker: MockerFixture) -> None: # pragma: win32 no cover mocker.patch("filelock._identity.os.kill", side_effect=OSError(EPERM, "operation not permitted")) assert process_alive(_DEAD_PID) is True @_POSIX_ONLY def test_process_alive_reraises_unexpected_errno(mocker: MockerFixture) -> None: # pragma: win32 no cover mocker.patch("filelock._identity.os.kill", side_effect=OSError(ENODEV, "no such device")) with pytest.raises(OSError, match="no such device"): # pragma: win32 no cover process_alive(_DEAD_PID) @_NEEDS_START_TOKEN def test_process_start_token_is_int_for_self() -> None: assert isinstance(process_start_token(os.getpid()), int) def test_process_start_token_none_for_dead() -> None: assert process_start_token(_DEAD_PID) is None @_LINUX_ONLY # pragma: linux cover @pytest.mark.parametrize( ("namespace", "offsets", "for_children", "readable"), [ pytest.param(INITIAL_TIME_NAMESPACE, _BOOTTIME_SHIFTED, None, True, id="initial"), pytest.param(_ANOTHER_TIME_NAMESPACE, _BOOTTIME_UNSHIFTED, None, True, id="container-without-offset"), pytest.param(_ANOTHER_TIME_NAMESPACE, "1 0 0\n7 0 0\n", None, True, id="clock-ids-without-offset"), pytest.param(_ANOTHER_TIME_NAMESPACE, _BOOTTIME_SHIFTED, None, False, id="boottime-offset"), pytest.param(_ANOTHER_TIME_NAMESPACE, "1 0 0\n7 1000 0\n", None, False, id="clock-ids-with-offset"), pytest.param(_ANOTHER_TIME_NAMESPACE, _BOOTTIME_UNSHIFTED, 0xF0000A1D, False, id="unshared-not-entered"), pytest.param(_ANOTHER_TIME_NAMESPACE, PermissionError(EACCES, "denied"), None, False, id="offsets-unreadable"), pytest.param(FileNotFoundError(ENOENT, "missing"), _BOOTTIME_SHIFTED, None, True, id="kernel-before-5.6"), pytest.param(PermissionError(EACCES, "denied"), _BOOTTIME_UNSHIFTED, None, False, id="namespace-unreadable"), ], ) def test_process_start_token_read_only_without_a_boottime_offset( namespace: int | OSError, offsets: str | OSError, for_children: int | None, readable: bool, mocker: MockerFixture ) -> None: pin_time_namespace(mocker, namespace, offsets, for_children=for_children) assert (process_start_token(os.getpid()) is not None) is readable @pytest.mark.skipif(sys.platform != "darwin", reason="macOS sysctl probe") def test_darwin_sysctl_probe_failure_reads_no_token(mocker: MockerFixture) -> None: # pragma: darwin cover mocker.patch("filelock._identity._LIBC.sysctl", return_value=-1) assert process_start_token(os.getpid()) is None def test_owner_is_stale_foreign_host_never_reclaimed() -> None: assert owner_is_stale(os.getpid(), "another-host.example.com", 1) is False def test_owner_is_stale_dead_process_reclaimed() -> None: assert owner_is_stale(_DEAD_PID, host_name(), 1) is True def test_owner_is_stale_live_process_without_token_held() -> None: assert owner_is_stale(os.getpid(), host_name(), None) is False def test_owner_is_stale_live_process_matching_token_held() -> None: assert owner_is_stale(os.getpid(), host_name(), process_start_token(os.getpid())) is False @_NEEDS_START_TOKEN def test_owner_is_stale_live_process_mismatched_token_reclaimed() -> None: token: Final = process_start_token(os.getpid()) assert token is not None # Far enough ahead that no task on this kernel started then. assert owner_is_stale(os.getpid(), host_name(), token + 10**9) is True @_LINUX_ONLY def test_owner_is_stale_live_process_across_time_namespaces_held(mocker: MockerFixture) -> None: # pragma: linux cover # The kernel shifts starttime by the reader's boottime offset, so a token recorded outside never matches inside. token: Final = process_start_token(os.getpid()) assert token is not None pin_time_namespace(mocker, _ANOTHER_TIME_NAMESPACE, _BOOTTIME_SHIFTED) assert owner_is_stale(os.getpid(), host_name(), token + 100_000) is False @_LINUX_ONLY # pragma: linux cover @pytest.mark.parametrize("hidden_from", ["holder", "contender"]) def test_owner_is_stale_live_process_boot_id_hidden_from_one_side_held( hidden_from: str, hide_boot_id: Callable[[], None] ) -> None: token: Final = process_start_token(os.getpid()) assert token is not None if hidden_from == "contender": hide_boot_id() assert owner_is_stale(os.getpid(), host_name(), token if hidden_from == "contender" else token % 2**64) is False @_LINUX_ONLY # pragma: linux cover @pytest.mark.parametrize("hidden_from", ["holder", "contender"]) def test_owner_is_stale_recycled_pid_boot_id_hidden_from_one_side_reclaimed( hidden_from: str, hide_boot_id: Callable[[], None] ) -> None: token: Final = process_start_token(os.getpid()) assert token is not None if hidden_from == "contender": hide_boot_id() assert owner_is_stale(os.getpid(), host_name(), token % 2**64 + 10**9) is True @pytest.fixture def owner_in_another_namespace(mocker: MockerFixture) -> None: # pragma: linux cover # An older release in a container recorded its in-container PID; this process stands in for it, its innermost PID # being the recorded one. pin_pid_namespace(mocker, INITIAL_PID_NAMESPACE) own_status: Final = Path(f"/proc/{os.getpid()}/status") read_bytes: Final = Path.read_bytes mocker.patch.object( Path, "read_bytes", autospec=True, side_effect=lambda path: ( f"NSpid:\t{os.getpid()}\t{_DEAD_PID}\n".encode() if path == own_status else read_bytes(path) ), ) @_LINUX_ONLY # pragma: linux cover @pytest.mark.usefixtures("owner_in_another_namespace") @pytest.mark.parametrize( "view", [ pytest.param("same-token", id="start-token-matches"), pytest.param("recorded-without-boot-id", id="holder-without-boot-id"), pytest.param("read-without-boot-id", id="contender-without-boot-id"), pytest.param("time-namespace", id="contender-with-boottime-offset"), ], ) def test_owner_is_stale_owner_running_in_another_namespace_held( view: str, hide_boot_id: Callable[[], None], mocker: MockerFixture ) -> None: token: Final = process_start_token(os.getpid()) assert token is not None if view == "read-without-boot-id": hide_boot_id() if view == "time-namespace": pin_time_namespace(mocker, _ANOTHER_TIME_NAMESPACE, _BOOTTIME_SHIFTED) recorded: Final = token % 2**64 if view == "recorded-without-boot-id" else token assert owner_is_stale(_DEAD_PID, host_name(), recorded) is False @_LINUX_ONLY # pragma: linux cover @pytest.mark.parametrize("forged", ["x", str(_DEAD_PID)]) @pytest.mark.parametrize("separator", ["\r", "\v", "\f", "\x1c", "\x1d", "\x1e"]) def test_owner_is_stale_task_name_shaped_like_a_namespace_line_reclaimed( separator: str, forged: str, mocker: MockerFixture ) -> None: # A task picks its own name and /proc//status prints it raw but for "\n" and "\\". pin_pid_namespace(mocker, INITIAL_PID_NAMESPACE) comm: Final = Path("/proc/self/comm") name: Final = comm.read_bytes().rstrip(b"\n") comm.write_bytes(f"{separator}NSpid:\t{forged}".encode()) try: assert owner_is_stale(_DEAD_PID, host_name(), process_start_token(os.getpid())) is True finally: comm.write_bytes(name) @_LINUX_ONLY # pragma: linux cover @pytest.mark.parametrize( ("boot_flip", "scans"), [ # Workers forked together share a start token; a dead one's marker must not wait on its live siblings. pytest.param(0, 1, id="same-tick"), pytest.param(1 << 64, 0, id="another-boot"), ], ) def test_owner_is_stale_dead_owner_by_start_token_reclaimed(boot_flip: int, scans: int, mocker: MockerFixture) -> None: pin_pid_namespace(mocker, INITIAL_PID_NAMESPACE) scandir: Final = mocker.patch("filelock._identity.os.scandir", autospec=True, side_effect=os.scandir) token: Final = process_start_token(os.getpid()) assert token is not None assert (owner_is_stale(_DEAD_PID, host_name(), token ^ boot_flip), scandir.call_count) == (True, scans) @_LINUX_ONLY def test_owner_is_stale_without_a_readable_proc_reclaimed(mocker: MockerFixture) -> None: # pragma: linux cover pin_pid_namespace(mocker, INITIAL_PID_NAMESPACE) mocker.patch("filelock._identity.os.scandir", autospec=True, side_effect=PermissionError(EACCES, "hidepid")) assert owner_is_stale(_DEAD_PID, host_name(), process_start_token(os.getpid())) is True @_NEEDS_START_TOKEN def test_owner_is_stale_sibling_pid_namespace_never_reclaimed(mocker: MockerFixture) -> None: # Two containers in one pod share the hostname; the same PID in the holder's namespace is another process. token: Final = process_start_token(os.getpid()) assert token is not None pin_pid_namespace(mocker, 0xF0000001) holder_host: Final[str] = host_name() pin_pid_namespace(mocker, 0xF0000002) assert owner_is_stale(os.getpid(), holder_host, token + 1) is False def test_owner_is_stale_live_process_unreadable_token_held(mocker: MockerFixture) -> None: # A live PID whose current start token cannot be read is indistinguishable from the recorded owner, so it holds. mocker.patch("filelock._identity.process_start_token", return_value=None) assert owner_is_stale(os.getpid(), host_name(), 987654) is False filelock-4.2.8/tests/test_read_write.py0000644000000000000000000015600115272561277015164 0ustar00from __future__ import annotations import ctypes import os import re import struct import subprocess # ruff:ignore[suspicious-subprocess-import] # only fixed-argument, no-shell runs below import sys import threading import time from contextlib import closing from errno import EWOULDBLOCK from multiprocessing import Event, Process, Value, set_start_method from pathlib import Path from typing import TYPE_CHECKING, Final, Literal, cast import pytest from capabilities import CAPABILITIES pytest.importorskip("sqlite3") import sqlite3 from filelock import ReadWriteLock, Timeout from filelock._read_write import _ForkSafeConnection from tests.capability_marks import SKIP_ON_UNRELIABLE_PROCESS_SYNC from tests.process_helpers import cleanup_processes from tests.read_write_helpers import Interrupt, assert_read_write_lock_state, interrupt_once if sys.platform == "win32": # pragma: win32 cover from filelock._identity import host_name from filelock._read_write import _host_digest, _remove_link_directory_on_windows from filelock._windows import _advapi32, _directory_is_private, _make_private_directory # Bounds how long a spawned process may take to reach the lock, not how fast it must be: an interpreter that starts # slowly under a loaded suite is not a locking failure. Only a genuine hang waits this out. Each test's own timeout # has to outlast the waits it makes, so those are multiples of this rather than bare seconds. _PROCESS_DEADLINE: Final[int] = 30 # Reaping a process that has already been signaled is not a startup wait, and it has to stay under the suite's own # per-test timeout so the guard still reports the hang it was put there for. _REAP_DEADLINE: Final[int] = 5 pytestmark = SKIP_ON_UNRELIABLE_PROCESS_SYNC if sys.implementation.name == "pypy": set_start_method( "spawn", force=True ) # pragma: no cover # exercised only under the pypy fork backend, which runs without coverage if TYPE_CHECKING: from collections.abc import Callable, Generator from multiprocessing.sharedctypes import Synchronized from multiprocessing.synchronize import Event as EventType from pytest_mock import MockerFixture from filelock._read_write import _LinkDirectory @pytest.mark.timeout(_PROCESS_DEADLINE * 5) def test_read_locks_are_shared(lock_file: str) -> None: read1_acquired = Event() read2_acquired = Event() reader1 = Process(target=acquire_lock, args=(lock_file, "read", read1_acquired)) reader2 = Process(target=acquire_lock, args=(lock_file, "read", read2_acquired)) with cleanup_processes([reader1, reader2]): reader1.start() time.sleep(0.5) # let reader1 acquire before reader2 starts reader2.start() assert read1_acquired.wait(timeout=_PROCESS_DEADLINE), f"First read lock not acquired on {lock_file}" assert read2_acquired.wait(timeout=_PROCESS_DEADLINE), f"Second read lock not acquired on {lock_file}" reader1.join(timeout=_PROCESS_DEADLINE) reader2.join(timeout=_PROCESS_DEADLINE) assert not reader1.is_alive(), "Reader 1 did not exit cleanly" assert not reader2.is_alive(), "Reader 2 did not exit cleanly" @pytest.mark.timeout(_PROCESS_DEADLINE * 5) def test_write_lock_excludes_other_write_locks(lock_file: str) -> None: write1_acquired = Event() release_write1 = Event() write2_acquired = Event() holder = Process(target=acquire_lock, args=(lock_file, "write", write1_acquired, release_write1)) contender = Process(target=acquire_lock, args=(lock_file, "write", write2_acquired, None, 0.5, True)) with cleanup_processes([holder]): holder.start() assert write1_acquired.wait(timeout=_PROCESS_DEADLINE), "First write lock not acquired" with cleanup_processes([contender]): contender.start() # Wait for the contender's own timeout rather than release behind its back: a slow start could otherwise # let it acquire after the release and be terminated inside the event's lock, which then never unlocks. contender.join(timeout=_PROCESS_DEADLINE) assert (contender.is_alive(), write2_acquired.is_set()) == (False, False), ( "Second write lock should not be acquired" ) release_write1.set() holder.join(timeout=_PROCESS_DEADLINE) assert not holder.is_alive(), "Holder did not exit cleanly" successor = Process(target=acquire_lock, args=(lock_file, "write", write2_acquired)) with cleanup_processes([successor]): successor.start() assert write2_acquired.wait(timeout=_PROCESS_DEADLINE), ( "Second write lock not acquired after first released" ) successor.join(timeout=_PROCESS_DEADLINE) assert not successor.is_alive(), "Successor did not exit cleanly" @pytest.mark.parametrize( ("holder_mode", "contender_mode"), [ pytest.param("write", "read", id="write-blocks-read"), pytest.param("read", "write", id="read-blocks-write"), ], ) @pytest.mark.timeout(_PROCESS_DEADLINE * 5) def test_lock_excludes_opposite_mode( lock_file: str, holder_mode: Literal["read", "write"], contender_mode: Literal["read", "write"], ) -> None: holder_acquired = Event() release_holder = Event() contender_acquired = Event() contender_started = Event() holder = Process(target=acquire_lock, args=(lock_file, holder_mode, holder_acquired, release_holder)) contender = Process( target=acquire_lock, args=(lock_file, contender_mode, contender_acquired, None, -1, True, contender_started), ) with cleanup_processes([holder, contender]): holder.start() assert holder_acquired.wait(timeout=_PROCESS_DEADLINE), f"{holder_mode} lock not acquired" contender.start() contender_started.set() time.sleep(2) # confirm the contender stays blocked assert not contender_acquired.is_set(), f"{contender_mode} lock should not be acquired while {holder_mode} held" release_holder.set() holder.join(timeout=_PROCESS_DEADLINE) assert contender_acquired.wait(timeout=_PROCESS_DEADLINE), ( f"{contender_mode} lock not acquired after {holder_mode} released" ) contender.join(timeout=_PROCESS_DEADLINE) assert not contender.is_alive(), "Contender did not exit cleanly" @pytest.mark.timeout(_PROCESS_DEADLINE * 8) def test_write_non_starvation(lock_file: str) -> None: """A writer that joins after the first reader must acquire before the reader chain drains. Seven readers overlap in a chain and the writer contends mid-chain. It should win the lock while readers still hold it, so it is not starved. """ NUM_READERS: Final[int] = 7 chain_forward = [Event() for _ in range(NUM_READERS)] chain_backward = [Event() for _ in range(NUM_READERS)] writer_ready = Event() writer_contending = Event() writer_acquired = Event() release_count = Value("i", 0) readers = [] for idx in range(NUM_READERS): next_reader = chain_forward[idx + 1] if idx < NUM_READERS - 1 else None prev_or_writer = chain_backward[idx - 1] if idx > 0 else writer_ready reader = Process( target=chain_reader, args=(idx, lock_file, release_count, chain_forward[idx], chain_backward[idx], next_reader, prev_or_writer), ) readers.append(reader) # The last reader holds until its own wait expires, since the test only releases the chain once the writer is in, # so the writer has to outwait that hold rather than time out first. writer = Process( target=acquire_lock, args=(lock_file, "write", writer_acquired, None, _PROCESS_DEADLINE * 2, True, writer_ready, writer_contending), ) with cleanup_processes([*readers, writer]): for reader in readers: reader.start() chain_forward[0].set() assert writer_ready.wait(timeout=_PROCESS_DEADLINE), "First reader did not acquire lock" # Count only the releases the writer actually waited through; a slow interpreter start is not starvation. # The contending event says the writer process booted, not that its write intent reached the database, and # only the intent bars new readers. The probe observes that boundary: a non-blocking read acquire times out # exactly once the intent is registered, so the count starts there. Probing before the writer exists pins # the still-pending arc, which the loop alone cannot promise: on a fast host the first in-loop probe # already times out. prober = ReadWriteLock(lock_file, is_singleton=False) probe_deadline = time.monotonic() + _PROCESS_DEADLINE assert _write_intent_pending(prober, probe_deadline) writer.start() assert writer_contending.wait(timeout=_PROCESS_DEADLINE), "Writer process did not reach the lock" # pragma-no-branch: the loop never exhausts in a passing run, since the helper's deadline fires first for _ in range(_PROCESS_DEADLINE * 20): # pragma: no branch if not _write_intent_pending(prober, probe_deadline): # pragma: no branch # exits at the writer's speed break prober.close() with release_count.get_lock(): releases_before_contending = release_count.value assert writer_acquired.wait(timeout=_PROCESS_DEADLINE * 2), "Writer couldn't acquire lock - possible starvation" with release_count.get_lock(): read_releases = release_count.value - releases_before_contending assert read_releases < 3, f"Writer acquired after {read_releases} readers released - this indicates starvation" writer.join(timeout=_PROCESS_DEADLINE) assert not writer.is_alive(), "Writer did not exit cleanly" for event in chain_backward: event.set() for idx, reader in enumerate(readers): reader.join(timeout=_PROCESS_DEADLINE) assert not reader.is_alive(), f"Reader {idx} did not exit cleanly" @pytest.mark.parametrize( "hold_mode", [pytest.param("read", id="upgrade"), pytest.param("write", id="downgrade")], ) @pytest.mark.timeout(_PROCESS_DEADLINE * 3) def test_lock_mode_transition_prohibited(lock_file: str, hold_mode: Literal["read", "write"]) -> None: result = Value("i", -1) worker = Process(target=try_illegal_transition, args=(lock_file, hold_mode, result)) with cleanup_processes([worker]): worker.start() worker.join(timeout=_PROCESS_DEADLINE) assert not worker.is_alive(), "Process did not exit cleanly" assert result.value == 0, f"Illegal {hold_mode} transition was permitted" @pytest.mark.timeout(_PROCESS_DEADLINE * 3) def test_timeout_behavior(lock_file: str) -> None: write_acquired = Event() release_write = Event() read_acquired = Event() writer = Process(target=acquire_lock, args=(lock_file, "write", write_acquired, release_write)) reader = Process(target=acquire_lock, args=(lock_file, "read", read_acquired, None, 0.5, True)) with cleanup_processes([writer, reader]): writer.start() assert write_acquired.wait(timeout=_PROCESS_DEADLINE), "Write lock not acquired" start_time = time.time() reader.start() assert not read_acquired.wait(timeout=1), "Read lock should not be acquired" reader.join(timeout=_PROCESS_DEADLINE) elapsed = time.time() - start_time assert 0.4 <= elapsed <= 10.0, f"Timeout was not respected: {elapsed}s" release_write.set() writer.join(timeout=_PROCESS_DEADLINE) @pytest.mark.timeout(_PROCESS_DEADLINE * 3) def test_non_blocking_behavior(lock_file: str) -> None: write_acquired = Event() release_write = Event() writer = Process(target=acquire_lock, args=(lock_file, "write", write_acquired, release_write)) with cleanup_processes([writer]): writer.start() assert write_acquired.wait(timeout=_PROCESS_DEADLINE), "Write lock not acquired" start_time = time.time() with pytest.raises(Timeout): ReadWriteLock(lock_file).acquire_read(blocking=False) elapsed = time.time() - start_time assert elapsed < 0.1, f"Non-blocking took too long: {elapsed}s" release_write.set() writer.join(timeout=_PROCESS_DEADLINE) @pytest.mark.parametrize( "mode", [pytest.param("read", id="read"), pytest.param("write", id="write")], ) @pytest.mark.timeout(_PROCESS_DEADLINE * 3) def test_recursive_lock_acquisition(lock_file: str, mode: Literal["read", "write"]) -> None: success = Value("i", 0) worker = Process(target=recursive_lock, args=(lock_file, mode, success)) with cleanup_processes([worker]): worker.start() worker.join(timeout=_PROCESS_DEADLINE) assert success.value == 1, "Recursive lock acquisition failed" @pytest.mark.parametrize( "mode", [pytest.param("read", id="read"), pytest.param("write", id="write")], ) @pytest.mark.timeout(_PROCESS_DEADLINE * 4) def test_lock_release_on_process_termination(lock_file: str, mode: Literal["read", "write"]) -> None: lock_acquired = Event() crashing = Process(target=acquire_lock_and_crash, args=(lock_file, mode, lock_acquired)) crashing.start() assert lock_acquired.wait(timeout=_PROCESS_DEADLINE), "Lock not acquired by first process" write_acquired = Event() successor = Process(target=acquire_lock, args=(lock_file, "write", write_acquired)) with cleanup_processes([crashing, successor]): time.sleep(0.5) # let the lock settle before killing the holder crashing.terminate() crashing.join(timeout=_REAP_DEADLINE) successor.start() assert write_acquired.wait(timeout=_PROCESS_DEADLINE), "Lock not acquired after process termination" successor.join(timeout=_PROCESS_DEADLINE) assert not successor.is_alive(), "Successor did not exit cleanly" @pytest.mark.parametrize( "mode", [pytest.param("read", id="read"), pytest.param("write", id="write")], ) @pytest.mark.timeout(15) def test_single_lock_reentrant(lock_file: str, mode: Literal["read", "write"]) -> None: lock = ReadWriteLock(lock_file) acquire = lock.read_lock if mode == "read" else lock.write_lock with acquire(), acquire(): pass with acquire(): pass @pytest.mark.parametrize( ("first", "second"), [ pytest.param("read", "write", id="read-then-write"), pytest.param("write", "read", id="write-then-read"), ], ) @pytest.mark.timeout(15) def test_sequential_lock_modes( lock_file: str, first: Literal["read", "write"], second: Literal["read", "write"], ) -> None: lock = ReadWriteLock(lock_file) for mode in (first, second): acquire = lock.read_lock if mode == "read" else lock.write_lock with acquire(): pass def test_read_write_lock_keeps_constructed_database(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: first_directory = tmp_path / "first" second_directory = tmp_path / "second" first_directory.mkdir() second_directory.mkdir() first_database = first_directory / "lock.db" second_database = second_directory / "lock.db" monkeypatch.chdir(first_directory) lock = ReadWriteLock("lock.db", is_singleton=False) monkeypatch.chdir(second_directory) with lock.read_lock(): assert_read_write_lock_state(str(first_database), "write", available=False) assert_read_write_lock_state(str(second_database), "write", available=True) lock.close() class _SubclassedReadWriteLock(ReadWriteLock): pass @pytest.mark.usefixtures("connect_route") @pytest.mark.parametrize("held_mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.parametrize( "open_other", [ pytest.param(lambda path: ReadWriteLock(path, is_singleton=False), id="non-singleton"), pytest.param(_SubclassedReadWriteLock, id="subclass-singleton"), pytest.param( lambda path: ReadWriteLock(_hard_link(path), is_singleton=False), id="hard-link-alias", marks=pytest.mark.requires_hard_links, ), ], ) def test_another_instance_on_the_database_keeps_the_held_lock( tmp_path: Path, held_mode: Literal["read", "write"], open_other: Callable[[Path], ReadWriteLock] ) -> None: # POSIX locks belong to the process, so closing any descriptor on the inode drops every lock it holds there; only a # peer process can see that the holder kept its lock. lock_file = tmp_path / "lock.db" holder = ReadWriteLock(lock_file, is_singleton=False) (holder.acquire_read if held_mode == "read" else holder.acquire_write)() open_other(lock_file).close() assert_read_write_lock_state(str(lock_file), "write", available=False) holder.close() def _hard_link(path: Path) -> Path: # pragma: needs hard-link alias = path.with_name("alias.db") os.link(path, alias) return alias @pytest.mark.usefixtures("connect_route") def test_closing_every_instance_closes_its_descriptors(lock_file: str) -> None: if sys.platform == "win32": # pragma: win32 cover pytest.skip("the descriptor table is not listable on Windows") descriptor_directory = Path("/proc/self/fd" if sys.platform == "linux" else "/dev/fd") # pragma: win32 no cover before = len(list(descriptor_directory.iterdir())) # pragma: win32 no cover holder = ReadWriteLock(lock_file, is_singleton=False) # pragma: win32 no cover holder.acquire_read() # pragma: win32 no cover ReadWriteLock(lock_file, is_singleton=False).close() # pragma: win32 no cover holder.close() # pragma: win32 no cover assert len(list(descriptor_directory.iterdir())) == before # pragma: win32 no cover @pytest.fixture( params=[ pytest.param(False, id="private-directory"), pytest.param( True, id="shared-directory", marks=[ pytest.mark.requires_hard_links, pytest.mark.skipif( not CAPABILITIES["posix-hard-link"] and sys.platform != "win32", reason="private database aliases need POSIX hard links without following symlinks", ), ], ), ] ) def connect_route(request: pytest.FixtureRequest, tmp_path: Path) -> None: # Where others may create names beside the database, SQLite connects through a private link instead of the name. if request.param and sys.platform == "win32": # pragma: win32 cover _grant_everyone_write(tmp_path) elif request.param: # pragma: needs posix-hard-link tmp_path.chmod(0o777) def test_read_write_lock_refuses_a_symlinked_path(tmp_path: Path) -> None: # Following it would let anyone who can create names in the lock directory make the victim create the target. target = tmp_path / "target.db" link = tmp_path / "lock.db" try: link.symlink_to(target) except OSError as error: # pragma: no cover - platform policy can deny symlink creation pytest.skip(str(error)) # Construction opens a connection, so the refusal lands there; acquiring reopens and refuses the same way. with pytest.raises(OSError, match=re.escape(str(link))): ReadWriteLock(link, is_singleton=False).acquire_read() assert not target.exists() def test_read_write_lock_refuses_a_non_regular_path(tmp_path: Path) -> None: if sys.platform == "win32" or not CAPABILITIES["fifo"]: # pragma: win32 cover pytest.skip("os.mkfifo is unavailable") # the platform arm also narrows so ty resolves os.mkfifo below # A FIFO planted at the lock path opens without blocking (O_NONBLOCK) but is not a database to hand SQLite. lock_path = tmp_path / "lock.db" # pragma: win32 no cover os.mkfifo(lock_path) # pragma: win32 no cover with pytest.raises(OSError, match="non-regular"): # pragma: win32 no cover ReadWriteLock(lock_path, is_singleton=False) def test_read_write_lock_refuses_a_wal_database(lock_file: str) -> None: _convert_to_wal(lock_file) with pytest.raises(ValueError, match="WAL-mode"): ReadWriteLock(lock_file, is_singleton=False) def test_read_write_lock_refuses_a_database_converted_to_wal_after_construction(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) _convert_to_wal(lock_file) with pytest.raises(ValueError, match="WAL-mode"): lock.acquire_read() lock.close() def _convert_to_wal(path: str) -> None: with closing(sqlite3.connect(path)) as connection: connection.executescript("PRAGMA journal_mode=WAL; CREATE TABLE application (value);").close() @pytest.mark.parametrize("replace", [pytest.param(False, id="unlinked"), pytest.param(True, id="replaced")]) def test_read_write_lock_refuses_a_database_moved_after_connect( tmp_path: Path, mocker: MockerFixture, *, replace: bool ) -> None: if sys.platform == "win32": # pragma: win32 cover pytest.skip("SQLite holds the database open without delete sharing, so its name cannot move") # A waiter connects, then the path moves on before its BEGIN lands; the lock it takes then excludes no later opener. lock_path = tmp_path / "lock.db" # pragma: win32 no cover lock = ReadWriteLock(lock_path, is_singleton=False) # pragma: win32 no cover real_connect = sqlite3.connect # pragma: win32 no cover def connect_then_move( # pragma: win32 no cover database: str, *, check_same_thread: bool, factory: type[sqlite3.Connection], cached_statements: int, timeout: float, ) -> sqlite3.Connection: connection = real_connect( database, check_same_thread=check_same_thread, factory=factory, cached_statements=cached_statements, timeout=timeout, ) if replace: (replacement := tmp_path / "replacement.db").touch() replacement.replace(lock_path) else: lock_path.unlink() return connection mocker.patch("sqlite3.connect", side_effect=connect_then_move) # pragma: win32 no cover with pytest.raises(OSError, match="unlinked or replaced"): # pragma: win32 no cover lock.acquire_write() lock.close() # pragma: win32 no cover def test_read_write_lock_refuses_a_database_held_without_sharing(tmp_path: Path) -> None: if sys.platform != "win32": # pragma: win32 no cover pytest.skip("share modes exist only on Windows") # the platform arm also narrows so ty resolves ctypes.WinDLL # Another process holding the database with no sharing blocks the handle that pins its name, so construction fails. lock_path = tmp_path / "lock.db" # pragma: win32 cover kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) # pragma: win32 cover kernel32.CreateFileW.restype = ctypes.c_void_p # pragma: win32 cover # GENERIC_READ | GENERIC_WRITE, no sharing, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL handle = kernel32.CreateFileW(str(lock_path), 0xC0000000, 0, None, 2, 0x80, None) # pragma: win32 cover try: # pragma: win32 cover with pytest.raises(PermissionError, match="held without sharing"): ReadWriteLock(lock_path, is_singleton=False) finally: # pragma: win32 cover kernel32.CloseHandle(ctypes.c_void_p(handle)) @pytest.mark.skipif(sys.platform != "win32", reason="oplocks exist only on Windows") def test_read_write_lock_oplocked_database_raises_timeout_after_its_timeout( tmp_path: Path, hold_oplock: Callable[[Path], Callable[[], None]] ) -> None: # pragma: win32 cover with closing(ReadWriteLock(database := tmp_path / "lock.db", is_singleton=False)) as lock: hold_oplock(database) started = time.monotonic() with pytest.raises(Timeout): lock.acquire_read(timeout=1) assert 0.9 <= time.monotonic() - started < 5 @pytest.mark.skipif(sys.platform != "win32", reason="oplocks exist only on Windows") def test_read_write_lock_waits_for_an_oplock_holder_that_answers( tmp_path: Path, hold_oplock: Callable[[Path], Callable[[], None]] ) -> None: # pragma: win32 cover with closing(ReadWriteLock(database := tmp_path / "lock.db", is_singleton=False)): pass timer = threading.Timer(1.0, hold_oplock(database)) started = time.monotonic() timer.start() try: with closing(ReadWriteLock(database, is_singleton=False)) as lock, lock.read_lock(): assert time.monotonic() - started >= 1 finally: timer.join() def test_read_write_lock_open_blocked_by_an_oplock_is_a_timeout(tmp_path: Path, mocker: MockerFixture) -> None: with closing(ReadWriteLock(tmp_path / "lock.db", is_singleton=False)) as lock: mocker.patch("filelock._read_write._connect", side_effect=BlockingIOError(EWOULDBLOCK, "oplock break")) with pytest.raises(Timeout): lock.acquire_read(timeout=0) @pytest.mark.skipif(sys.platform != "win32", reason="POSIX creates the database 0o600 through its mode bits") def test_read_write_lock_database_keeps_out_the_directory_acl( inheriting_directory: Path, ) -> None: # pragma: win32 cover lock_path = inheriting_directory / "lock.db" with closing(ReadWriteLock(lock_path, is_singleton=False)): acl = subprocess.run( [Path(os.environ["SYSTEMROOT"], "System32", "icacls.exe"), lock_path], capture_output=True, check=True ).stdout assert b"(I)" not in acl # icacls marks inherited entries with (I) in every display language @pytest.mark.parametrize( ("held_mode", "probe_mode"), [ pytest.param("read", "write", id="read"), pytest.param("write", "read", id="write"), ], ) @pytest.mark.parametrize( ("after_rollback", "available_after_failure"), [ pytest.param(False, False, id="transaction-open"), pytest.param(True, True, id="transaction-ended"), ], ) def test_release_rollback_failure_reconciles_lock_state( lock_file: str, mocker: MockerFixture, held_mode: Literal["read", "write"], probe_mode: Literal["read", "write"], *, after_rollback: bool, available_after_failure: bool, ) -> None: rollback_error = _patch_rollback_failure(mocker, after_rollback=after_rollback) lock = ReadWriteLock(lock_file, is_singleton=False) (lock.acquire_read if held_mode == "read" else lock.acquire_write)() with pytest.raises(sqlite3.OperationalError, match="rollback failed") as info: lock.release() assert info.value is rollback_error assert_read_write_lock_state(lock_file, probe_mode, available=available_after_failure) if available_after_failure: with pytest.raises(RuntimeError, match="not held"): lock.release() else: lock.release() assert_read_write_lock_state(lock_file, probe_mode, available=True) lock.close() def _patch_rollback_failure(mocker: MockerFixture, *, after_rollback: bool) -> sqlite3.OperationalError: real_connect = sqlite3.connect rollback_error = sqlite3.OperationalError("rollback failed") connection_count = 0 def connect( database: str, *, factory: type[sqlite3.Connection], timeout: float, ) -> sqlite3.Connection: nonlocal connection_count connection_count += 1 real_connection = real_connect( database, check_same_thread=False, factory=factory, cached_statements=0, timeout=timeout, ) if connection_count != 2: return real_connection rollback_failed = False def rollback() -> None: nonlocal rollback_failed if not rollback_failed: rollback_failed = True if after_rollback: real_connection.rollback() raise rollback_error real_connection.rollback() connection = mocker.MagicMock( spec_set=type(real_connection), wraps=real_connection, database_identity=None, **{"rollback.side_effect": rollback}, ) mocker.patch.object( type(connection), "in_transaction", new_callable=mocker.PropertyMock, create=True, side_effect=lambda: real_connection.in_transaction, ) return cast("sqlite3.Connection", connection) mocker.patch("filelock._read_write._connect", side_effect=connect) return rollback_error @pytest.fixture def lock_file(tmp_path: Path) -> str: return str(tmp_path / "test_lock.db") @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) def test_acquire_proxy_releases_the_read_write_lock_on_exit(lock_file: str, mode: Literal["read", "write"]) -> None: # acquire_read/acquire_write hand back the shared AcquireReturnProxy; a read/write lock is not a BaseFileLock, so # exiting the proxy must route through the lock's own release() rather than a context-error policy. lock = ReadWriteLock(lock_file, is_singleton=False) acquire = lock.acquire_read if mode == "read" else lock.acquire_write with acquire(): assert_read_write_lock_state(lock_file, "write", available=False) assert_read_write_lock_state(lock_file, "write", available=True) def acquire_lock( lock_file: str, mode: Literal["read", "write"], acquired_event: EventType, release_event: EventType | None = None, timeout: float = -1, blocking: bool = True, ready_event: EventType | None = None, contending_event: EventType | None = None, ) -> None: if ready_event: ready_event.wait(timeout=_PROCESS_DEADLINE) lock = ReadWriteLock(lock_file, timeout=timeout, blocking=blocking) if contending_event: contending_event.set() # interpreter start-up is over, so a caller can time the contention itself with lock.read_lock() if mode == "read" else lock.write_lock(): acquired_event.set() if release_event: release_event.wait(timeout=_PROCESS_DEADLINE) else: time.sleep(0.5) # hold briefly to simulate work def test_cleanup_reports_the_failure_that_escaped_before_a_start() -> None: unstarted = Process(target=time.sleep, args=(0,)) def fail_before_start() -> None: with cleanup_processes([unstarted]): msg = "the real failure" raise AssertionError(msg) with pytest.raises(AssertionError, match="the real failure"): fail_before_start() def _write_intent_pending(prober: ReadWriteLock, deadline: float) -> bool: try: prober.acquire_read(blocking=False) except Timeout: return False prober.release() assert time.monotonic() < deadline, "Writer never registered its write intent" time.sleep(0.05) return True def chain_reader( reader_index: int, lock_file: str, release_count: Synchronized[int], start_signal: EventType, release_signal: EventType, next_reader_signal: EventType | None, writer_or_prev_signal: EventType, ) -> None: start_signal.wait(timeout=_PROCESS_DEADLINE) lock = ReadWriteLock(lock_file) with lock.read_lock(): if reader_index > 0: time.sleep(2) # overlap with earlier readers so the writer contends mid-chain if next_reader_signal is not None: next_reader_signal.set() if reader_index == 0: time.sleep(1) # hold briefly before releasing the writer writer_or_prev_signal.set() release_signal.wait(timeout=_PROCESS_DEADLINE) with release_count.get_lock(): release_count.value += 1 def try_illegal_transition(lock_file: str, hold_mode: Literal["read", "write"], result: Synchronized[int]) -> None: lock = ReadWriteLock(lock_file) with lock.read_lock() if hold_mode == "read" else lock.write_lock(): try: if hold_mode == "read": lock.acquire_write() else: lock.acquire_read() except RuntimeError: result.value = 0 def recursive_lock(lock_file: str, mode: Literal["read", "write"], success_flag: Synchronized[int]) -> None: lock = ReadWriteLock(lock_file) acquire = lock.read_lock if mode == "read" else lock.write_lock with acquire(): assert lock._lock_level == 1 assert lock._current_mode == mode with acquire(): assert lock._lock_level == 2 assert lock._current_mode == mode with acquire(): assert lock._lock_level == 3 assert lock._current_mode == mode assert lock._lock_level == 2 assert lock._current_mode == mode assert lock._lock_level == 1 assert lock._current_mode == mode assert lock._lock_level == 0 assert lock._current_mode is None success_flag.value = 1 def acquire_lock_and_crash(lock_file: str, mode: Literal["read", "write"], acquired_event: EventType) -> None: lock = ReadWriteLock(lock_file) # pragma: win32 no cover with lock.read_lock() if mode == "read" else lock.write_lock(): # pragma: win32 no cover acquired_event.set() while True: # pragma: win32 no cover time.sleep(0.1) @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.parametrize( "step", [ pytest.param("rollback", id="after-rollback"), pytest.param("close", id="after-close"), pytest.param("level-reset", id="at-level-reset"), ], ) def test_read_write_lock_after_an_interrupted_release_locks_again( lock_file: str, mocker: MockerFixture, mode: Literal["read", "write"], step: Literal["rollback", "close", "level-reset"], ) -> None: lock: Final = ReadWriteLock(lock_file, is_singleton=False) acquire: Final = lock.acquire_read if mode == "read" else lock.acquire_write acquire() if step == "level-reset": reset = interrupt_once(ReadWriteLock._clear_lock_state, before=True) mocker.patch.object(ReadWriteLock, "_clear_lock_state", new=reset) else: real = sqlite3.Connection.rollback if step == "rollback" else _ForkSafeConnection.close mocker.patch.object(_ForkSafeConnection, step, new=interrupt_once(real, before=False)) with pytest.raises(Interrupt): lock.release() acquire() contender: Final = ReadWriteLock(lock_file, is_singleton=False) with pytest.raises(Timeout): contender.acquire_write(blocking=False) lock.release() contender.close() lock.close() @pytest.mark.timeout(60) @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) def test_read_write_lock_refuses_a_schema_that_never_finishes_loading( tmp_path: Path, mode: Literal["read", "write"] ) -> None: # Every interior page leads to the next one through each of its 660 cells, so the schema has 661**4 rows to scan. database = tmp_path / "lock.db" pages = [ _btree_page( 0x05, # table interior page struct.pack(">IB", number + 1, 1), 660, right_child=struct.pack(">I", number + 1), header_at=100 if number == 1 else 0, ) for number in range(1, 5) ] pages.append(_btree_page(_TABLE_LEAF_PAGE, bytes([6, 1, 6, 0, 0, 0, 0, 0]), 1)) pages[0][:100] = _file_header(len(pages)) database.write_bytes(b"".join(pages)) lock = ReadWriteLock(database, is_singleton=False) with pytest.raises(ValueError, match="schema"): (lock.acquire_read if mode == "read" else lock.acquire_write)(timeout=1) @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) def test_read_write_lock_refuses_a_schema_row_longer_than_a_lock_needs( tmp_path: Path, mode: Literal["read", "write"] ) -> None: # The row's text continues on an overflow page that names itself as the next one, so one page of disk reads as a # megabyte, far more schema text than a lock database holds and below SQLite's one-billion-byte string limit. long_sql_length = 1_000_000 database = tmp_path / "lock.db" statement = b"CREATE TABLE application (value) --" columns = [b"table", b"application", b"application", b"\x02"] serial_types = [13 + 2 * len(column) for column in columns[:3]] + [1, 13 + 2 * long_sql_length] record_header = b"".join(_varint(serial_type) for serial_type in serial_types) record_start = _varint(len(record_header) + 1) + record_header + b"".join(columns) + statement record_length = len(record_start) + long_sql_length - len(statement) # How much of an overflowing record a table leaf keeps on its own page. local = (minimum := (_PAGE_SIZE - 12) * 32 // 255 - 23) + (record_length - minimum) % (_PAGE_SIZE - 4) cell = _varint(record_length) + _varint(1) + record_start.ljust(local, b"-") + struct.pack(">I", 2) leaf = _btree_page(_TABLE_LEAF_PAGE, cell, 1, header_at=100) page_count = long_sql_length // _PAGE_SIZE + 2 leaf[:100] = _file_header(page_count) database.write_bytes(leaf + struct.pack(">I", 2).ljust(_PAGE_SIZE, b"-")) # SQLite refuses a record longer than the file, which a hole stretches at no cost. os.truncate(database, page_count * _PAGE_SIZE) lock = ReadWriteLock(database, is_singleton=False) with pytest.raises(ValueError, match="schema"): (lock.acquire_read if mode == "read" else lock.acquire_write)(timeout=1) # https://www.sqlite.org/fileformat.html _PAGE_SIZE: Final[int] = 4096 _TABLE_LEAF_PAGE: Final[int] = 0x0D def _btree_page(kind: int, cell: bytes, cell_count: int, *, right_child: bytes = b"", header_at: int = 0) -> bytearray: page = bytearray(_PAGE_SIZE) cell_at = _PAGE_SIZE - len(cell) page[cell_at:] = cell # Every slot of the cell pointer array names the one cell. header = struct.pack(">BHHHB", kind, 0, cell_count, cell_at, 0) + right_child pointers = struct.pack(">H", cell_at) * cell_count page[header_at : header_at + len(header) + len(pointers)] = header + pointers return page def _file_header(page_count: int) -> bytes: # Rollback journal format, a matching change counter and version-valid-for, so SQLite trusts the page count. return struct.pack( ">16sH6B12I20s2I", b"SQLite format 3\0", _PAGE_SIZE, *(1, 1, 0, 64, 32, 32), *(1, page_count, 0, 0, 1, 4, 0, 0, 1, 0, 0, 0), b"", 1, 3040001, ) def _varint(value: int) -> bytes: groups = [value & 0x7F] while value := value >> 7: groups.insert(0, value & 0x7F | 0x80) return bytes(groups) _NEEDS_POSIX_HARD_LINK: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["posix-hard-link"], reason="a shared lock directory gets a private link to the database only where POSIX hard links work", ) @pytest.mark.requires_hard_links @_NEEDS_POSIX_HARD_LINK def test_shared_directory_ignores_a_journal_beside_the_database( # pragma: needs posix-hard-link shared_database: Path, tmp_path: Path ) -> None: # SQLite treats a journal beside a non-empty database as an interrupted transaction, and finishing it deletes the # super-journal file the journal names. named = tmp_path / "notes.txt" named.write_text("keep") with closing(sqlite3.connect(shared_database)) as connection: connection.execute("CREATE TABLE application (value)") Path(f"{shared_database}-journal").write_bytes(_hot_journal(named)) with ReadWriteLock(shared_database, is_singleton=False).read_lock(): pass assert named.read_text() == "keep" @pytest.mark.requires_hard_links @_NEEDS_POSIX_HARD_LINK def test_shared_directory_ignores_a_directory_at_the_wal_name( # pragma: needs posix-hard-link shared_database: Path, ) -> None: # SQLite removes a leftover -wal beside an empty database, so a name it cannot remove failed every acquire. wal = Path(f"{shared_database}-wal") wal.mkdir() (wal / "entry").touch() with ReadWriteLock(shared_database, is_singleton=False).write_lock(): assert_read_write_lock_state(str(shared_database), "read", available=False) @pytest.mark.skipif(sys.platform != "win32", reason="a Windows directory is shared through its ACL") def test_windows_shared_directory_ignores_a_journal_beside_the_database( # pragma: win32 cover everyone_writable_database: Path, tmp_path: Path ) -> None: # SQLite treats a journal beside a non-empty database as an interrupted transaction, and finishing it deletes the # super-journal file the journal names. named = tmp_path / "notes.txt" named.write_text("keep") with closing(sqlite3.connect(everyone_writable_database)) as connection: connection.execute("CREATE TABLE application (value)") Path(f"{everyone_writable_database}-journal").write_bytes(_hot_journal(named)) with ReadWriteLock(everyone_writable_database, is_singleton=False).read_lock(): pass assert named.read_text() == "keep" @pytest.mark.skipif(sys.platform != "win32", reason="a Windows directory is shared through its ACL") def test_windows_shared_directory_ignores_an_undeletable_file_at_the_wal_name( # pragma: win32 cover everyone_writable_database: Path, ) -> None: # SQLite removes a leftover -wal beside an empty database, so a name it cannot remove failed every acquire. with Path(f"{everyone_writable_database}-wal").open("wb") as wal: wal.write(b"\0" * 32) wal.flush() with ReadWriteLock(everyone_writable_database, is_singleton=False).write_lock(): assert_read_write_lock_state(str(everyone_writable_database), "read", available=False) @pytest.mark.skipif(sys.platform != "win32", reason="a Windows directory is shared through its ACL") def test_windows_directory_whose_acl_cannot_be_read_gets_a_private_link( # pragma: win32 cover tmp_path: Path, mocker: MockerFixture ) -> None: # An account without read access to a directory's ACL, as with C:\Windows\Temp, cannot tell who may write there. read_acl = _advapi32.GetNamedSecurityInfoW denials = [5] mocker.patch.object( _advapi32, "GetNamedSecurityInfoW", side_effect=lambda *args: denials.pop() if denials else read_acl(*args), ) connect = mocker.spy(sqlite3, "connect") database = tmp_path / "lock.db" ReadWriteLock(database, is_singleton=False).close() opened = Path(connect.call_args_list[0].args[0]) assert (opened.name, opened.parent.parent.parent) == ("lock.db", tmp_path) @pytest.mark.skipif(sys.platform != "win32", reason="a Windows directory is shared through its ACL") @pytest.mark.parametrize("right", [pytest.param("WDAC", id="write-dac"), pytest.param("WO", id="write-owner")]) def test_windows_directory_whose_acl_can_be_rewritten_gets_a_private_link( # pragma: win32 cover tmp_path: Path, mocker: MockerFixture, right: str ) -> None: # An account that can rewrite a directory's ACL or take its ownership can grant itself the right to add names. directory = tmp_path / "rewritable" directory.mkdir() icacls = Path(os.environ["SYSTEMROOT"], "System32", "icacls.exe") subprocess.run([icacls, directory, "/grant", f"*S-1-1-0:({right})"], capture_output=True, check=True) connect = mocker.spy(sqlite3, "connect") database = directory / "lock.db" ReadWriteLock(database, is_singleton=False).close() opened = Path(connect.call_args_list[0].args[0]) assert (_directory_is_private(os.fspath(directory)), opened.parent.parent.parent) == (False, directory) @pytest.mark.skipif(sys.platform != "win32", reason="a Windows directory is shared through its ACL") def test_windows_directory_only_the_account_can_write_connects_by_name( # pragma: win32 cover tmp_path: Path, mocker: MockerFixture ) -> None: connect = mocker.spy(sqlite3, "connect") database = tmp_path / "lock.db" ReadWriteLock(database, is_singleton=False).close() assert (_directory_is_private(os.fspath(tmp_path)), connect.call_args_list[0].args[0]) == (True, str(database)) @pytest.mark.skipif(sys.platform != "win32", reason="a Windows directory is shared through its ACL") def test_windows_shared_directory_leaves_only_the_database_after_release( # pragma: win32 cover everyone_writable_database: Path, ) -> None: lock = ReadWriteLock(everyone_writable_database, is_singleton=False) lock.acquire_read() assert len(list(everyone_writable_database.parent.iterdir())) == 2 lock.release() assert list(everyone_writable_database.parent.iterdir()) == [everyone_writable_database] @pytest.mark.skipif(sys.platform != "win32", reason="a Windows directory is shared through its ACL") @pytest.mark.parametrize( "boundary", [pytest.param("os.link", id="link"), pytest.param("sqlite3.connect", id="connect")] ) def test_windows_shared_directory_removes_the_link_after_a_failure( # pragma: win32 cover everyone_writable_database: Path, mocker: MockerFixture, boundary: str ) -> None: mocker.patch(boundary, autospec=True, side_effect=OSError("cannot open database")) with pytest.raises(OSError, match="cannot open database"): ReadWriteLock(everyone_writable_database, is_singleton=False) assert list(everyone_writable_database.parent.iterdir()) == [everyone_writable_database] @pytest.mark.skipif(sys.platform != "win32", reason="a Windows directory is shared through its ACL") def test_windows_shared_directory_refuses_a_link_directory_others_can_write( # pragma: win32 cover everyone_writable_database: Path, mocker: MockerFixture ) -> None: mocker.patch("filelock._read_write._directory_is_private", autospec=True, return_value=False) with pytest.raises(OSError, match="replaced while opening"): ReadWriteLock(everyone_writable_database, is_singleton=False) assert list(everyone_writable_database.parent.iterdir()) == [everyone_writable_database] @pytest.mark.skipif(sys.platform != "win32", reason="a Windows directory is shared through its ACL") def test_windows_shared_directory_release_keeps_a_directory_renamed_onto_the_link( # pragma: win32 cover everyone_writable_database: Path, tmp_path: Path, mocker: MockerFixture ) -> None: other = tmp_path / "other" other.mkdir() (other / "notes.txt").write_text("keep") renamed: list[Path] = [] def swap_then_remove(link: _LinkDirectory) -> None: link.path.rename(tmp_path / "aside") other.rename(link.path) renamed.append(link.path) _remove_link_directory_on_windows(link) mocker.patch("filelock._read_write._remove_link_directory_on_windows", autospec=True, side_effect=swap_then_remove) ReadWriteLock(everyone_writable_database, is_singleton=False).close() assert (renamed[0] / "notes.txt").read_text() == "keep" @pytest.mark.skipif(sys.platform != "win32", reason="a Windows directory is shared through its ACL") def test_windows_shared_directory_sweeps_the_link_of_a_dead_holder( # pragma: win32 cover everyone_writable_database: Path, ) -> None: everyone_writable_database.touch() left = everyone_writable_database.parent / f".filelock-{2**31 - 1}--{_host_digest(host_name())}-x" _make_private_directory(os.fspath(left)) (left / "0123456789abcdef").mkdir() (left / "0123456789abcdef" / "lock.db").hardlink_to(everyone_writable_database) with ReadWriteLock(everyone_writable_database, is_singleton=False).read_lock(): pass assert (_link_directories(everyone_writable_database), everyone_writable_database.stat().st_nlink) == ([], 1) @pytest.mark.skipif(sys.platform != "win32", reason="a Windows directory is shared through its ACL") def test_windows_shared_directory_sweep_keeps_a_file_that_is_not_a_link_to_the_database( # pragma: win32 cover everyone_writable_database: Path, ) -> None: left = everyone_writable_database.parent / f".filelock-{2**31 - 1}--{_host_digest(host_name())}-x" _make_private_directory(os.fspath(left)) (left / "0123456789abcdef").mkdir() (left / "0123456789abcdef" / "lock.db").write_text("keep") ReadWriteLock(everyone_writable_database, is_singleton=False).close() assert (left / "0123456789abcdef" / "lock.db").read_text() == "keep" @pytest.mark.skipif(sys.platform != "win32", reason="a Windows directory is shared through its ACL") def test_windows_shared_directory_sweep_keeps_the_link_of_a_live_holder( # pragma: win32 cover everyone_writable_database: Path, holder: subprocess.Popen[str] ) -> None: left = _link_directories(everyone_writable_database) ReadWriteLock(everyone_writable_database, is_singleton=False).close() assert (len(left), _link_directories(everyone_writable_database)) == (1, left) assert holder.poll() is None @pytest.mark.requires_hard_links @_NEEDS_POSIX_HARD_LINK def test_shared_directory_keeps_sqlite_off_the_name_of_an_unlinked_database( # pragma: needs posix-hard-link shared_database: Path, tmp_path: Path, mocker: MockerFixture ) -> None: # The kernel names an open file whose name was unlinked " (deleted)", a name anyone sharing the directory can # create; relinking the same inode under the database name keeps every check on the path satisfied. target = tmp_path / "target.txt" Path(f"{shared_database} (deleted)").symlink_to(target) shared_database.touch() kept = shared_database.with_name("kept.db") kept.hardlink_to(shared_database) connect = sqlite3.connect def relink_then_connect( path: str, *, check_same_thread: bool, factory: type[sqlite3.Connection], cached_statements: int, timeout: float, ) -> sqlite3.Connection: shared_database.unlink() shared_database.hardlink_to(kept) return connect( path, check_same_thread=check_same_thread, factory=factory, cached_statements=cached_statements, timeout=timeout, ) mocker.patch("sqlite3.connect", autospec=True, side_effect=relink_then_connect) ReadWriteLock(shared_database, is_singleton=False).close() assert not target.exists() @pytest.mark.requires_hard_links @_NEEDS_POSIX_HARD_LINK def test_private_directory_connects_by_the_database_name( # pragma: needs posix-hard-link tmp_path: Path, mocker: MockerFixture ) -> None: # Nobody else can create or rename names there, so SQLite may derive its side files beside the database. database = tmp_path / "lock.db" connect = mocker.spy(sqlite3, "connect") with ReadWriteLock(database, is_singleton=False).read_lock(): assert_read_write_lock_state(str(database), "write", available=False) assert (connect.call_args.args[0], list(tmp_path.iterdir())) == (str(database), [database]) @pytest.mark.requires_hard_links @_NEEDS_POSIX_HARD_LINK @pytest.mark.parametrize( "replacement", [pytest.param("database", id="database"), pytest.param("symlink", id="symlink")] ) def test_private_directory_under_a_shared_parent_refuses_a_replaced_directory( # pragma: needs posix-hard-link shared_database: Path, mocker: MockerFixture, tmp_path: Path, replacement: str ) -> None: # A rename in the shared parent owns every name below it, so the private directory check cannot stop SQLite from # opening a replacement swapped in before it connects; the acquire refuses before any statement runs, so a hot # journal beside the replacement is neither replayed nor finished. private = shared_database.parent / "private" private.mkdir(mode=0o700) lock = ReadWriteLock(private / "lock.db", is_singleton=False) moved = shared_database.parent / "moved" (private / "notes.txt").write_text("keep") connect = sqlite3.connect before: dict[Path, list[tuple[str, bytes | None]]] = {moved: _entries(private)} def replace_then_connect( path: str, *, check_same_thread: bool, factory: type[sqlite3.Connection], cached_statements: int, timeout: float, ) -> sqlite3.Connection: private.rename(moved) private.mkdir(mode=0o700) if replacement == "symlink": (private / "lock.db").symlink_to(tmp_path / "elsewhere.txt") else: with closing(connect(private / "lock.db")) as created: created.execute("CREATE TABLE application (value)") (private / "lock.db-journal").write_bytes(_hot_journal(moved / "notes.txt")) (private / "lock.db-wal").write_bytes(b"\0" * 32) before[private] = _entries(private) return connect( path, check_same_thread=check_same_thread, factory=factory, cached_statements=cached_statements, timeout=timeout, ) mocker.patch("sqlite3.connect", autospec=True, side_effect=replace_then_connect) with pytest.raises(OSError, match="replaced while acquiring"): lock.acquire_write() if replacement == "symlink": # SQLite's open creates the missing name, which a symlink there sends to its target. assert ((tmp_path / "elsewhere.txt").read_bytes(), (moved / "notes.txt").read_text()) == (b"", "keep") else: # The journal and WAL beside the replacement stay, the file the journal names stays, and no bytes change. assert {directory: _entries(directory) for directory in before} == before assert_read_write_lock_state(str(moved / "lock.db"), "write", available=True) def _hot_journal(super_journal: Path) -> bytes: # pragma: needs posix-hard-link # A journal whose tail names a super-journal makes SQLite unlink that file once it finishes the journal: # https://www.sqlite.org/fileformat.html#the_rollback_journal name = os.fsencode(super_journal) return name + struct.pack(">II", len(name), sum(name)) + bytes.fromhex("d9d505f920a163d7") def _entries(directory: Path) -> list[tuple[str, bytes | None]]: # pragma: needs posix-hard-link return sorted((path.name, path.read_bytes() if path.is_file() else None) for path in directory.iterdir()) @pytest.fixture def shared_database(tmp_path: Path) -> Path: # pragma: needs posix-hard-link shared = tmp_path / "shared" shared.mkdir() shared.chmod(0o777) return shared / "lock.db" @pytest.fixture def everyone_writable_database(tmp_path: Path) -> Path: # pragma: win32 cover shared = tmp_path / "shared" shared.mkdir() _grant_everyone_write(shared) return shared / "lock.db" def _grant_everyone_write(directory: Path) -> None: # pragma: win32 cover icacls = Path(os.environ["SYSTEMROOT"], "System32", "icacls.exe") subprocess.run([icacls, directory, "/grant", "*S-1-1-0:(OI)(CI)(M)"], capture_output=True, check=True) @pytest.fixture def holder(everyone_writable_database: Path) -> Generator[subprocess.Popen[str]]: # pragma: win32 cover script = ( "import sys, time\n" "from filelock import ReadWriteLock\n" "lock = ReadWriteLock(sys.argv[1], is_singleton=False)\n" "lock.acquire_read()\n" "print('held', flush=True)\n" "time.sleep(600)\n" ) command = [sys.executable, "-c", script, str(everyone_writable_database)] with subprocess.Popen(command, stdout=subprocess.PIPE, text=True) as process: try: assert process.stdout is not None assert process.stdout.readline() == "held\n" yield process finally: process.kill() process.wait(timeout=_PROCESS_DEADLINE) def _link_directories(database: Path) -> list[Path]: # pragma: win32 cover return sorted(path for path in database.parent.iterdir() if path.name.startswith(".filelock-")) filelock-4.2.8/tests/test_read_write_fork.py0000644000000000000000000012550215272561277016207 0ustar00from __future__ import annotations import contextlib import gc import os import signal import subprocess # ruff:ignore[suspicious-subprocess-import] # isolates process-global audit hooks and fork exits import sys import textwrap from pathlib import Path from types import SimpleNamespace from typing import TYPE_CHECKING, Final, Literal, NoReturn import pytest from capabilities import CAPABILITIES import filelock._read_write from filelock import ReadWriteLock from tests.capability_marks import ( NEEDS_AUDIT_EVENTS, NEEDS_COLLECTED_FINALIZATION, NEEDS_FORK, NEEDS_FORK1, ) from tests.descriptor_helpers import descriptors_on if TYPE_CHECKING: from _typeshed import StrOrBytesPath from pytest_mock import MockerFixture _NEEDS_FD_DIRECTORY: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["fd-directory"], reason="counting open descriptors needs a /dev/fd or /proc/self/fd view" ) _NEEDS_PROBE_DESCRIPTORS: Final[pytest.MarkDecorator] = pytest.mark.skipif( sys.platform == "win32", reason="Windows connects without probe descriptors" ) @NEEDS_AUDIT_EVENTS def test_read_write_lock_closes_idle_connections(tmp_path: Path) -> None: lock_path = tmp_path / "idle.db" connection_events = 0 # Typed for the sqlite3.connect payload the hook reads; every other event returns before touching args. def audit_hook( # pragma: no cover - interpreter audit hooks run with tracing disabled event: str, args: tuple[StrOrBytesPath, ...] ) -> None: nonlocal connection_events if event != "sqlite3.connect": return database = args[0] # The lock connects by its own path, or through a private hard link where others share its directory. if isinstance(database, (str, bytes)) and ( (path := os.fsdecode(database)) == str(lock_path) or Path(path).parent.parent.name.startswith(".filelock-") ): connection_events += 1 sys.addaudithook(audit_hook) lock = ReadWriteLock(lock_path, is_singleton=False) lock.acquire_read() lock.acquire_read() lock.release() lock.release() lock.acquire_write() lock.release() lock_path.unlink() assert connection_events == 3 @_NEEDS_FD_DIRECTORY @NEEDS_COLLECTED_FINALIZATION def test_read_write_lock_dropped_instances_leave_no_descriptors(tmp_path: Path) -> None: # pragma: needs fd-directory result = _run_fork_script(_dropped_instances_script(), [str(tmp_path)], timeout=10) assert result == (0, "", "") @_NEEDS_FD_DIRECTORY def test_read_write_lock_keeps_its_probe_descriptor_while_held(tmp_path: Path) -> None: # pragma: needs fd-directory lock_path: Final[Path] = tmp_path / "held.db" with ReadWriteLock(lock_path, is_singleton=False).read_lock(): # SQLite keeps its own descriptor, and the one that refused a symlink stays open with it: closing it would drop # this process's locks on the inode. assert descriptors_on(lock_path.stat()) == 2 @_NEEDS_FD_DIRECTORY # pragma: needs fd-directory def test_read_write_lock_cycling_beside_a_holder_keeps_descriptors_flat(tmp_path: Path) -> None: lock_path: Final[Path] = tmp_path / "held.db" cycler: Final = ReadWriteLock(lock_path, is_singleton=False) with ReadWriteLock(lock_path, is_singleton=False).read_lock(): with cycler.read_lock(): pass before: Final = descriptors_on(lock_path.stat()) for _ in range(20): with cycler.read_lock(): pass assert descriptors_on(lock_path.stat()) == before @_NEEDS_FD_DIRECTORY def test_read_write_locks_held_together_each_keep_their_own_probe(tmp_path: Path) -> None: # pragma: needs fd-directory # macOS SQLite fails, now and then, to open two live connections through one /dev/fd name. lock_path: Final[Path] = tmp_path / "shared.db" with ( ReadWriteLock(lock_path, is_singleton=False).read_lock(), ReadWriteLock(lock_path, is_singleton=False).read_lock(), ): assert descriptors_on(lock_path.stat()) == 4 @_NEEDS_FD_DIRECTORY @pytest.mark.parametrize( "newer_releases_last", [pytest.param(True, id="newer-last"), pytest.param(False, id="older-last")] ) @pytest.mark.parametrize("next_database", [pytest.param("shared.db", id="same"), pytest.param("other.db", id="other")]) def test_read_write_lock_close_interrupted_among_its_probes_closes_the_rest_later( # pragma: needs fd-directory tmp_path: Path, mocker: MockerFixture, newer_releases_last: bool, next_database: str ) -> None: lock_path: Final[Path] = tmp_path / "shared.db" older: Final = ReadWriteLock(lock_path, is_singleton=False) newer: Final = ReadWriteLock(lock_path, is_singleton=False) older.acquire_read() newer.acquire_read() first, last = (older, newer) if newer_releases_last else (newer, older) first.release() real_close: Final = os.close def close_then_interrupt(fd: int) -> None: real_close(fd) # A signal handler runs once the first probe's close returns, before the second one closes. mocker.stopall() raise KeyboardInterrupt mocker.patch("os.close", autospec=True, side_effect=close_then_interrupt) with pytest.raises(KeyboardInterrupt): last.release() # The next connect must not adopt the probe the interrupted close already shut, and any connect finishes the close. with ReadWriteLock(tmp_path / next_database, is_singleton=False).read_lock(): pass assert descriptors_on(lock_path.stat()) == 0 @_NEEDS_PROBE_DESCRIPTORS # pragma: win32 no cover @pytest.mark.parametrize( ("contender_output", "beside_a_peer"), [pytest.param("", False, id="alone"), pytest.param("Timeout\n", True, id="beside-a-peer")], ) def test_read_write_lock_release_interrupted_once_its_probe_release_applies( tmp_path: Path, mocker: MockerFixture, contender_output: str, *, beside_a_peer: bool ) -> None: lock_path: Final[Path] = tmp_path / "held.db" lock: Final = ReadWriteLock(lock_path, is_singleton=False) peer: Final = ReadWriteLock(lock_path, is_singleton=False) lock.acquire_read() if beside_a_peer: peer.acquire_read() release_locked: Final = filelock._read_write._ProbeDescriptors._release_locked # A signal handler runs once the release returns, before the drain dequeues it. def apply_then_interrupt( descriptors: filelock._read_write._ProbeDescriptors, probe: filelock._read_write._Probe ) -> NoReturn: release_locked(descriptors, probe) raise KeyboardInterrupt mocker.patch.object( filelock._read_write._ProbeDescriptors, "_release_locked", autospec=True, side_effect=apply_then_interrupt ) with pytest.raises(KeyboardInterrupt): lock.release() mocker.stopall() # Any connect drains the release the interrupt left queued, applying it again. with ReadWriteLock(tmp_path / "other.db", is_singleton=False).read_lock(): pass # Closing any descriptor on the inode would drop the read lock the peer holds for this process. assert _run_fork_script(_TRY_WRITE_SCRIPT, [str(lock_path)], timeout=30) == (0, contender_output, "") @_NEEDS_FD_DIRECTORY @pytest.mark.parametrize("taking", [pytest.param("retain", id="new-probe"), pytest.param("adopt", id="idle-probe")]) def test_read_write_lock_connect_interrupted_once_it_leases_a_probe_releases_it( # pragma: needs fd-directory tmp_path: Path, mocker: MockerFixture, taking: Literal["retain", "adopt"] ) -> None: lock_path: Final[Path] = tmp_path / "held.db" holder: Final = ReadWriteLock(lock_path, is_singleton=False) holder.acquire_read() if taking == "adopt": # A connection that closes beside a live one leaves its probe idle for the next connect to adopt. with ReadWriteLock(lock_path, is_singleton=False).read_lock(): pass real: Final = getattr(filelock._read_write._ProbeDescriptors, taking) def lease_then_interrupt( descriptors: filelock._read_write._ProbeDescriptors, fd_or_database: int | str, claimed: list[filelock._read_write._Probe], ) -> NoReturn: real(descriptors, fd_or_database, claimed) raise KeyboardInterrupt mocker.patch.object(filelock._read_write._ProbeDescriptors, taking, autospec=True, side_effect=lease_then_interrupt) with pytest.raises(KeyboardInterrupt): ReadWriteLock(lock_path, is_singleton=False).acquire_read() mocker.stopall() holder.release() assert descriptors_on(lock_path.stat()) == 0 @NEEDS_COLLECTED_FINALIZATION @_NEEDS_PROBE_DESCRIPTORS def test_read_write_lock_finalized_while_probes_are_recorded_does_not_deadlock( # pragma: win32 no cover tmp_path: Path, mocker: MockerFixture ) -> None: # The collector runs on whichever allocation trips it, here the one made while the probe table is locked. real_probes: Final = filelock._read_write._InodeProbes def collect_then_build() -> filelock._read_write._InodeProbes: gc.collect() return real_probes() gc.disable() try: cycle = SimpleNamespace(lock=ReadWriteLock(tmp_path / "dropped.db", is_singleton=False)) cycle.lock.acquire_read() cycle.itself = cycle del cycle mocker.patch("filelock._read_write._InodeProbes", autospec=True, side_effect=collect_then_build) ReadWriteLock(tmp_path / "other.db", is_singleton=False).close() finally: gc.enable() with ReadWriteLock(tmp_path / "dropped.db", is_singleton=False).write_lock(blocking=False): pass @NEEDS_FORK # pragma: needs fork @NEEDS_COLLECTED_FINALIZATION def test_async_read_write_lock_allows_finalizer_reentry(tmp_path: Path) -> None: result = _run_fork_script( _reentrant_finalizer_script(), [str(tmp_path / "async.db"), str(tmp_path / "finalizer.db")], timeout=10, ) assert result == (0, "", "") def test_read_write_lock_subclass_has_independent_singletons(tmp_path: Path) -> None: class DerivedReadWriteLock(ReadWriteLock): pass lock_path = tmp_path / "subclass.db" base_lock = ReadWriteLock(lock_path) derived_lock = DerivedReadWriteLock(lock_path) assert (derived_lock is DerivedReadWriteLock(lock_path), derived_lock is not base_lock) == (True, True) @pytest.mark.parametrize( "audit_event", [ pytest.param("sqlite3.connect", marks=NEEDS_AUDIT_EVENTS, id="sqlite-connect"), pytest.param( "ctypes.dlsym", marks=pytest.mark.skipif( sys.implementation.name != "cpython" or sys.version_info >= (3, 12) or sys.platform == "win32", reason="connection escrow uses this audit event only on POSIX CPython before 3.12", ), id="ctypes-dlsym", ), ], ) def test_read_write_lock_rejects_recursive_singleton_construction(tmp_path: Path, audit_event: str) -> None: result = subprocess.run( # executes this test's fixed interpreter and source [sys.executable, "-c", _recursive_construction_script(), str(tmp_path / "construction.db"), audit_event], capture_output=True, check=False, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @pytest.mark.skipif( sys.implementation.name != "cpython" or sys.version_info >= (3, 12), reason="connection escrow uses CPython reference functions before 3.12", ) def test_read_write_lock_escrow_ignores_shared_ctypes_signatures(tmp_path: Path) -> None: # pragma: <3.12 cover result = subprocess.run( # executes this test's fixed interpreter and source [sys.executable, "-c", _ctypes_signature_script(), str(tmp_path / "ctypes.db")], capture_output=True, check=False, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @pytest.mark.parametrize( "operation", [ pytest.param("acquire", id="acquire"), pytest.param("release", id="release"), pytest.param("close", id="close"), ], ) @NEEDS_AUDIT_EVENTS def test_read_write_lock_rejects_same_thread_operation_during_acquisition( tmp_path: Path, operation: Literal["acquire", "release", "close"] ) -> None: result = subprocess.run( # executes this test's fixed interpreter and source [sys.executable, "-c", _recursive_acquisition_script(), str(tmp_path / "acquisition.db"), operation], capture_output=True, check=False, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @pytest.mark.parametrize( "operation", [ pytest.param("release", id="release"), pytest.param("close", id="close"), ], ) @NEEDS_AUDIT_EVENTS def test_read_write_lock_serializes_other_thread_operation_during_acquisition( tmp_path: Path, operation: Literal["release", "close"] ) -> None: result = subprocess.run( # executes this test's fixed interpreter and source [sys.executable, "-c", _concurrent_operation_script(), str(tmp_path / "concurrent.db"), operation], capture_output=True, check=False, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @pytest.mark.parametrize( ("mode", "fork_name", "reject_audit_hook"), [ pytest.param("read", "fork", False, id="read-fork"), pytest.param("write", "fork", False, id="write-fork"), pytest.param("write", "fork", True, id="write-fork-rejected-audit-hook"), pytest.param( "read", "fork1", False, marks=NEEDS_FORK1, id="read-fork1", ), pytest.param( "write", "fork1", False, marks=NEEDS_FORK1, id="write-fork1", ), ], ) def test_read_write_lock_survives_normal_fork_child_exit( tmp_path: Path, mode: Literal["read", "write"], fork_name: Literal["fork", "fork1"], reject_audit_hook: bool, ) -> None: result = _run_fork_script( _fork_script(), [ str(tmp_path / "fork.db"), mode, fork_name, "reject" if reject_audit_hook else "allow", ], timeout=15, ) assert result == (0, "", "") @NEEDS_FORK # pragma: needs fork def test_read_write_lock_fork_waits_for_sqlite_operation(tmp_path: Path) -> None: result = _run_fork_script(_fork_during_sqlite_script(), [str(tmp_path / "fork-gate.db")], timeout=15) assert result == (0, "", "") @NEEDS_FORK # pragma: needs fork def test_read_write_lock_fork_beside_a_waiting_acquire_returns(tmp_path: Path) -> None: result = _run_fork_script(_fork_beside_waiter_script(), [str(tmp_path / "fork-waiter.db")], timeout=15) assert result == (0, "", "") @pytest.mark.skipif( sys.implementation.name != "cpython" or not hasattr(os, "fork"), reason="requires CPython profile events and os.fork", ) @pytest.mark.parametrize( "boundary", [ pytest.param("executescript", id="execute"), pytest.param("rollback", id="rollback"), pytest.param("close", id="close"), pytest.param("connection-return", id="connection-return"), ], ) def test_read_write_lock_fork_at_sqlite_boundary_exits_child( # pragma: needs fork tmp_path: Path, boundary: Literal["executescript", "rollback", "close", "connection-return"] ) -> None: result = _run_fork_script( _fork_at_sqlite_boundary_script(), [str(tmp_path / "fork-boundary.db"), boundary], timeout=10, ) assert result == (0, "", "") @NEEDS_FORK # pragma: needs fork def test_read_write_lock_idle_fork_handles_fresh_child_lock(tmp_path: Path) -> None: result = _run_fork_script(_idle_fork_script(), [str(tmp_path / "idle-fork.db")], timeout=10) assert result == (0, "", "") @pytest.mark.skipif(sys.implementation.name != "pypy" or not hasattr(os, "fork"), reason="requires fork on PyPy") def test_read_write_lock_pypy_child_allows_without_known_sqlite_use(tmp_path: Path) -> None: # pragma: pypy cover result = _run_fork_script( _pypy_fork_script(), [str(tmp_path / "child.db")], timeout=10, ) assert result == (0, "", "") @pytest.mark.skipif(sys.implementation.name != "pypy" or not hasattr(os, "fork"), reason="requires fork on PyPy") def test_read_write_lock_pypy_child_rejects_after_external_sqlite_use(tmp_path: Path) -> None: # pragma: pypy cover result = _run_fork_script( _pypy_external_sqlite_script(), [str(tmp_path / "external.db"), str(tmp_path / "child.db")], timeout=10, ) assert result == (0, "", "") def test_fork_script_terminates_timed_out_process_group() -> None: with pytest.raises(AssertionError, match="fork script exceeded"): _run_fork_script("import time; time.sleep(10)", [], timeout=0.01) @NEEDS_FORK # pragma: needs fork def test_read_write_lock_subclass_cache_resets_after_fork(tmp_path: Path) -> None: result = _run_fork_script(_subclass_fork_script(), [str(tmp_path / "subclass-fork.db")], timeout=10) assert result == (0, "", "") @NEEDS_FORK # pragma: needs fork @pytest.mark.parametrize("held", [pytest.param(False, id="idle"), pytest.param(True, id="held")]) def test_async_read_write_lock_fork_behavior(tmp_path: Path, held: bool) -> None: result = _run_fork_script( _async_fork_script(), [str(tmp_path / "async-fork.db"), "held" if held else "idle"], timeout=15, ) assert result == (0, "", "") def _run_fork_script(script: str, arguments: list[str], *, timeout: float) -> tuple[int, str, str]: process = subprocess.Popen( # executes this test's fixed interpreter and source [sys.executable, "-c", script, *arguments], start_new_session=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE, text=True, ) try: process_output, process_error = process.communicate(timeout=timeout) except subprocess.TimeoutExpired as error: with contextlib.suppress(ProcessLookupError): if sys.platform == "win32": # pragma: win32 cover process.kill() else: # pragma: win32 no cover os.killpg(process.pid, signal.SIGKILL) process_output, process_error = process.communicate() msg = f"fork script exceeded {timeout} seconds: stdout={process_output!r}, stderr={process_error!r}" raise AssertionError(msg) from error assert process.returncode is not None # pragma: needs fork return process.returncode, process_output, process_error # pragma: needs fork def _dropped_instances_script() -> str: # pragma: needs fd-directory return textwrap.dedent( """ from __future__ import annotations import gc import sys from pathlib import Path from filelock import ReadWriteLock root = Path(sys.argv[1]) descriptor_path = Path("/dev/fd") if Path("/dev/fd").is_dir() else Path("/proc/self/fd") gc.collect() initial_count = sum(1 for _ in descriptor_path.iterdir()) for index in range(100): lock = ReadWriteLock(root / f"dropped-{index}.db", is_singleton=False) lock.acquire_write() del lock gc.collect() assert sum(1 for _ in descriptor_path.iterdir()) == initial_count with ReadWriteLock(root / "dropped-0.db", is_singleton=False).read_lock(): pass """ ) _TRY_WRITE_SCRIPT: Final = textwrap.dedent( """ import sys from filelock import ReadWriteLock, Timeout try: ReadWriteLock(sys.argv[1], is_singleton=False).acquire_write(timeout=0) except Timeout: print("Timeout") """ ) def _reentrant_finalizer_script() -> str: # pragma: needs fork return textwrap.dedent( """ from __future__ import annotations import asyncio import gc import sys import weakref from threading import Condition, Event from types import CodeType, FrameType from typing import Final from filelock import AsyncReadWriteLock, ReadWriteLock class _FinalizerTarget: cycle: _FinalizerTarget _FINALIZED: Final[Event] = Event() def _close_lock() -> None: ReadWriteLock(sys.argv[2], is_singleton=False).close() _FINALIZED.set() _TARGET = _FinalizerTarget() _TARGET.cycle = _TARGET weakref.finalize(_TARGET, _close_lock) del _TARGET _NOTIFY_CODE: Final[CodeType] = Condition.notify_all.__code__ def _collect_at_notify(frame: FrameType, _event: str, _argument: None) -> None: if frame.f_code is _NOTIFY_CODE: sys.settrace(None) gc.collect() sys.settrace(_collect_at_notify) _LOCK: Final[AsyncReadWriteLock] = AsyncReadWriteLock(sys.argv[1], is_singleton=False) sys.settrace(None) assert _FINALIZED.is_set() asyncio.run(_LOCK.close()) """ ) def _recursive_construction_script() -> str: return textwrap.dedent( """ from __future__ import annotations import sys from filelock import ReadWriteLock lock_path, audit_event = sys.argv[1:] armed = True from typing import TYPE_CHECKING if TYPE_CHECKING: from _typeshed import Unused def audit_hook(event: str, _args: Unused) -> None: if armed and event == audit_event: ReadWriteLock(lock_path) sys.addaudithook(audit_hook) try: ReadWriteLock(lock_path) except RuntimeError as error: assert str(error) == f"Singleton lock construction is already active for {lock_path}" else: raise AssertionError("recursive singleton construction succeeded") armed = False with ReadWriteLock(lock_path).read_lock(): pass """ ) def _recursive_acquisition_script() -> str: return textwrap.dedent( """ from __future__ import annotations import sys from filelock import ReadWriteLock lock_path, operation = sys.argv[1:] lock = ReadWriteLock(lock_path) armed = True from typing import TYPE_CHECKING if TYPE_CHECKING: from _typeshed import Unused def audit_hook(event: str, _args: Unused) -> None: if armed and event == "sqlite3.connect": if operation == "acquire": lock.acquire_read() elif operation == "release": lock.release() else: lock.close() sys.addaudithook(audit_hook) try: lock.acquire_read() except RuntimeError as error: assert str(error) == ( f"Cannot {operation} ReadWriteLock on {lock_path} while acquisition is active in this thread" ) else: raise AssertionError(f"recursive {operation} succeeded") armed = False with lock.read_lock(): pass """ ) def _ctypes_signature_script() -> str: # pragma: <3.12 cover return textwrap.dedent( """ from __future__ import annotations import ctypes import sys ctypes.pythonapi.Py_IncRef.argtypes = (ctypes.c_void_p,) from filelock import ReadWriteLock lock = ReadWriteLock(sys.argv[1], is_singleton=False) lock.acquire_read() ctypes.pythonapi.Py_DecRef.argtypes = (ctypes.c_void_p,) lock.release() with ReadWriteLock(sys.argv[1], is_singleton=False).write_lock(): pass """ ) def _concurrent_operation_script() -> str: return textwrap.dedent( """ from __future__ import annotations import queue import sqlite3 import sys import threading from filelock import ReadWriteLock lock_path, operation = sys.argv[1:] lock = ReadWriteLock(lock_path) acquisition_inside_connect = threading.Event() continue_acquisition = threading.Event() acquire_errors: queue.SimpleQueue[BaseException] = queue.SimpleQueue() operation_errors: queue.SimpleQueue[BaseException] = queue.SimpleQueue() armed = True from typing import TYPE_CHECKING if TYPE_CHECKING: from _typeshed import Unused def audit_hook(event: str, _args: Unused) -> None: if armed and event == "sqlite3.connect": acquisition_inside_connect.set() assert continue_acquisition.wait(5) def acquire() -> None: try: lock.acquire_write() except BaseException as error: acquire_errors.put(error) def operate() -> None: try: getattr(lock, operation)() except BaseException as error: operation_errors.put(error) sys.addaudithook(audit_hook) acquire_thread = threading.Thread(target=acquire) acquire_thread.start() assert acquisition_inside_connect.wait(5) operation_thread = threading.Thread(target=operate) operation_thread.start() operation_thread.join(0.1) assert operation_thread.is_alive() armed = False continue_acquisition.set() acquire_thread.join(5) operation_thread.join(5) assert not acquire_thread.is_alive() assert not operation_thread.is_alive() assert acquire_errors.empty() if operation == "release": assert "while it is held by thread" in str(operation_errors.get_nowait()) lock.release(force=True) with lock.write_lock(): pass else: assert operation_errors.empty() try: lock.acquire_read() except sqlite3.ProgrammingError as error: assert str(error) == "Cannot operate on a closed database." else: raise AssertionError("concurrent acquisition reopened a closed lock") """ ) def _fork_script() -> str: return textwrap.dedent( """ from __future__ import annotations import gc import os import sqlite3 import sys from typing import TYPE_CHECKING if TYPE_CHECKING: from _typeshed import Unused lock_path, mode, fork_name, audit_hook_mode = sys.argv[1:] if audit_hook_mode == "reject": def reject_filelock_audit_hook(event: str, _args: Unused) -> None: if event == "sys.addaudithook": raise RuntimeError("audit hook registration rejected") sys.addaudithook(reject_filelock_audit_hook) from filelock import ReadWriteLock, Timeout lock = ReadWriteLock(lock_path, is_singleton=False) acquire = lock.acquire_read if mode == "read" else lock.acquire_write proxy = acquire() parent_to_child_read, parent_to_child_write = os.pipe() child_to_parent_read, child_to_parent_write = os.pipe() child_pid = getattr(os, fork_name)() if child_pid == 0: os.close(parent_to_child_write) os.close(child_to_parent_read) try: with proxy: pass try: lock.acquire_read() except RuntimeError as error: assert "was invalidated by fork()" in str(error) else: raise AssertionError("inherited acquisition succeeded") lock.release() lock.close() del proxy del lock gc.collect() sqlite_connects = 0 def count_sqlite_connects(event: str, _args: Unused) -> None: global sqlite_connects if event == "sqlite3.connect": sqlite_connects += 1 sys.addaudithook(count_sqlite_connects) try: ReadWriteLock(lock_path, is_singleton=False) except RuntimeError as error: expected = ( "ReadWriteLock is unavailable in a PyPy fork child" if sys.implementation.name == "pypy" else "was active across fork(); exec or exit" ) assert expected in str(error) else: raise AssertionError("child reopened a database active during fork") assert sqlite_connects == 0 os.write(child_to_parent_write, b"1") assert os.read(parent_to_child_read, 1) == b"1" except BaseException: import traceback traceback.print_exc() sys.exit(1) sys.exit(0) os.close(parent_to_child_read) os.close(child_to_parent_write) assert os.read(child_to_parent_read, 1) == b"1" contender = ReadWriteLock(lock_path, is_singleton=False) conflicting_acquire = contender.acquire_write if mode == "read" else contender.acquire_read try: conflicting_acquire(blocking=False) except Timeout: pass else: raise AssertionError("child exit released the parent's lock") lock.release() os.write(parent_to_child_write, b"1") _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 with sqlite3.connect(lock_path) as connection: assert connection.execute("PRAGMA integrity_check").fetchone() == ("ok",) """ ) def _fork_beside_waiter_script() -> str: # pragma: needs fork return textwrap.dedent( r""" from __future__ import annotations import os import sys import threading import warnings from filelock import ReadWriteLock warnings.filterwarnings("ignore", category=DeprecationWarning, message=r".*fork\(\).*") lock_path = sys.argv[1] holder = ReadWriteLock(lock_path, is_singleton=False) waiter = ReadWriteLock(lock_path, is_singleton=False) acquired = threading.Event() def acquire() -> None: with waiter.write_lock(): acquired.set() holder.acquire_write() waiter_thread = threading.Thread(target=acquire, daemon=True) waiter_thread.start() assert not acquired.wait(0.2) # Only this thread can end the waiter's wait, so a fork that waited for it would not return. child_pid = os.fork() if child_pid == 0: os._exit(0) _, status = os.waitpid(child_pid, 0) holder.release() assert acquired.wait(5) waiter_thread.join(5) assert os.waitstatus_to_exitcode(status) == 0 assert not waiter_thread.is_alive() """ ) def _fork_during_sqlite_script() -> str: # pragma: needs fork return textwrap.dedent( r""" from __future__ import annotations import os import queue import sqlite3 import sys import threading import time import warnings from typing import TYPE_CHECKING if TYPE_CHECKING: from _typeshed import StrOrBytesPath from filelock import ReadWriteLock warnings.filterwarnings("ignore", category=DeprecationWarning, message=r".*fork\(\).*") lock_path = sys.argv[1] waiter = ReadWriteLock(lock_path, is_singleton=False) holder = sqlite3.connect(lock_path, check_same_thread=False) holder.executescript("PRAGMA journal_mode=MEMORY; BEGIN EXCLUSIVE TRANSACTION;").close() waiter_inside_connect = threading.Event() continue_connect = threading.Event() acquisition_errors: queue.SimpleQueue[BaseException] = queue.SimpleQueue() armed = True # Typed for the sqlite3.connect payload the hook reads; every other event returns before touching args. def audit_hook(event: str, args: tuple[StrOrBytesPath, ...]) -> None: if not armed or event != "sqlite3.connect": return database = args[0] if isinstance(database, (str, bytes)) and ( (path := os.fsdecode(database)) == lock_path or path.startswith(("/dev/fd/", "/proc/self/fd/")) or os.path.basename(os.path.dirname(path)).startswith(".filelock-") ): waiter_inside_connect.set() assert continue_connect.wait(5) def acquire() -> None: try: with waiter.read_lock(timeout=5): pass except BaseException as error: acquisition_errors.put(error) def release_holder() -> None: threading.Event().wait(0.2) continue_connect.set() holder.rollback() holder.close() sys.addaudithook(audit_hook) waiter_thread = threading.Thread(target=acquire) waiter_thread.start() assert waiter_inside_connect.wait(5) release_thread = threading.Thread(target=release_holder) release_thread.start() start = time.monotonic() child_pid = os.fork() elapsed = time.monotonic() - start if child_pid == 0: sys.exit(0) armed = False _, status = os.waitpid(child_pid, 0) waiter_thread.join(5) release_thread.join(5) assert os.waitstatus_to_exitcode(status) == 0 assert elapsed >= 0.1 assert not waiter_thread.is_alive() assert not release_thread.is_alive() if not acquisition_errors.empty(): raise acquisition_errors.get() """ ) def _fork_at_sqlite_boundary_script() -> str: return textwrap.dedent( """ from __future__ import annotations import os import sqlite3 import sys from types import FrameType from typing import TYPE_CHECKING, Protocol if TYPE_CHECKING: from _typeshed import Unused class ProfiledCall(Protocol): @property def __name__(self) -> str: ... def reject_filelock_audit_hook(event: str, _args: Unused) -> None: if event == "sys.addaudithook": raise RuntimeError("audit hook registration rejected") sys.addaudithook(reject_filelock_audit_hook) from filelock import ReadWriteLock lock_path, boundary = sys.argv[1:] child_pids: list[int] = [] armed = True def profile( _frame: FrameType, event: str, value: ProfiledCall | sqlite3.Connection | None, ) -> None: global armed connection_returned = boundary == "connection-return" and event == "return" and isinstance( value, sqlite3.Connection ) sqlite_called = ( boundary != "connection-return" and event == "c_call" and value is not None and value.__name__ == boundary ) if armed and (connection_returned or sqlite_called): armed = False child_pids.append(os.fork()) sys.setprofile(profile) with ReadWriteLock(lock_path, is_singleton=False).write_lock(): pass sys.setprofile(None) assert len(child_pids) == 1 _, status = os.waitpid(child_pids[0], 0) assert os.waitstatus_to_exitcode(status) == 70 """ ) def _idle_fork_script() -> str: return textwrap.dedent( """ from __future__ import annotations import os import sys from filelock import ReadWriteLock lock_path = sys.argv[1] inherited_lock = ReadWriteLock(lock_path, is_singleton=False) child_pid = os.fork() if child_pid == 0: try: inherited_lock.acquire_read() except RuntimeError as error: assert "was invalidated by fork()" in str(error) else: raise AssertionError("inherited idle lock acquired") if sys.implementation.name == "pypy": try: ReadWriteLock(lock_path, is_singleton=False) except RuntimeError as error: assert str(error) == ( "ReadWriteLock is unavailable in a PyPy fork child; exec or exit before using it" ) else: raise AssertionError("PyPy child opened SQLite after fork") else: with ReadWriteLock(lock_path, is_singleton=False).write_lock(): pass sys.exit(0) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 """ ) def _pypy_fork_script() -> str: # pragma: pypy cover return textwrap.dedent( """ from __future__ import annotations import os import sys from filelock import ReadWriteLock child_path = sys.argv[1] child_pid = os.fork() if child_pid == 0: with ReadWriteLock(child_path, is_singleton=False).read_lock(): pass sys.exit(0) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 """ ) def _pypy_external_sqlite_script() -> str: # pragma: pypy cover return textwrap.dedent( """ from __future__ import annotations import os import sqlite3 import sys from filelock import ReadWriteLock external_path, child_path = sys.argv[1:] sqlite3.connect(external_path).close() child_pid = os.fork() if child_pid == 0: try: ReadWriteLock(child_path, is_singleton=False) except RuntimeError as error: assert str(error) == ( "ReadWriteLock is unavailable in a PyPy fork child; exec or exit before using it" ) else: raise AssertionError("PyPy child opened SQLite after parent use") sys.exit(0) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 """ ) def _subclass_fork_script() -> str: return textwrap.dedent( """ from __future__ import annotations import os import sys from filelock import ReadWriteLock class DerivedReadWriteLock(ReadWriteLock): pass lock_path = sys.argv[1] inherited_lock = DerivedReadWriteLock(lock_path) child_pid = os.fork() if child_pid == 0: if sys.implementation.name == "pypy": try: DerivedReadWriteLock(lock_path) except RuntimeError as error: assert str(error) == ( "ReadWriteLock is unavailable in a PyPy fork child; exec or exit before using it" ) else: raise AssertionError("PyPy child constructed a lock after fork") else: child_lock = DerivedReadWriteLock(lock_path) assert child_lock is not inherited_lock with child_lock.read_lock(): pass sys.exit(0) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 """ ) def _async_fork_script() -> str: return textwrap.dedent( r""" from __future__ import annotations import asyncio import gc import os import sqlite3 import sys import warnings from filelock import AsyncAcquireReadWriteReturnProxy, AsyncReadWriteLock, ReadWriteLock, Timeout warnings.filterwarnings("ignore", category=DeprecationWarning, message=r".*fork\(\).*") lock_path, state = sys.argv[1:] async def set_up_parent() -> tuple[AsyncReadWriteLock, AsyncAcquireReadWriteReturnProxy | None]: parent_lock = AsyncReadWriteLock(lock_path, is_singleton=False) parent_proxy = await parent_lock.acquire_write() if state == "held" else None return parent_lock, parent_proxy lock, proxy = asyncio.run(set_up_parent()) child_pid = os.fork() if child_pid == 0: async def check_child() -> None: if proxy is not None: async with proxy: pass try: await lock.acquire_read() except RuntimeError as error: assert "was invalidated by fork()" in str(error) else: raise AssertionError("inherited async lock acquired") await lock.release() await lock.close() if sys.implementation.name == "pypy": try: AsyncReadWriteLock(lock_path, is_singleton=False) except RuntimeError as error: assert str(error) == ( "ReadWriteLock is unavailable in a PyPy fork child; exec or exit before using it" ) else: raise AssertionError("PyPy child constructed an async lock after fork") elif state == "idle": fresh_lock = AsyncReadWriteLock(lock_path, is_singleton=False) async with fresh_lock.write_lock(): pass await fresh_lock.close() else: try: ReadWriteLock(lock_path, is_singleton=False) except RuntimeError as error: expected = ( "ReadWriteLock is unavailable in a PyPy fork child" if sys.implementation.name == "pypy" else "was active across fork(); exec or exit" ) assert expected in str(error) else: raise AssertionError("active async database reopened in child") asyncio.run(check_child()) del lock gc.collect() sys.exit(0) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 if state == "held": contender = ReadWriteLock(lock_path, is_singleton=False) try: contender.acquire_read(blocking=False) except Timeout: pass else: raise AssertionError("async child cleanup released the parent's lock") # Each asyncio.run is a new task and a hold belongs to the task that took it, so close() releases the parent's. asyncio.run(lock.close()) with sqlite3.connect(lock_path) as connection: assert connection.execute("PRAGMA integrity_check").fetchone() == ("ok",) """ ) filelock-4.2.8/tests/test_read_write_private_link.py0000644000000000000000000004445615272561277017745 0ustar00from __future__ import annotations import asyncio import errno import os import shutil import stat import subprocess # ruff:ignore[suspicious-subprocess-import] # runs this test's own interpreter import sys import tempfile from pathlib import Path from typing import TYPE_CHECKING, Final, Literal import pytest from capabilities import CAPABILITIES pytest.importorskip("sqlite3") import sqlite3 from filelock import AsyncReadWriteLock, ReadWriteLock from tests.capability_marks import NEEDS_FILE_PERMISSIONS from tests.read_write_helpers import assert_read_write_lock_state if TYPE_CHECKING: from collections.abc import Generator from pytest_mock import MockerFixture pytestmark: Final = [ pytest.mark.requires_hard_links, pytest.mark.skipif( not CAPABILITIES["posix-hard-link"], reason="private database aliases need POSIX hard links without following symlinks", ), ] @pytest.mark.asyncio @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) async def test_private_link_connects_through_the_validated_inode( database: Path, mocker: MockerFixture, mode: Literal["read", "write"] ) -> None: connect: Final = mocker.spy(sqlite3, "connect") lock: Final = AsyncReadWriteLock(database, is_singleton=False) try: async with (lock.read_lock if mode == "read" else lock.write_lock)(): opened: Final = Path(connect.call_args.args[0]) link_directory: Final = opened.parent.parent assert ( opened.name, link_directory.parent, stat.S_IMODE(link_directory.stat().st_mode), await asyncio.to_thread(opened.samefile, database), ) == ("lock.db", database.parent, 0o700, True) await asyncio.to_thread(assert_read_write_lock_state, str(database), "write", available=False) finally: await lock.close() @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) def test_private_link_preserves_contention(database: Path, mode: Literal["read", "write"]) -> None: lock: Final = ReadWriteLock(database, is_singleton=False) with (lock.read_lock if mode == "read" else lock.write_lock)(): assert_read_write_lock_state(str(database), "write", available=False) @pytest.mark.parametrize( "boundary", [pytest.param("tempfile.mkdtemp", id="mkdtemp"), pytest.param("os.link", id="link")] ) def test_private_link_failure_preserves_a_peer_lock(database: Path, mocker: MockerFixture, boundary: str) -> None: with ReadWriteLock(database, is_singleton=False).read_lock(): mocker.patch(boundary, autospec=True, side_effect=PermissionError(errno.EACCES, "read-only directory")) with pytest.raises(PermissionError, match="read-only directory"): ReadWriteLock(database, is_singleton=False) assert_read_write_lock_state(str(database), "write", available=False) assert list(database.parent.glob(".filelock-*")) == [] @pytest.mark.asyncio @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) async def test_private_link_preserves_async_contention(database: Path, mode: Literal["read", "write"]) -> None: lock: Final = AsyncReadWriteLock(database, is_singleton=False) async with (lock.read_lock if mode == "read" else lock.write_lock)(): assert_read_write_lock_state(str(database), "write", available=False) await lock.close() def test_private_link_is_removed_after_release(database: Path, created_directories: list[Path]) -> None: lock: Final = ReadWriteLock(database, is_singleton=False) lock.acquire_read() assert [directory.exists() for directory in created_directories] == [False, True] lock.release() assert [directory.exists() for directory in created_directories] == [False, False] def test_private_link_sits_beside_the_database(database: Path, created_directories: list[Path]) -> None: # A hard link cannot cross filesystems, so an alias in TMPDIR fails for a database stored anywhere else. ReadWriteLock(database, is_singleton=False).close() assert [directory.parent for directory in created_directories] == [database.parent] def test_private_link_release_survives_a_removed_alias(database: Path, created_directories: list[Path]) -> None: lock: Final = ReadWriteLock(database, is_singleton=False) lock.acquire_read() shutil.rmtree(created_directories[-1]) lock.release() with ReadWriteLock(database, is_singleton=False).write_lock(blocking=False): assert_read_write_lock_state(str(database), "write", available=False) @pytest.mark.parametrize( "boundary", [pytest.param("os.link", id="link"), pytest.param("sqlite3.connect", id="connect")], ) def test_private_link_is_removed_after_failure( database: Path, mocker: MockerFixture, boundary: str, created_directories: list[Path] ) -> None: mocker.patch(boundary, autospec=True, side_effect=OSError("cannot open database")) with pytest.raises(OSError, match="cannot open database"): ReadWriteLock(database, is_singleton=False) assert [directory.exists() for directory in created_directories] == [False] @pytest.mark.parametrize("replacement_kind", [pytest.param("file", id="file"), pytest.param("symlink", id="symlink")]) def test_private_link_rejects_replaced_database( database: Path, tmp_path: Path, mocker: MockerFixture, replacement_kind: str, created_directories: list[Path] ) -> None: replacement: Final = tmp_path / "replacement.db" if replacement_kind == "file": replacement.touch() else: replacement.symlink_to(tmp_path / "victim.db") link: Final = os.link def replace_and_link(source: str, destination: Path, *, follow_symlinks: bool) -> None: replacement.replace(source) link(source, destination, follow_symlinks=follow_symlinks) mocker.patch("os.link", autospec=True, side_effect=replace_and_link) with pytest.raises(OSError, match="database changed"): ReadWriteLock(database, is_singleton=False) assert ([directory.exists() for directory in created_directories], (tmp_path / "victim.db").exists()) == ( [False], False, ) def test_private_link_refuses_a_directory_swapped_in_before_it_opens( database: Path, tmp_path: Path, mocker: MockerFixture ) -> None: swapped_in: Final[list[Path]] = [] mkdtemp: Final = tempfile.mkdtemp def create_then_swap(*, prefix: str, dir: Path) -> str: # ruff:ignore[builtin-argument-shadowing] # mirrors tempfile.mkdtemp directory: Final = Path(mkdtemp(prefix=prefix, dir=dir)) directory.rename(tmp_path / "aside") directory.mkdir(mode=0o755) (directory / "notes.txt").write_text("keep") swapped_in.append(directory) return str(directory) mocker.patch("tempfile.mkdtemp", autospec=True, side_effect=create_then_swap) with pytest.raises(OSError, match="replaced while opening"): ReadWriteLock(database, is_singleton=False) assert [(directory / "notes.txt").read_text() for directory in swapped_in] == ["keep"] def test_private_link_closes_a_mode_an_inherited_acl_opened(database: Path, loosened_directories: list[Path]) -> None: lock: Final = ReadWriteLock(database, is_singleton=False) with lock.read_lock(): assert [stat.S_IMODE(directory.stat().st_mode) for directory in loosened_directories[1:]] == [0o700] @pytest.mark.parametrize( "fchmod_error", [ pytest.param(None, id="ignored"), pytest.param(PermissionError(errno.EPERM, "Operation not permitted"), id="refused"), ], ) def test_private_link_refuses_a_directory_chmod_cannot_close( database: Path, loosened_directories: list[Path], mocker: MockerFixture, fchmod_error: PermissionError | None ) -> None: # ZFS with aclmode=restricted refuses a chmod on a directory carrying an inherited ACL. mocker.patch("os.fchmod", autospec=True, side_effect=fchmod_error) with pytest.raises(OSError, match="writable by its owner only"): ReadWriteLock(database, is_singleton=False) assert len(loosened_directories) == 1 def test_private_link_opens_nothing_through_a_swapped_link_directory( database: Path, tmp_path: Path, mocker: MockerFixture, created_directories: list[Path] ) -> None: # Whoever can rename in the lock directory may swap the link directory for one of its own before SQLite opens it. target: Final = tmp_path / "target.db" connect: Final = sqlite3.connect def swap_then_connect( database: str, *, check_same_thread: bool, factory: type[sqlite3.Connection], cached_statements: int, timeout: float, ) -> sqlite3.Connection: created_directories[-1].rename(tmp_path / "aside") created_directories[-1].mkdir() (created_directories[-1] / "lock.db").symlink_to(target) return connect( database, check_same_thread=check_same_thread, factory=factory, cached_statements=cached_statements, timeout=timeout, ) mocker.patch("sqlite3.connect", autospec=True, side_effect=swap_then_connect) with pytest.raises(sqlite3.OperationalError, match="unable to open database file"): ReadWriteLock(database, is_singleton=False) assert (target.exists(), (created_directories[-1] / "lock.db").is_symlink()) == (False, True) def test_private_link_release_keeps_a_directory_renamed_onto_the_link(database: Path, tmp_path: Path) -> None: other: Final = database.parent / "project" other.mkdir() (other / "notes.txt").write_text("keep") with ReadWriteLock(database, is_singleton=False).write_lock(): (link_directory,) = _link_directories(database) link_directory.rename(tmp_path / "aside") other.rename(link_directory) assert (link_directory / "notes.txt").read_text() == "keep" def test_private_link_sweeps_the_link_of_a_killed_holder(database: Path, holder: subprocess.Popen[str]) -> None: holder.kill() holder.wait(timeout=_PROCESS_DEADLINE) left: Final = _link_directories(database) with ReadWriteLock(database, is_singleton=False).read_lock(): pass assert (len(left), _link_directories(database), database.stat().st_nlink) == (1, [], 1) def test_private_link_sweep_removes_a_link_left_by_an_earlier_release(database: Path) -> None: # Releases up to 4.2.0 linked the database right inside the directory. database.touch() left: Final = database.parent / f".filelock-{2**31 - 1}--{_host_digest(database)}-x" left.mkdir(mode=0o700) (left / "lock.db").hardlink_to(database) with ReadWriteLock(database, is_singleton=False).read_lock(): pass assert (_link_directories(database), database.stat().st_nlink) == ([], 1) @pytest.mark.parametrize( "entry", [ pytest.param("lock.db", id="flat"), pytest.param("inner/lock.db", id="nested"), pytest.param("inner/other", id="nested-without-link"), ], ) def test_private_link_sweep_keeps_a_directory_named_like_a_dead_holder(database: Path, entry: str) -> None: named: Final = database.parent / f".filelock-{2**31 - 1}--{_host_digest(database)}-x" (named / entry).parent.mkdir(parents=True) (named / entry).write_text("keep") (named / "notes.txt").write_text("keep") ReadWriteLock(database, is_singleton=False).close() assert [(named / name).read_text() for name in (entry, "notes.txt")] == ["keep", "keep"] @pytest.mark.parametrize( "shape", [pytest.param("empty", id="empty"), pytest.param("empty-subdirectory", id="empty-subdirectory")] ) def test_private_link_sweep_removes_a_directory_left_without_a_link(database: Path, shape: str) -> None: left: Final = database.parent / f".filelock-{2**31 - 1}--{_host_digest(database)}-x" left.mkdir(mode=0o700) if shape == "empty-subdirectory": (left / "0123456789abcdef").mkdir(mode=0o700) with ReadWriteLock(database, is_singleton=False).read_lock(): pass assert _link_directories(database) == [] def test_private_link_sweep_never_parses_a_directory_of_another_account(database: Path, mocker: MockerFixture) -> None: database.touch() planted: Final = database.parent / f".filelock-{2**31 - 1}--{_host_digest(database)}-x" planted.mkdir(mode=0o700) (planted / "inner").mkdir() (planted / "inner" / "lock.db").hardlink_to(database) real_fstat: Final = os.fstat def fstat_with_other_owner(fd: int) -> os.stat_result: status = real_fstat(fd) if status.st_ino != planted.stat().st_ino: return status fields = tuple(status) return os.stat_result((*fields[:4], status.st_uid + 1, *fields[5:])) mocker.patch("os.fstat", autospec=True, side_effect=fstat_with_other_owner) mocker.patch("os.kill", autospec=True, side_effect=AssertionError("PID probed")) ReadWriteLock(database, is_singleton=False).close() assert ((planted / "inner" / "lock.db").exists(), database.stat().st_nlink) == (True, 2) def test_private_link_sweep_skips_a_symlink_under_a_dead_holder_name(database: Path) -> None: # The symlink leads to the database's own directory. database.touch() named: Final = database.parent / f".filelock-{2**31 - 1}--{_host_digest(database)}-x" named.mkdir() (named / "inner").symlink_to(database.parent) ReadWriteLock(database, is_singleton=False).close() assert (database.exists(), (named / "inner").is_symlink()) == (True, True) def test_private_link_sweep_skips_a_symlink_to_a_live_holder(database: Path, holder: subprocess.Popen[str]) -> None: (held,) = _link_directories(database) (inner,) = list(held.iterdir()) named: Final = database.parent / f".filelock-{2**31 - 1}--{_host_digest(database)}-x" named.mkdir() (named / "inner").symlink_to(inner) ReadWriteLock(database, is_singleton=False).close() assert ((inner / "lock.db").exists(), holder.poll()) == (True, None) @pytest.mark.usefixtures("holder") def test_private_link_sweep_keeps_the_link_of_a_live_holder(database: Path) -> None: held: Final = _link_directories(database) with ReadWriteLock(database, is_singleton=False).read_lock(): pass assert (len(held), _link_directories(database)) == (1, held) def test_private_link_sweep_keeps_a_directory_it_did_not_name(database: Path) -> None: foreign: Final = database.parent / ".filelock-not-ours" foreign.mkdir() with ReadWriteLock(database, is_singleton=False).read_lock(): pass assert foreign.is_dir() @pytest.mark.parametrize( ("pid_text", "start_text"), [ pytest.param("²", "1", id="non-ascii-pid"), pytest.param("\uff19" * 9, "1", id="parsed-non-ascii-pid"), pytest.param(str(2**31), "1", id="out-of-range-pid"), pytest.param("0", "1", id="zero-pid"), pytest.param("1", "²", id="non-ascii-start"), ], ) def test_private_link_sweep_survives_a_malformed_link_name( database: Path, mocker: MockerFixture, pid_text: str, start_text: str ) -> None: malformed: Final = database.parent / f".filelock-{pid_text}-{start_text}-{_host_digest(database)}-x" malformed.mkdir() mocker.patch("os.kill", autospec=True, side_effect=AssertionError("invalid PID probed")) with ReadWriteLock(database, is_singleton=False).read_lock(): assert_read_write_lock_state(str(database), "write", available=False) assert malformed.is_dir() @NEEDS_FILE_PERMISSIONS # pragma: needs file-permissions def test_private_link_in_a_directory_it_cannot_list(database: Path) -> None: database.parent.chmod(0o333) try: with ReadWriteLock(database, is_singleton=False).read_lock(): assert_read_write_lock_state(str(database), "write", available=False) finally: database.parent.chmod(0o777) _PROCESS_DEADLINE: Final = 30 @pytest.fixture def holder(database: Path) -> Generator[subprocess.Popen[str]]: script: Final = ( "import sys, time\n" "from filelock import ReadWriteLock\n" "lock = ReadWriteLock(sys.argv[1], is_singleton=False)\n" "lock.acquire_read()\n" "print('held', flush=True)\n" "time.sleep(600)\n" ) with subprocess.Popen([sys.executable, "-c", script, str(database)], stdout=subprocess.PIPE, text=True) as holder: try: assert holder.stdout is not None assert holder.stdout.readline() == "held\n" yield holder finally: holder.kill() holder.wait(timeout=_PROCESS_DEADLINE) def _link_directories(database: Path) -> list[Path]: return sorted(path for path in database.parent.iterdir() if path.name.startswith(".filelock-")) def _host_digest(database: Path) -> str: # Taken from a link of another database, so the sweep for this one has not run yet. seed: Final = database.parent.with_name("seed") seed.mkdir(mode=0o777) seed.chmod(0o777) with ReadWriteLock(seed / "lock.db", is_singleton=False).read_lock(): return next(seed.glob(".filelock-*")).name.rsplit("-", 2)[1] @pytest.fixture def database(tmp_path: Path) -> Path: # Others may create names in this directory, so SQLite connects through a private link instead of the name. shared: Final = tmp_path / "shared" shared.mkdir() shared.chmod(0o777) return shared / "lock.db" @pytest.fixture def created_directories(mocker: MockerFixture) -> list[Path]: directories: Final[list[Path]] = [] mkdtemp: Final = tempfile.mkdtemp def create_directory(*, prefix: str, dir: Path) -> str: # ruff:ignore[builtin-argument-shadowing] # mirrors tempfile.mkdtemp directory: Final = mkdtemp(prefix=prefix, dir=dir) directories.append(Path(directory)) return directory mocker.patch("tempfile.mkdtemp", autospec=True, side_effect=create_directory) return directories @pytest.fixture def loosened_directories(mocker: MockerFixture) -> list[Path]: directories: Final[list[Path]] = [] mkdtemp: Final = tempfile.mkdtemp def create_with_group_access(*, prefix: str, dir: Path) -> str: # ruff:ignore[builtin-argument-shadowing] # mirrors tempfile.mkdtemp # Stands in for an inheritable ACL entry that grants the group access to every new directory. directory: Final = Path(mkdtemp(prefix=prefix, dir=dir)) directory.chmod(0o770) directories.append(directory) return str(directory) mocker.patch("tempfile.mkdtemp", autospec=True, side_effect=create_with_group_access) return directories filelock-4.2.8/tests/test_read_write_unit.py0000644000000000000000000011003015272561277016213 0ustar00from __future__ import annotations import math import os import re import sys import threading import time from concurrent.futures import ThreadPoolExecutor from contextlib import ExitStack, closing from functools import partial from types import SimpleNamespace from typing import TYPE_CHECKING, Final, Literal import pytest from tests.capability_marks import NEEDS_GENERATOR_EXCEPTION_CONTEXT, NEEDS_PROMPT_FINALIZATION from tests.read_write_helpers import ACQUIRE_SETTINGS, INVALID_TIMEOUTS pytest.importorskip("sqlite3") import sqlite3 from pathlib import Path from filelock import SoftReadWriteLock, Timeout from filelock._read_write import ( _FORKED_DATABASES, _MAX_SQLITE_TIMEOUT_MS, ReadWriteLock, _ForkedDatabaseRegistry, _track_sqlite_use, timeout_for_sqlite, ) if TYPE_CHECKING: from collections.abc import Callable, Generator from types import FrameType from pytest_mock import MockerFixture from filelock import AcquireReturnProxy @pytest.fixture(autouse=True) def _clear_singleton_cache() -> Generator[None]: ReadWriteLock._instances.clear() yield for instance in list(ReadWriteLock._instances.valuerefs()): if (lock := instance()) is not None: # pragma: no cover # cache is normally emptied before teardown lock.close() ReadWriteLock._instances.clear() @pytest.fixture def lock_file(tmp_path: Path) -> str: return str(tmp_path / "test_lock.db") def test_timeout_for_sqlite_non_blocking() -> None: assert timeout_for_sqlite(10.0, blocking=False, already_waited=0.0) == 0 def test_timeout_for_sqlite_infinite_timeout() -> None: assert timeout_for_sqlite(-1, blocking=True, already_waited=0.0) == _MAX_SQLITE_TIMEOUT_MS def test_timeout_for_sqlite_negative_timeout_raises() -> None: with pytest.raises(ValueError, match="timeout must be a non-negative number or -1"): timeout_for_sqlite(-2, blocking=True, already_waited=0.0) def test_timeout_for_sqlite_positive_timeout_subtracts_waited() -> None: assert timeout_for_sqlite(5.0, blocking=True, already_waited=2.0) == 3000 def test_timeout_for_sqlite_waited_exceeds_timeout() -> None: assert timeout_for_sqlite(1.0, blocking=True, already_waited=2.0) == 0 def test_timeout_for_sqlite_zero_timeout() -> None: assert timeout_for_sqlite(0.0, blocking=True, already_waited=0.0) == 0 def test_timeout_for_sqlite_huge_timeout_clamped() -> None: assert timeout_for_sqlite(3_000_000.0, blocking=True, already_waited=0.0) == _MAX_SQLITE_TIMEOUT_MS def test_singleton_returns_same_instance(lock_file: str) -> None: lock1 = ReadWriteLock(lock_file) lock2 = ReadWriteLock(lock_file) assert lock1 is lock2 def test_singleton_different_files(tmp_path: Path) -> None: lock1 = ReadWriteLock(str(tmp_path / "a.db")) lock2 = ReadWriteLock(str(tmp_path / "b.db")) assert lock1 is not lock2 def test_singleton_rejects_different_timeout(lock_file: str) -> None: lock = ReadWriteLock(lock_file, timeout=1.0) with pytest.raises(ValueError, match="Singleton lock created with"): ReadWriteLock(lock_file, timeout=2.0) del lock @pytest.mark.parametrize( ("first", "second"), [pytest.param(-1, math.inf, id="inf"), pytest.param(math.nan, math.nan, id="nan")] ) def test_singleton_accepts_a_timeout_that_waits_the_same( lock_type: type[ReadWriteLock | SoftReadWriteLock], first: float, second: float, lock_file: str ) -> None: assert lock_type(lock_file, timeout=first) is lock_type(lock_file, timeout=second) @pytest.mark.parametrize("timeout", [pytest.param(True, id="bool"), pytest.param("1", id="str")]) def test_constructor_rejects_a_timeout_that_is_not_a_number( lock_type: type[ReadWriteLock | SoftReadWriteLock], timeout: bool | str, lock_file: str ) -> None: with pytest.raises(TypeError, match="timeout must be a number of seconds"): lock_type( lock_file, timeout=timeout, # ty: ignore[invalid-argument-type] # the rejected type is the contract is_singleton=False, ) def test_singleton_rejects_different_blocking(lock_file: str) -> None: lock = ReadWriteLock(lock_file, blocking=True) with pytest.raises(ValueError, match="Singleton lock created with"): ReadWriteLock(lock_file, blocking=False) del lock def test_non_singleton_creates_new_instance(lock_file: str) -> None: lock1 = ReadWriteLock(lock_file, is_singleton=False) lock2 = ReadWriteLock(lock_file, is_singleton=False) assert lock1 is not lock2 def test_init_sets_attributes(lock_file: str) -> None: lock = ReadWriteLock(lock_file, timeout=5.0, blocking=False, is_singleton=False) assert lock.lock_file == lock_file assert lock.timeout == pytest.approx(5.0) assert lock.blocking is False assert lock._lock_level == 0 assert lock._current_mode is None assert lock._write_thread_id is None assert Path(lock_file).is_file() def test_acquire_release_read(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) proxy = lock.acquire_read() assert lock._lock_level == 1 assert lock._current_mode == "read" lock.release() assert lock._lock_level == 0 assert lock._current_mode is None assert isinstance(proxy, object) def test_acquire_release_write(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_write() assert lock._lock_level == 1 assert lock._current_mode == "write" assert lock._write_thread_id == threading.get_ident() lock.release() assert lock._lock_level == 0 assert lock._current_mode is None assert lock._write_thread_id is None @pytest.mark.parametrize( "mode", [ pytest.param("read", id="read"), pytest.param("write", id="write"), ], ) def test_reentrant_lock(lock_file: str, mode: Literal["read", "write"]) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) acquire = lock.acquire_read if mode == "read" else lock.acquire_write acquire() acquire() assert lock._lock_level == 2 lock.release() assert lock._lock_level == 1 assert lock._current_mode == mode lock.release() assert lock._lock_level == 0 def test_upgrade_read_to_write_prohibited(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_read() with pytest.raises(RuntimeError, match=r"already holding a read lock.*upgrade not allowed"): lock.acquire_write() lock.release() def test_downgrade_write_to_read_prohibited(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_write() with pytest.raises(RuntimeError, match=r"already holding a write lock.*downgrade not allowed"): lock.acquire_read() lock.release() def test_release_unheld_lock_raises(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) with pytest.raises(RuntimeError, match="not held"): lock.release() def test_release_force_unheld_is_noop(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.release(force=True) def test_release_force_resets_level(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_write() lock.acquire_write() assert lock._lock_level == 2 lock.release(force=True) assert lock._lock_level == 0 assert lock._current_mode is None def test_release_force_closes_lingering_connection_at_zero_level(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_write() lock._lock_level = 0 lock.release(force=True) assert lock._con is None assert lock._lock_level == 0 @pytest.mark.parametrize( "mode", [ pytest.param("read", id="read"), pytest.param("write", id="write"), ], ) def test_lock_context_manager(lock_file: str, mode: Literal["read", "write"]) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) with lock.read_lock() if mode == "read" else lock.write_lock(): assert lock._lock_level == 1 assert lock._current_mode == mode assert lock._lock_level == 0 assert lock._current_mode is None @pytest.mark.parametrize( "mode", [ pytest.param("read", id="read"), pytest.param("write", id="write"), ], ) def test_lock_uses_instance_defaults(lock_file: str, mode: Literal["read", "write"]) -> None: lock = ReadWriteLock(lock_file, timeout=3.0, blocking=True, is_singleton=False) with lock.read_lock() if mode == "read" else lock.write_lock(): assert lock._current_mode == mode def test_read_lock_custom_timeout(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) with lock.read_lock(timeout=5.0): assert lock._current_mode == "read" def test_write_lock_custom_blocking(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) with lock.write_lock(blocking=True): assert lock._current_mode == "write" def test_read_lock_context_manager_overrides_defaults(lock_file: str) -> None: lock = ReadWriteLock(lock_file, timeout=10.0, blocking=False, is_singleton=False) with lock.read_lock(timeout=5.0, blocking=True): assert lock._current_mode == "read" def test_close_releases_lock_and_closes_connection(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_write() assert lock._lock_level == 1 lock.close() assert lock._lock_level == 0 with pytest.raises(sqlite3.ProgrammingError, match="Cannot operate on a closed database"): lock.acquire_read() def test_close_on_unheld_lock_closes_connection(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.close() with pytest.raises(sqlite3.ProgrammingError, match="Cannot operate on a closed database"): lock.acquire_read() def test_write_lock_reentrant_from_different_thread_prohibited(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_write() error: RuntimeError | None = None def try_reenter() -> None: nonlocal error try: lock.acquire_write() except RuntimeError as exc: error = exc thread = threading.Thread(target=try_reenter) thread.start() thread.join(timeout=5) lock.release() assert error is not None assert "while it is held by thread" in str(error) def test_write_lock_release_from_different_thread_prohibited(lock_file: str) -> None: with ( closing(ReadWriteLock(lock_file, is_singleton=False)) as lock, closing(ReadWriteLock(lock_file, is_singleton=False)) as contender, ThreadPoolExecutor(max_workers=1) as other_thread, ): lock.acquire_write() with pytest.raises(RuntimeError, match="while it is held by thread"): other_thread.submit(lock.release).result() with pytest.raises(Timeout): contender.acquire_write(blocking=False) def test_read_lock_release_from_a_thread_without_a_read_hold_prohibited( sync_locks: tuple[ReadWriteLock, ReadWriteLock], other_thread: ThreadPoolExecutor ) -> None: lock, contender = sync_locks lock.acquire_read() with pytest.raises(RuntimeError, match="which does not hold it"): other_thread.submit(lock.release).result() with pytest.raises(Timeout): contender.acquire_write(blocking=False) def test_read_lock_release_past_a_threads_own_depth_prohibited( sync_locks: tuple[ReadWriteLock, ReadWriteLock], other_thread: ThreadPoolExecutor ) -> None: lock, contender = sync_locks lock.acquire_read() lock.acquire_read() other_thread.submit(lock.acquire_read).result() other_thread.submit(lock.release).result() with pytest.raises(RuntimeError, match="which does not hold it"): other_thread.submit(lock.release).result() with pytest.raises(Timeout): contender.acquire_write(blocking=False) def test_read_lock_shared_by_threads_ends_with_the_last_reader( sync_locks: tuple[ReadWriteLock, ReadWriteLock], other_thread: ThreadPoolExecutor ) -> None: lock, contender = sync_locks lock.acquire_read() other_thread.submit(lock.acquire_read).result() lock.release() other_thread.submit(lock.release).result() with contender.write_lock(blocking=False): pass @pytest.mark.parametrize( ("held", "requested"), [ pytest.param(ReadWriteLock.acquire_read, ReadWriteLock.acquire_write, id="read-held"), pytest.param(ReadWriteLock.acquire_write, ReadWriteLock.acquire_read, id="write-held"), ], ) def test_opposite_mode_from_another_thread_names_the_per_thread_instance_fix( sync_locks: tuple[ReadWriteLock, ReadWriteLock], other_thread: ThreadPoolExecutor, held: Callable[[ReadWriteLock], AcquireReturnProxy], requested: Callable[[ReadWriteLock], AcquireReturnProxy], ) -> None: held(sync_locks[0]) with pytest.raises(RuntimeError, match="give each thread its own instance"): other_thread.submit(requested, sync_locks[0]).result() def test_thread_with_its_own_instance_contends_instead_of_raising( sync_locks: tuple[ReadWriteLock, ReadWriteLock], other_thread: ThreadPoolExecutor ) -> None: lock, own = sync_locks lock.acquire_read() with pytest.raises(Timeout): other_thread.submit(partial(own.acquire_write, blocking=False)).result() @pytest.fixture def other_thread() -> Generator[ThreadPoolExecutor]: with ThreadPoolExecutor(max_workers=1) as executor: yield executor @pytest.mark.parametrize( "drop", [ pytest.param(ReadWriteLock.close, id="close"), pytest.param(partial(ReadWriteLock.release, force=True), id="force-release"), ], ) def test_write_lock_drop_from_different_thread_needs_an_explicit_override( lock_file: str, drop: Callable[[ReadWriteLock], None] ) -> None: with ( closing(ReadWriteLock(lock_file, is_singleton=False)) as lock, closing(ReadWriteLock(lock_file, is_singleton=False)) as contender, ThreadPoolExecutor(max_workers=1) as other_thread, ): lock.acquire_write() other_thread.submit(drop, lock).result() with contender.write_lock(blocking=False): pass @pytest.mark.parametrize( ("first", "second"), [ pytest.param("read", "write", id="read-then-write"), pytest.param("write", "read", id="write-then-read"), ], ) def test_sequential_mode_switch(lock_file: str, first: str, second: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) with lock.read_lock() if first == "read" else lock.write_lock(): pass with lock.read_lock() if second == "read" else lock.write_lock(): pass def test_non_blocking_read_fails_when_write_held(lock_file: str) -> None: holder_lock = ReadWriteLock(lock_file, is_singleton=False) contender_lock = ReadWriteLock(lock_file, is_singleton=False) acquired = threading.Event() def hold_write() -> None: with holder_lock.write_lock(): acquired.set() threading.Event().wait(timeout=2) thread = threading.Thread(target=hold_write) thread.start() acquired.wait(timeout=5) with pytest.raises(Timeout): contender_lock.acquire_read(blocking=False) thread.join(timeout=5) def test_non_blocking_write_fails_when_read_held(lock_file: str) -> None: holder_lock = ReadWriteLock(lock_file, is_singleton=False) contender_lock = ReadWriteLock(lock_file, is_singleton=False) acquired = threading.Event() def hold_read() -> None: with holder_lock.read_lock(): acquired.set() threading.Event().wait(timeout=2) thread = threading.Thread(target=hold_read) thread.start() acquired.wait(timeout=5) with pytest.raises(Timeout): contender_lock.acquire_write(blocking=False) thread.join(timeout=5) def test_timeout_read_expires(lock_file: str) -> None: holder_lock = ReadWriteLock(lock_file, is_singleton=False) contender_lock = ReadWriteLock(lock_file, is_singleton=False) acquired = threading.Event() def hold_write() -> None: with holder_lock.write_lock(): acquired.set() threading.Event().wait(timeout=3) thread = threading.Thread(target=hold_write) thread.start() acquired.wait(timeout=5) with pytest.raises(Timeout): contender_lock.acquire_read(timeout=0.2) thread.join(timeout=5) def test_nested_read_context_managers(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) with lock.read_lock(): assert lock._lock_level == 1 with lock.read_lock(): assert lock._lock_level == 2 with lock.read_lock(): assert lock._lock_level == 3 assert lock._lock_level == 2 assert lock._lock_level == 1 assert lock._lock_level == 0 def test_nested_write_context_managers(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) with lock.write_lock(): assert lock._lock_level == 1 with lock.write_lock(): assert lock._lock_level == 2 assert lock._lock_level == 1 assert lock._lock_level == 0 @pytest.mark.parametrize( "mode", [ pytest.param("read", id="read"), pytest.param("write", id="write"), ], ) def test_non_blocking_transaction_lock_timeout(lock_file: str, mode: Literal["read", "write"]) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock._transaction_lock.acquire() try: with pytest.raises(Timeout): (lock.acquire_read if mode == "read" else lock.acquire_write)(blocking=False) finally: lock._transaction_lock.release() @pytest.mark.parametrize( "mode", [ pytest.param("read", id="read"), pytest.param("write", id="write"), ], ) def test_non_blocking_with_timeout_no_value_error(lock_file: str, mode: Literal["read", "write"]) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock._transaction_lock.acquire() try: with pytest.raises(Timeout): (lock.acquire_read if mode == "read" else lock.acquire_write)(blocking=False, timeout=5.0) finally: lock._transaction_lock.release() @pytest.mark.parametrize( "mode", [ pytest.param("read", id="read"), pytest.param("write", id="write"), ], ) def test_finite_timeout_transaction_lock(lock_file: str, mode: Literal["read", "write"]) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock._transaction_lock.acquire() try: with pytest.raises(Timeout): (lock.acquire_read if mode == "read" else lock.acquire_write)(timeout=0.1) finally: lock._transaction_lock.release() def test_double_check_read_reentrance(lock_file: str, mocker: MockerFixture) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) real_lock = lock._transaction_lock mock_lock = mocker.MagicMock() def fake_acquire(blocking: bool = True, timeout: float = -1) -> bool: lock._lock_level = 1 lock._current_mode = "read" _fake_open_transaction(lock, mocker) return real_lock.acquire(blocking, timeout) mock_lock.acquire = fake_acquire mock_lock.release = real_lock.release lock._transaction_lock = mock_lock proxy = lock.acquire_read() assert lock._lock_level == 2 assert proxy is not None lock._lock_level = 0 lock._current_mode = None lock._con, lock._connection_transaction_released = None, True lock.release(force=True) def test_double_check_write_reentrance(lock_file: str, mocker: MockerFixture) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) real_lock = lock._transaction_lock mock_lock = mocker.MagicMock() def fake_acquire(blocking: bool = True, timeout: float = -1) -> bool: lock._lock_level = 1 lock._current_mode = "write" lock._write_thread_id = threading.get_ident() _fake_open_transaction(lock, mocker) return real_lock.acquire(blocking, timeout) mock_lock.acquire = fake_acquire mock_lock.release = real_lock.release lock._transaction_lock = mock_lock proxy = lock.acquire_write() assert lock._lock_level == 2 assert proxy is not None lock._lock_level = 0 lock._current_mode = None lock._con, lock._connection_transaction_released = None, True lock._write_thread_id = None lock.release(force=True) @pytest.mark.parametrize( ("acquire_mode", "conflicting_mode", "match"), [ pytest.param("read", "write", "another thread holds a write lock", id="read-blocked-by-write"), pytest.param("write", "read", "another thread holds a read lock", id="write-blocked-by-read"), ], ) def test_double_check_wrong_mode_raises( lock_file: str, mocker: MockerFixture, acquire_mode: Literal["read", "write"], conflicting_mode: Literal["read", "write"], match: str, ) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) real_lock = lock._transaction_lock mock_lock = mocker.MagicMock() def fake_acquire(blocking: bool = True, timeout: float = -1) -> bool: lock._lock_level = 1 lock._current_mode = conflicting_mode _fake_open_transaction(lock, mocker) return real_lock.acquire(blocking, timeout) mock_lock.acquire = fake_acquire mock_lock.release = real_lock.release lock._transaction_lock = mock_lock with pytest.raises(RuntimeError, match=match): (lock.acquire_read if acquire_mode == "read" else lock.acquire_write)() lock._lock_level = 0 lock._current_mode = None lock._con, lock._connection_transaction_released = None, True def _fake_open_transaction(lock: ReadWriteLock, mocker: MockerFixture) -> None: # A level stands only while its transaction does, so a faked level needs a transaction behind it too. lock._con = mocker.MagicMock(in_transaction=True) lock._connection_transaction_released = False def test_write_lock_context_manager_overrides_defaults(lock_file: str) -> None: lock = ReadWriteLock(lock_file, timeout=10.0, blocking=False, is_singleton=False) with lock.write_lock(timeout=5.0, blocking=True): assert lock._current_mode == "write" @pytest.mark.parametrize( "mode", [ pytest.param("read", id="read"), pytest.param("write", id="write"), ], ) def test_busy_timeout_recomputed_after_journal_mode( lock_file: str, mocker: MockerFixture, mode: Literal["read", "write"] ) -> None: counter = iter([0.0, 0.0, 0.0, 0.5]) mocker.patch( "filelock._read_write.time", new=SimpleNamespace( monotonic=time.monotonic, perf_counter=mocker.create_autospec(time.perf_counter, side_effect=counter) ), ) lock = ReadWriteLock(lock_file, is_singleton=False) (lock.acquire_read if mode == "read" else lock.acquire_write)(timeout=2.0) lock.release() def test_acquire_converts_connect_lock_error_to_timeout(lock_file: str, mocker: MockerFixture) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) mocker.patch( "filelock._read_write._connect", side_effect=sqlite3.OperationalError("database is locked"), ) with pytest.raises(Timeout): lock.acquire_read(timeout=0.1) def test_get_lock_returns_the_singleton(lock_file: str) -> None: lock = ReadWriteLock.get_lock(lock_file) assert isinstance(lock, ReadWriteLock) assert lock is ReadWriteLock(lock_file) def test_note_sqlite_use_records_use_on_pypy(mocker: MockerFixture) -> None: mocker.patch("filelock._read_write._IS_PYPY", True) registry = _ForkedDatabaseRegistry() registry.note_sqlite_use() assert registry._sqlite_used is True @pytest.mark.parametrize("identity", [None, (1, 2)], ids=["no-identity", "with-identity"]) def test_poison_after_fork_records_path_and_identity(identity: tuple[int, int] | None) -> None: registry = _ForkedDatabaseRegistry() path = Path("poisoned.db") registry.poison_after_fork(path, identity) assert path in registry._paths assert (identity in registry._identities) is (identity is not None) def test_track_sqlite_use_forwards_connect_event(mocker: MockerFixture) -> None: note = mocker.patch.object(_FORKED_DATABASES, "note_sqlite_use") _track_sqlite_use("sqlite3.connect", ()) note.assert_called_once_with() def test_track_sqlite_use_ignores_other_events(mocker: MockerFixture) -> None: note = mocker.patch.object(_FORKED_DATABASES, "note_sqlite_use") _track_sqlite_use("open", ()) note.assert_not_called() def test_meta_resets_class_state_after_pid_change(lock_file: str) -> None: class _Sub(ReadWriteLock): pass _Sub._instances_pid = -1 lock = _Sub(lock_file) try: assert isinstance(lock, _Sub) assert _Sub._instances_pid == os.getpid() finally: lock.close() def test_meta_raises_when_pid_changes_during_singleton_construction(lock_file: str, mocker: MockerFixture) -> None: class _Sub(ReadWriteLock): pass pid_box = [os.getpid()] mocker.patch("filelock._read_write._GETPID", side_effect=lambda: pid_box[0]) original_init = _Sub.__init__ def _init_then_fork( self: _Sub, lock_file: str | os.PathLike[str], timeout: float = -1, *, blocking: bool = True, is_singleton: bool = True, ) -> None: original_init(self, lock_file, timeout, blocking=blocking, is_singleton=is_singleton) pid_box[0] += 1 mocker.patch.object(_Sub, "__init__", _init_then_fork) with pytest.raises(RuntimeError, match="construction cannot continue after fork"): _Sub(lock_file) @NEEDS_GENERATOR_EXCEPTION_CONTEXT def test_finish_connection_chains_rollback_then_close_error(lock_file: str, mocker: MockerFixture) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) con = mocker.MagicMock() type(con).in_transaction = mocker.PropertyMock(side_effect=[True, False]) con.rollback.side_effect = sqlite3.OperationalError("rollback boom") con.close.side_effect = sqlite3.OperationalError("close boom") lock._con = con lock._connection_transaction_released = False with pytest.raises(sqlite3.OperationalError, match="close boom") as excinfo: lock._finish_connection() assert isinstance(excinfo.value.__context__, sqlite3.OperationalError) assert "rollback boom" in str(excinfo.value.__context__) con.rollback.assert_called_once_with() con.close.assert_called_once_with() def test_finish_connection_skips_rollback_when_already_released(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_write() con = lock._con assert con is not None lock._connection_transaction_released = True lock._finish_connection() assert lock._con is None with pytest.raises(sqlite3.ProgrammingError, match="closed database"): con.execute("SELECT 1") def test_read_lock_avoids_sqlite_schema_alias(lock_file: str, mocker: MockerFixture) -> None: # SQLite before 3.33.0 has no sqlite_schema alias, so a read lock issuing it fails with "no such table". real_connect = sqlite3.connect statements: list[str] = [] def connect( database: str, *, check_same_thread: bool, factory: type[sqlite3.Connection], cached_statements: int, timeout: float, ) -> sqlite3.Connection: connection = real_connect( database, check_same_thread=check_same_thread, factory=factory, cached_statements=cached_statements, timeout=timeout, ) connection.set_trace_callback(statements.append) return connection mocker.patch("sqlite3.connect", side_effect=connect) with ReadWriteLock(lock_file, is_singleton=False).read_lock(): pass assert statements assert not [statement for statement in statements if "sqlite_schema" in statement] @NEEDS_PROMPT_FINALIZATION def test_dropping_a_lock_closes_its_connection(lock_file: str) -> None: # Nothing else closes a lock dropped while still holding one, so the connection would outlive it. lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_read() del lock with ReadWriteLock(lock_file, is_singleton=False).write_lock(blocking=False): pass @NEEDS_PROMPT_FINALIZATION @pytest.mark.parametrize("mode", ["read", "write"]) def test_singleton_dropped_while_held_is_not_handed_out_again(lock_file: str, mode: Literal["read", "write"]) -> None: lock = ReadWriteLock(lock_file) (lock.acquire_read if mode == "read" else lock.acquire_write)() looked_up: Final[list[ReadWriteLock]] = [] # The cache lookup another thread makes while the dropped lock's connection closes. Interpreters run a profile # hook with tracing off, so coverage does not record its body. def look_up_while_closing(_frame: FrameType, event: str, arg: object) -> None: # pragma: no cover closing = event == "c_call" and getattr(arg, "__name__", None) == "close" if closing and isinstance(getattr(arg, "__self__", None), sqlite3.Connection) and not looked_up: looked_up.append(ReadWriteLock(lock_file)) sys.setprofile(look_up_while_closing) try: del lock finally: sys.setprofile(None) with (looked_up[0].read_lock if mode == "read" else looked_up[0].write_lock)(), pytest.raises(Timeout): ReadWriteLock(lock_file, is_singleton=False).acquire_write(blocking=False) def test_connection_close_from_another_process_leaves_it_open(lock_file: str, mocker: MockerFixture) -> None: # A child inherits the parent's connection, and closing it there would take the parent's down with it. lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_read() connection = lock._con assert connection is not None mocker.patch("filelock._read_write._GETPID", return_value=-1) connection.close() mocker.stopall() assert connection.execute("select 1").fetchone() == (1,) lock.release() @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @ACQUIRE_SETTINGS def test_acquire_respects_settings( sync_locks: tuple[ReadWriteLock, ReadWriteLock], mode: Literal["read", "write"], instance_timeout: float, call_timeout: float | None, minimum: float, *, instance_blocking: bool, call_blocking: bool | None, omitted: bool, ) -> None: contender: Final[ReadWriteLock] = sync_locks[1] contender.timeout = instance_timeout contender.blocking = instance_blocking acquire: Final = contender.acquire_read if mode == "read" else contender.acquire_write with sync_locks[0].write_lock(): started: Final[float] = time.perf_counter() with pytest.raises(Timeout): acquire() if omitted else acquire(timeout=call_timeout, blocking=call_blocking) assert minimum <= time.perf_counter() - started < 5 def test_contended_acquire_beside_another_thread_times_out_on_time( sync_locks: tuple[ReadWriteLock, ReadWriteLock], ) -> None: holder, contender = sync_locks done: Final = threading.Event() # With another thread alive the wait runs in short attempts, which together still honor the timeout. other: Final = threading.Thread(target=done.wait) other.start() try: started = time.perf_counter() with holder.write_lock(), pytest.raises(Timeout): contender.acquire_write(timeout=0.2) # SQLite counts its busy timeout in whole milliseconds, so the last attempt can end a little early. assert 0.15 <= time.perf_counter() - started < 5 finally: done.set() other.join() def test_unlimited_acquire_beside_another_thread_waits_for_the_holder( sync_locks: tuple[ReadWriteLock, ReadWriteLock], ) -> None: holder, contender = sync_locks holder.acquire_write() with ThreadPoolExecutor(max_workers=1) as executor: acquired: Final = executor.submit(contender.acquire_read, -1) with pytest.raises(TimeoutError): acquired.result(timeout=0.2) holder.release() acquired.result(timeout=5) executor.submit(contender.release).result(timeout=5) @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) def test_acquire_default_excludes_contender( sync_locks: tuple[ReadWriteLock, ReadWriteLock], mode: Literal["read", "write"] ) -> None: holder: Final[ReadWriteLock] = sync_locks[0] contender: Final[ReadWriteLock] = sync_locks[1] with holder.acquire_read() if mode == "read" else holder.acquire_write(), pytest.raises(Timeout): contender.acquire_write(blocking=False) with contender.acquire_write(), pytest.raises(Timeout): holder.acquire_write(blocking=False) @pytest.fixture def sync_locks(tmp_path: Path) -> Generator[tuple[ReadWriteLock, ReadWriteLock]]: with ( closing(ReadWriteLock(tmp_path / "a", timeout=30, is_singleton=False)) as holder, closing(ReadWriteLock(tmp_path / "a", timeout=30, is_singleton=False)) as contender, ): yield holder, contender @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @INVALID_TIMEOUTS @pytest.mark.parametrize("reentrant", [pytest.param(True, id="reentrant"), pytest.param(False, id="first")]) def test_acquire_rejects_invalid_timeout( timeout_locks: tuple[ReadWriteLock | SoftReadWriteLock, ReadWriteLock | SoftReadWriteLock], mode: Literal["read", "write"], timeout: float, message: str, *, reentrant: bool, ) -> None: holder: Final = timeout_locks[0] acquire: Final = holder.acquire_read if mode == "read" else holder.acquire_write with ExitStack() as stack: if reentrant: stack.enter_context(acquire()) with pytest.raises(ValueError, match=f"^{re.escape(message)}$"): stack.enter_context(acquire(timeout=timeout)) with timeout_locks[1].acquire_write(blocking=False), pytest.raises(Timeout): holder.acquire_write(blocking=False) @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.parametrize( ("timeout", "blocking"), [ pytest.param(-2, False, id="nonblocking-integer"), pytest.param(-0.5, False, id="nonblocking-fraction"), pytest.param(float("-inf"), False, id="nonblocking-negative-infinite"), pytest.param(float("inf"), False, id="nonblocking-infinite"), pytest.param(float("nan"), False, id="nonblocking-nan"), pytest.param(-1, True, id="unlimited"), pytest.param(float("inf"), True, id="infinite"), pytest.param(threading.TIMEOUT_MAX * 2, True, id="above-lock-limit"), pytest.param(0, True, id="immediate"), ], ) def test_acquire_valid_timeout_preserves_nested_hold( timeout_locks: tuple[ReadWriteLock | SoftReadWriteLock, ReadWriteLock | SoftReadWriteLock], mode: Literal["read", "write"], timeout: float, *, blocking: bool, ) -> None: holder: Final = timeout_locks[0] contender: Final = timeout_locks[1] acquire: Final = holder.acquire_read if mode == "read" else holder.acquire_write with acquire(timeout=timeout, blocking=blocking): with acquire(timeout=timeout, blocking=blocking), pytest.raises(Timeout): contender.acquire_write(blocking=False) with pytest.raises(Timeout): contender.acquire_write(blocking=False) with contender.acquire_write(blocking=False), pytest.raises(Timeout): holder.acquire_write(blocking=False) @pytest.fixture def timeout_locks( tmp_path: Path, lock_type: type[ReadWriteLock | SoftReadWriteLock] ) -> Generator[tuple[ReadWriteLock | SoftReadWriteLock, ReadWriteLock | SoftReadWriteLock]]: with ( closing(lock_type(tmp_path / "a", is_singleton=False)) as holder, closing(lock_type(tmp_path / "a", is_singleton=False)) as contender, ): yield holder, contender @pytest.fixture( params=[ pytest.param(ReadWriteLock, id="sqlite"), pytest.param(SoftReadWriteLock, id="soft", marks=pytest.mark.requires_hard_links), ] ) def lock_type(request: pytest.FixtureRequest) -> type[ReadWriteLock | SoftReadWriteLock]: return request.param filelock-4.2.8/tests/test_soft_lease.py0000644000000000000000000011661315272561277015170 0ustar00from __future__ import annotations import gc import itertools import os import socket import subprocess # ruff:ignore[suspicious-subprocess-import] # interpreter exit finalizes a held lease import sys import threading import time from contextlib import suppress from decimal import Decimal from errno import EIO, ENOENT from threading import Event, Thread from types import FrameType, SimpleNamespace from typing import TYPE_CHECKING, Final, Literal, NoReturn, TypedDict, cast import pytest from filelock import ( AsyncSoftFileLease, FileLock, LeaseCompromise, LeaseSettingsMismatch, SoftFileLease, StrictSoftFileLock, Timeout, ) from filelock._identity import host_name from filelock._soft import MALFORMED_LOCK_AGE_THRESHOLD from filelock._util import exchange_names from tests.capability_marks import ( NEEDS_COLLECTED_FINALIZATION, NEEDS_FORK, NEEDS_PROMPT_FINALIZATION, NEEDS_STALE_RECLAIM, NEEDS_UNLINK_OPEN_FILE, ) from tests.conftest import held_after_reclaim from tests.fork_helpers import exit_child, fork_process if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path from pytest_mock import MockerFixture #: Short enough to keep the suite quick, long enough that a loaded runner still refreshes twice before expiry. _DURATION: float = 0.9 _HEARTBEAT: float = 0.1 #: Taking a claim from a live holder means removing its marker while the holder still has it open. Where that is #: refused, a lease only reclaims once the holder exits and its handle closes. @pytest.fixture def marker(tmp_path: Path) -> Path: return tmp_path / "a.lock" def _lease( marker: Path, *, lease_duration: float = _DURATION, timeout: float = 0.3, on_compromise: Callable[[LeaseCompromise], None] | None = None, ) -> SoftFileLease: return SoftFileLease( str(marker), timeout=timeout, lease_duration=lease_duration, heartbeat_interval=_HEARTBEAT, on_compromise=on_compromise, ) def test_lease_publishes_its_claim(marker: Path) -> None: lease = _lease(marker) with lease: owner = lease.owner assert owner is not None assert (owner.pid, owner.hostname, owner.mode, owner.lease_duration) == ( os.getpid(), host_name(), "lease", _DURATION, ) assert owner.token == lease.token def test_lease_token_names_the_claim_only_while_held(marker: Path) -> None: lease = _lease(marker) with lease: held = lease.token assert held is not None assert lease.token is None def test_lease_token_names_no_claim_after_a_contended_acquire(marker: Path) -> None: contender = _lease(marker, timeout=0) with _lease(marker): with pytest.raises(Timeout): contender.acquire() assert contender.token is None _FORK_WARNING: Final[pytest.MarkDecorator] = pytest.mark.filterwarnings( "ignore:.*multi-threaded, use of fork.*:DeprecationWarning" ) @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.parametrize("thread_local", [True, False], ids=["thread-local", "shared"]) def test_lease_claim_does_not_survive_fork(marker: Path, *, thread_local: bool) -> None: # fork copies the parent's claim, token, compromise and heartbeat included, into a child that holds nothing. lease = SoftFileLease( str(marker), lease_duration=_DURATION, heartbeat_interval=_HEARTBEAT, thread_local=thread_local, is_singleton=False, ) lease.acquire() assert lease.token is not None def child() -> NoReturn: unheld = lease.token is None and not lease.is_locked and lease.compromise is None exit_child(0 if unheld else 1) child_pid = fork_process(child) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 assert lease.token is not None lease.release() def test_lease_token_names_no_claim_after_an_acquire_that_raises(tmp_path: Path) -> None: # The one path no rollback covers: a raise before the acquire holds a descriptor. (blocker := tmp_path / "blocker").touch() lease = _lease(blocker / "resource.lock") with pytest.raises(FileExistsError): lease.acquire() assert lease.token is None def test_lease_heartbeat_keeps_a_live_claim_past_its_duration(marker: Path) -> None: holder = _lease(marker) with holder: time.sleep(_DURATION * 1.5) # only a refreshing heartbeat keeps the claim past this with pytest.raises(Timeout): _lease(marker).acquire() def test_lease_heartbeat_uses_acquisition_directory(working_directories: tuple[Path, Path]) -> None: first, second = working_directories compromised = Event() lease = SoftFileLease( "resource.lock", lease_duration=0.9, heartbeat_interval=0.05, on_compromise=lambda _: compromised.set() ) with lease: os.chdir(second) os.utime(first / "resource.lock", (0, 0)) assert not compromised.wait(timeout=0.3) assert (first / "resource.lock").stat().st_mtime > 0 @pytest.mark.skipif( sys.platform == "win32", reason="Windows refuses to rename a directory holding an open file" ) # pragma: win32 no cover def test_lease_heartbeat_survives_directory_rename( working_directories: tuple[Path, Path], ) -> None: first, _ = working_directories renamed = first.parent / "renamed" compromised = Event() lease = SoftFileLease( "resource.lock", lease_duration=0.9, heartbeat_interval=0.05, on_compromise=lambda _: compromised.set() ) with lease: first.rename(renamed) os.utime(renamed / "resource.lock", (0, 0)) assert not compromised.wait(timeout=0.3) assert (renamed / "resource.lock").stat().st_mtime > 0 @NEEDS_UNLINK_OPEN_FILE # pragma: needs unlink-open-file @NEEDS_STALE_RECLAIM def test_lease_peer_takes_an_expired_claim(marker: Path, mocker: MockerFixture) -> None: # A wedged holder: its marker stays on disk, but no refresh ever lands on it again, so the claim ages out. mocker.patch("filelock._lease.touch") holder = _lease(marker) holder.acquire() try: peer = _lease(marker, timeout=_DURATION * 5) with peer: assert peer.is_lock_held_by_us assert peer.token != holder.token finally: holder.release() @NEEDS_STALE_RECLAIM def test_lease_self_heals_a_malformed_marker(marker: Path, mocker: MockerFixture) -> None: # The pytest timeout bounds recovery while the mocked clock controls marker expiry. clock: Final = mocker.patch("filelock._soft.time", autospec=True) clock.monotonic.side_effect = itertools.count(step=MALFORMED_LOCK_AGE_THRESHOLD) marker.write_text("not a protocol 2 record\n", encoding="utf-8") with _lease(marker, timeout=-1) as lease: assert lease.is_lock_held_by_us @pytest.mark.parametrize( "contract", [ pytest.param(f"mode=lease\ntoken=abc\nduration={_DURATION!r}\n", id="lease"), pytest.param(f"mode=lease\ntoken=abc\nduration={_DURATION * 3!r}\n", id="lease-other-duration"), pytest.param("mode=exclusive\n", id="exclusive"), ], ) @NEEDS_STALE_RECLAIM def test_lease_reclaims_a_dead_same_host_holder(marker: Path, contract: str) -> None: marker.write_text(f"filelock/2\npid=999999\nhost={host_name()}\n{contract}", encoding="utf-8") with held_after_reclaim(_lease(marker)) as lease: assert lease.is_lock_held_by_us @NEEDS_UNLINK_OPEN_FILE def test_lease_reports_compromise_when_the_marker_vanishes(marker: Path) -> None: # pragma: needs unlink-open-file seen: list[LeaseCompromise] = [] lease = _lease(marker, on_compromise=seen.append) with lease: token = lease.token marker.unlink() _wait_until(lambda: bool(seen)) assert [(c.reason, c.token, c.lock_file) for c in seen] == [("marker-missing", token, str(marker))] @NEEDS_UNLINK_OPEN_FILE def test_lease_reports_compromise_when_a_peer_takes_over(marker: Path) -> None: # pragma: needs unlink-open-file seen: list[LeaseCompromise] = [] holder = _lease(marker, on_compromise=seen.append) peer = _lease(marker) try: with holder: marker.unlink() peer.acquire() # a peer publishes a fresh marker at the same path _wait_until(lambda: bool(seen)) assert [c.reason for c in seen] == ["owner-changed"] finally: peer.release() # stop the peer's heartbeat here, so its release log never lands in a later test's caplog def test_lease_reports_compromise_when_a_refresh_fails(marker: Path, mocker: MockerFixture) -> None: seen: list[LeaseCompromise] = [] failure = OSError("cannot touch the marker") mocker.patch("filelock._lease.touch", side_effect=failure) lease = _lease(marker, on_compromise=seen.append) with lease: # A starved heartbeat can miss one refresh margin; the failure is deduplicated, so it still reports once. _wait_until(lambda: bool(seen)) assert [(c.reason, c.error) for c in seen] == [("refresh-failed", failure)] @pytest.mark.parametrize("target", [pytest.param("touch", id="touch"), pytest.param("os.lstat", id="lstat")]) def test_lease_tolerates_a_transient_refresh_error(marker: Path, mocker: MockerFixture, target: str) -> None: # A transient ESTALE/EIO on the refresh path that recovers before the lease could lapse must not raise a # compromise: the marker was ours last tick, so retry rather than tell the holder to abandon its work. import filelock._lease as lease_mod ticks = itertools.count() real = cast("Callable[..., object]", lease_mod.touch if target == "touch" else os.lstat) def flaky(path: str, *args: object, **kwargs: object) -> object: if path.endswith(marker.name) and next(ticks) < 2: raise OSError(EIO, "Input/output error") return real(path, *args, **kwargs) mocker.patch(f"filelock._lease.{target}", side_effect=flaky) seen: list[LeaseCompromise] = [] # A long lease keeps a slow runner's two failed ticks inside the window; the deadline has its own test above. lease: Final = _lease(marker, lease_duration=30, on_compromise=seen.append) with lease: time.sleep(_HEARTBEAT * 6) # several ticks: the first two fail, the rest recover assert seen == [] assert lease.compromise is None @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @NEEDS_STALE_RECLAIM def test_lease_heartbeat_tolerates_one_refresh_under_a_peers_exchange( # pragma: win32 no cover marker: Path, mocker: MockerFixture ) -> None: seen: list[LeaseCompromise] = [] lease: Final = SoftFileLease(marker, lease_duration=2, heartbeat_interval=0.05, on_compromise=seen.append) refreshed: Final = Event() lstat: Final = os.lstat def spy(path: str | os.PathLike[str], *, dir_fd: int | None = None) -> os.stat_result: found: Final = lstat(path, dir_fd=dir_fd) os.fspath(path) == str(marker) and refreshed.set() return found with lease: (peer := marker.with_name("peer")).write_text("peer", encoding="utf-8") exchange_names(str(marker), str(peer)) mocker.patch.object(os, "lstat", new=spy) assert refreshed.wait(5) exchange_names(str(marker), str(peer)) mocker.stopall() time.sleep(0.15) assert (lease.compromise, seen, lease.is_locked) == (None, [], True) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @NEEDS_STALE_RECLAIM def test_lease_heartbeat_reports_an_owner_changed_that_persists(marker: Path) -> None: # pragma: win32 no cover seen: list[LeaseCompromise] = [] lease: Final = SoftFileLease(marker, lease_duration=2, heartbeat_interval=0.05, on_compromise=seen.append) with lease: (peer := marker.with_name("peer")).write_text("peer", encoding="utf-8") exchange_names(str(marker), str(peer)) _wait_until(lambda: bool(seen)) time.sleep(0.2) exchange_names(str(marker), str(peer)) assert [compromise.reason for compromise in seen] == ["owner-changed"] @NEEDS_UNLINK_OPEN_FILE def test_lease_reports_one_compromise_per_claim(marker: Path) -> None: # pragma: needs unlink-open-file seen: list[LeaseCompromise] = [] lease = _lease(marker, on_compromise=seen.append) with lease: marker.unlink() _wait_until(lambda: bool(seen)) time.sleep(_HEARTBEAT * 4) # later refreshes fail too, but the holder is told once assert len(seen) == 1 @NEEDS_UNLINK_OPEN_FILE def test_lease_records_the_compromise_without_a_callback(marker: Path) -> None: # pragma: needs unlink-open-file lease = _lease(marker) with lease: marker.unlink() _wait_until(lambda: lease.compromise is not None) compromise = lease.compromise assert compromise is not None assert compromise.reason == "marker-missing" def test_lease_holds_an_uncompromised_claim(marker: Path) -> None: lease = _lease(marker) with lease: time.sleep(_HEARTBEAT * 3) assert lease.compromise is None @NEEDS_UNLINK_OPEN_FILE def test_lease_can_be_released_from_the_compromise_callback(marker: Path) -> None: # pragma: needs unlink-open-file # The callback runs on the heartbeat thread, so releasing from it needs a context that thread can see, and it must # not deadlock joining itself. holder: list[SoftFileLease] = [] def release_the_claim(_: LeaseCompromise) -> None: holder[0].release() lease = SoftFileLease( str(marker), thread_local=False, lease_duration=_DURATION, heartbeat_interval=_HEARTBEAT, on_compromise=release_the_claim, ) holder.append(lease) lease.acquire() marker.unlink() _wait_until(lambda: not lease.is_locked) assert lease.compromise is not None assert not lease.is_locked @NEEDS_UNLINK_OPEN_FILE # pragma: needs unlink-open-file def test_lease_release_from_the_callback_needs_a_shared_context(marker: Path) -> None: # With the default thread-local context the heartbeat thread sees no claim of its own, so its release() does # nothing. Pin the trap the docstring warns about. holder: list[SoftFileLease] = [] def release_the_claim(_: LeaseCompromise) -> None: holder[0].release() lease = _lease(marker, on_compromise=release_the_claim) holder.append(lease) lease.acquire() try: marker.unlink() _wait_until(lambda: lease.compromise is not None) assert lease.is_locked, "a thread-local release() from the heartbeat thread silently did nothing" finally: lease.release() def test_lease_records_a_compromise_without_a_callback_deterministically(marker: Path, mocker: MockerFixture) -> None: # With no callback the heartbeat still records the loss on the lease so a holder can poll .compromise. A refresh # that keeps failing drives the loss on every platform without depending on a peer taking the marker. mocker.patch("filelock._lease.touch", side_effect=OSError("cannot touch the marker")) lease = _lease(marker) with lease: _wait_until(lambda: lease.compromise is not None) assert lease.compromise is not None assert lease.compromise.reason == "refresh-failed" def test_lease_release_from_the_callback_does_not_join_itself(marker: Path, mocker: MockerFixture) -> None: # The callback runs on the heartbeat thread, so releasing there must skip joining that thread onto itself. A shared # context lets the thread see the claim, and a failing refresh drives the loss deterministically on every platform. mocker.patch("filelock._lease.touch", side_effect=OSError("cannot touch the marker")) holder: list[SoftFileLease] = [] def release_the_claim(_: LeaseCompromise) -> None: holder[0].release() lease = SoftFileLease( str(marker), thread_local=False, lease_duration=_DURATION, heartbeat_interval=_HEARTBEAT, on_compromise=release_the_claim, ) holder.append(lease) lease.acquire() _wait_until(lambda: not lease.is_locked) assert lease.compromise is not None assert not lease.is_locked def test_lease_records_a_float_subclass_duration_as_a_plain_float(marker: Path) -> None: with SoftFileLease(str(marker), lease_duration=_Seconds(_DURATION), heartbeat_interval=_HEARTBEAT): assert f"duration={_DURATION!r}\n" in marker.read_text(encoding="ascii") class _Seconds(float): # NumPy 2's float64 spells its repr this way; a marker that records it is one no parser reads. def __repr__(self) -> str: # pragma: no cover # only a lease that records the subclass itself calls it return f"_Seconds({float(self)!r})" def test_lease_singleton_accepts_the_same_decimal_heartbeat_again(marker: Path) -> None: # 4.0.12 took any real number; the annotation stays float. heartbeat: Final = cast("float", Decimal("0.1")) lease: Final = SoftFileLease(str(marker), is_singleton=True, lease_duration=_DURATION, heartbeat_interval=heartbeat) assert ( SoftFileLease(str(marker), is_singleton=True, lease_duration=_DURATION, heartbeat_interval=heartbeat) is lease ) def test_lease_keeps_its_claim_when_another_thread_fails_to_acquire(marker: Path) -> None: # The context is thread-local, so a second thread contending on the same lease object must leave the holder's # claim alone: a torn-down heartbeat stops refreshing the marker and a peer takes it while the holder still holds. holder = _lease(marker) def contend() -> None: with suppress(Timeout): holder.acquire() with holder: token = holder.token contender = Thread(target=contend) contender.start() contender.join() assert holder.token == token time.sleep(_DURATION * 1.5) # only a surviving heartbeat keeps the claim past this with pytest.raises(Timeout): _lease(marker).acquire() def test_lease_shared_across_threads_has_one_claim(marker: Path) -> None: lease: Final = SoftFileLease( str(marker), thread_local=False, lease_duration=_DURATION, heartbeat_interval=_HEARTBEAT ) inside, resume = Event(), Event() # Interpreters run a profile hook with tracing off, so coverage never records its body. def pause_in_claim_creation(frame: FrameType, event: str, _arg: object) -> None: # pragma: no cover if event == "call" and type(frame.f_locals.get("self")).__name__ == "_LeaseClaim": inside.set() resume.wait() def read_compromise() -> None: sys.setprofile(pause_in_claim_creation) try: assert lease.compromise is None finally: sys.setprofile(None) reader: Final = Thread(target=read_compromise) reader.start() # A claim built on first use would park the reader here, and acquire would then build a second one. inside.wait(0.5) lease.acquire() resume.set() reader.join() try: assert lease.token is not None finally: lease.release() def test_lease_force_released_for_an_exited_thread_stops_that_holds_heartbeat(marker: Path) -> None: # A heartbeat left running would find its marker gone and report a compromise nobody suffered. reports: Final[list[LeaseCompromise]] = [] lease: Final = _lease(marker, on_compromise=reports.append) holder: Final = Thread(target=lease.acquire) holder.start() holder.join() lease.release(force=True) time.sleep(_HEARTBEAT * 3) assert (reports, marker.exists()) == ([], False) def test_lease_hands_back_the_claim_when_the_heartbeat_cannot_start(marker: Path, mocker: MockerFixture) -> None: # A heartbeat thread the OS refuses (an rlimit reached) must not leave the claim behind: a peer would evict the # unrefreshed marker and acquire while this instance still believed it held the lease, and the acquire rollback # would raise joining a thread that never started. mocker.patch.object(Thread, "start", side_effect=RuntimeError("can't start new thread")) lease = _lease(marker) with pytest.raises(RuntimeError, match="can't start new thread"): lease.acquire() assert not lease.is_locked assert not marker.exists() lease.release(force=True) # nothing was left to release, so this must not raise def test_lease_release_stops_a_heartbeat_recorded_before_its_thread_starts(marker: Path, mocker: MockerFixture) -> None: # A shared, non-thread-local claim lets a release() run while an acquire has recorded its heartbeat but not yet # started the thread. Stubbing start() as a no-op freezes that window: release() must stop the heartbeat without # raising on a join of the unstarted thread, and must still unlink the marker so a peer cannot take a claim we # let go. mocker.patch.object(Thread, "start") lease = SoftFileLease( str(marker), timeout=0.3, lease_duration=_DURATION, heartbeat_interval=_HEARTBEAT, thread_local=False ) lease.acquire() assert lease.is_locked lease.release() # must not raise joining a thread that never started assert not lease.is_locked assert not marker.exists() def test_lease_rejects_a_peer_configured_with_another_duration(marker: Path) -> None: holder = _lease(marker) with holder, pytest.raises(LeaseSettingsMismatch, match="must agree on lease_duration"): _lease(marker, lease_duration=_DURATION * 3).acquire() class _LeaseSettings(TypedDict, total=False): lease_duration: float heartbeat_interval: float on_compromise: Callable[[LeaseCompromise], None] _LEASE_TYPES: Final = [pytest.param(SoftFileLease, id="sync"), pytest.param(AsyncSoftFileLease, id="async")] @pytest.fixture def lease_settings() -> _LeaseSettings: return {"lease_duration": 6, "heartbeat_interval": 1, "on_compromise": lambda _compromise: None} @pytest.mark.parametrize("lease_type", _LEASE_TYPES) def test_lease_singleton_reuses_the_instance_for_the_same_settings( marker: Path, lease_type: type[SoftFileLease], lease_settings: _LeaseSettings ) -> None: lease = lease_type(str(marker), is_singleton=True, **lease_settings) assert lease_type(str(marker), is_singleton=True, **lease_settings) is lease @pytest.mark.parametrize("lease_type", _LEASE_TYPES) def test_lease_singleton_resolves_an_omitted_heartbeat_before_comparing( marker: Path, lease_type: type[SoftFileLease] ) -> None: lease = lease_type(str(marker), is_singleton=True, lease_duration=6) assert lease_type(str(marker), is_singleton=True, lease_duration=6, heartbeat_interval=2) is lease @pytest.mark.parametrize( "setting", [pytest.param(name, id=name) for name in ("lease_duration", "heartbeat_interval", "on_compromise")] ) @pytest.mark.parametrize("lease_type", _LEASE_TYPES) def test_lease_singleton_rejects_reuse_with_another_setting( marker: Path, lease_type: type[SoftFileLease], lease_settings: _LeaseSettings, setting: Literal["lease_duration", "heartbeat_interval", "on_compromise"], ) -> None: _held = lease_type(str(marker), is_singleton=True, **lease_settings) # the cache only keeps it weakly del lease_settings[setting] with pytest.raises(ValueError, match=rf"\n\t{setting} \(existing lock has "): lease_type(str(marker), is_singleton=True, **lease_settings) @pytest.mark.requires_hard_links def test_lease_does_not_expire_a_strict_holder(marker: Path) -> None: # pragma: needs hard-link # A strict holder never agreed to be superseded by age, so a lease contender waits it out instead. with StrictSoftFileLock(str(marker)): with pytest.raises(Timeout): _lease(marker, timeout=_DURATION * 2).acquire() assert marker.exists() @pytest.mark.parametrize( ("lease_duration", "heartbeat_interval", "error", "message"), [ pytest.param(0, None, ValueError, "lease_duration must be positive", id="zero-duration"), pytest.param(-1, None, ValueError, "lease_duration must be positive", id="negative-duration"), pytest.param(float("nan"), _HEARTBEAT, ValueError, "positive and finite", id="nan-duration"), pytest.param(float("inf"), _HEARTBEAT, ValueError, "positive and finite", id="infinite-duration"), pytest.param(float("-inf"), _HEARTBEAT, ValueError, "positive and finite", id="negative-infinite-duration"), pytest.param(True, None, TypeError, "number of seconds, not bool", id="true-duration"), pytest.param(False, None, TypeError, "number of seconds, not bool", id="false-duration"), pytest.param(_DURATION, 0, ValueError, "heartbeat_interval must be positive", id="zero-heartbeat"), pytest.param(_DURATION, _DURATION, ValueError, "below lease_duration", id="heartbeat-equals-duration"), pytest.param(_DURATION, _DURATION * 2, ValueError, "below lease_duration", id="heartbeat-over-duration"), pytest.param( threading.TIMEOUT_MAX * 4, threading.TIMEOUT_MAX * 2, ValueError, "TIMEOUT_MAX", id="heartbeat-over-timeout-max", ), pytest.param( threading.TIMEOUT_MAX * 4, None, ValueError, "TIMEOUT_MAX", id="default-heartbeat-over-timeout-max" ), ], ) def test_lease_rejects_incoherent_settings( marker: Path, lease_duration: float, heartbeat_interval: float | None, error: type[Exception], message: str, ) -> None: with pytest.raises(error, match=message): SoftFileLease(str(marker), lease_duration=lease_duration, heartbeat_interval=heartbeat_interval) def test_lease_defaults_the_heartbeat_below_the_duration(marker: Path) -> None: lease = SoftFileLease(str(marker), lease_duration=30) assert lease.lease_duration == 30 def test_lease_drops_lifetime_with_a_warning(marker: Path) -> None: with pytest.warns(UserWarning, match="lease_duration sets when a lease expires"): lease = SoftFileLease(str(marker), lease_duration=_DURATION, lifetime=5) assert lease.lifetime is None @NEEDS_STALE_RECLAIM def test_lease_supersedes_a_live_holder_once_its_claim_ages_out(marker: Path, mocker: MockerFixture) -> None: # A live holder whose refresh stalled keeps its marker, yet a contender takes it once it has watched the marker go # lease_duration without a refresh. Windows cannot delete a live holder's open marker, so drive the age branch with # a non-stale owner and a clock that steps a full duration per look rather than a real second process. mocker.patch("filelock._lease.owner_is_stale", return_value=False) mocker.patch( "filelock._soft.time", new=SimpleNamespace( monotonic=mocker.create_autospec(time.monotonic, side_effect=itertools.count(step=_DURATION)), sleep=time.sleep, ), ) marker.write_text( f"filelock/2\npid={os.getpid()}\nhost={host_name()}\nmode=lease\ntoken=stalled\nduration={_DURATION!r}\n", encoding="utf-8", ) # The stepped clock ages the marker out on the second look; the real timeout only has to outlast two polls. with _lease(marker, timeout=30) as contender: assert contender.is_lock_held_by_us assert contender.token not in {None, "stalled"} def test_lease_reports_marker_missing_when_a_refresh_cannot_stat_it(marker: Path, mocker: MockerFixture) -> None: # A vanished marker surfaces as FileNotFoundError from the refresh stat. Windows keeps an open marker undeletable, # so inject the missing stat at the agnostic os.lstat call rather than unlinking a held file. seen: list[LeaseCompromise] = [] lease = _lease(marker, on_compromise=seen.append) lease.acquire() token = lease.token real_lstat: Final = os.lstat real_fstat: Final = os.fstat missing = False def lstat(path: str) -> os.stat_result: if missing and path.endswith(marker.name): raise FileNotFoundError(ENOENT, "No such file or directory", marker.name) return real_lstat(path) def fstat(fd: int) -> os.stat_result | SimpleNamespace: return SimpleNamespace(st_nlink=0) if missing else real_fstat(fd) mocker.patch("filelock._lease.os.lstat", autospec=True, side_effect=lstat) mocker.patch("filelock._lease.os.fstat", autospec=True, side_effect=fstat) try: missing = True # The first miss may be a peer's break in flight, so the report comes a refresh later. _wait_until(lambda: bool(seen)) finally: missing = False lease.release() assert [(c.reason, c.token, c.lock_file) for c in seen] == [("marker-missing", token, str(marker))] def test_lease_reports_refresh_failed_when_a_missing_marker_cannot_be_inspected( marker: Path, mocker: MockerFixture ) -> None: seen: list[LeaseCompromise] = [] lease = _lease(marker, on_compromise=seen.append) lease.acquire() real_lstat: Final = os.lstat real_fstat: Final = os.fstat missing = False name_lost = False def lstat(path: str) -> os.stat_result: nonlocal name_lost if missing and path.endswith(marker.name): name_lost = True raise FileNotFoundError(ENOENT, "No such file or directory", marker.name) return real_lstat(path) def fstat(fd: int) -> os.stat_result: # Fails only once the name is gone, so no other call in the refresh can report first. if name_lost: raise OSError(EIO, "Input/output error") return real_fstat(fd) mocker.patch("filelock._lease.os.lstat", autospec=True, side_effect=lstat) mocker.patch("filelock._lease.os.fstat", autospec=True, side_effect=fstat) try: missing = True _wait_until(lambda: bool(seen)) finally: missing = False name_lost = False lease.release() assert [c.reason for c in seen] == ["refresh-failed"] def test_lease_rides_out_a_marker_missing_for_one_refresh(marker: Path, mocker: MockerFixture) -> None: # A peer's stale break moves a live marker aside and links it back; a refresh between the two finds no marker. seen: Final[list[LeaseCompromise]] = [] lease: Final = _lease(marker, on_compromise=seen.append) real_lstat: Final = os.lstat misses: Final = iter([True]) def lstat(path: str) -> os.stat_result: if path.endswith(marker.name) and next(misses, False): raise FileNotFoundError(ENOENT, "No such file or directory", marker.name) return real_lstat(path) with lease: mocker.patch("filelock._lease.os.lstat", autospec=True, side_effect=lstat) time.sleep(_HEARTBEAT * 4) assert (seen, lease.compromise, next(misses, None)) == ([], None, None) def test_lease_reports_owner_changed_when_the_marker_identity_shifts(marker: Path, mocker: MockerFixture) -> None: # A peer that took the expired claim replaces the marker, so the refresh stat names a different inode. Drive that # agnostically by returning a stat whose identity differs from the one the holder verified at acquire. seen: list[LeaseCompromise] = [] lease = _lease(marker, on_compromise=seen.append) lease.acquire() token = lease.token real_lstat = cast("Callable[..., os.stat_result]", os.lstat) shifted = False def lstat(path: object, *args: object, **kwargs: object) -> object: st = real_lstat(path, *args, **kwargs) if shifted and str(path).endswith(marker.name): return SimpleNamespace(st_dev=st.st_dev, st_ino=st.st_ino + 1) return st mocker.patch("filelock._lease.os.lstat", side_effect=lstat) try: shifted = True _wait_until(lambda: bool(seen)) finally: shifted = False lease.release() assert [(c.reason, c.token) for c in seen] == [("owner-changed", token)] def test_native_lock_rejects_a_lease_duration(marker: Path) -> None: # A kernel lock lives on the inode, so no pathname age can revoke it; the option must not even be accepted. The # rejection happens at runtime, so reach it the way a caller without a type checker would. construct = cast("Callable[..., FileLock]", FileLock) with pytest.raises(TypeError, match="does not support non-default lock options: lease_duration"): construct(str(marker), lease_duration=5) def test_lease_does_not_age_out_a_live_exclusive_owner(marker: Path, mocker: MockerFixture) -> None: # An exclusive owner never agreed to expire, so only proof of death may reclaim it, however long it sits unchanged. mocker.patch("filelock._lease.owner_is_stale", return_value=False) mocker.patch( "filelock._soft.time", new=SimpleNamespace( monotonic=mocker.create_autospec(time.monotonic, side_effect=itertools.count(step=_DURATION)), sleep=time.sleep, ), ) marker.write_text(f"filelock/2\npid={os.getpid()}\nhost={socket.gethostname()}\nmode=exclusive\n", encoding="utf-8") with pytest.raises(Timeout): _lease(marker).acquire() assert marker.exists() def test_lease_keeps_a_claim_refreshed_by_a_host_whose_clock_lags(marker: Path, mocker: MockerFixture) -> None: # The holder's clock runs an hour behind, so every refresh stamps an hour-old mtime. The claim is live as long as # the mtime keeps changing; reading it against this host's wall clock would supersede the holder at once. mocker.patch("filelock._lease.owner_is_stale", return_value=False) marker.write_text( f"filelock/2\npid={os.getpid()}\nhost={host_name()}\nmode=lease\ntoken=skewed\nduration={_DURATION!r}\n", encoding="utf-8", ) stop: Final = Event() def refresh_an_hour_behind() -> None: while not stop.wait(_HEARTBEAT): os.utime(marker, (lagging := time.time() - 3600, lagging)) refresher: Final = Thread(target=refresh_an_hour_behind) refresher.start() try: with pytest.raises(Timeout): _lease(marker, timeout=_DURATION * 3).acquire() finally: stop.set() refresher.join() assert marker.read_text(encoding="utf-8").endswith("token=skewed\nduration=0.9\n") @NEEDS_COLLECTED_FINALIZATION def test_lease_dropped_while_held_releases_its_marker(marker: Path) -> None: lease = _lease(marker) lease.acquire() del lease gc.collect() # PyPy queues the finalizer the collection found and runs it at a later bytecode boundary, in whichever thread gets # there first, so the release lands after the collection returns. _wait_until(lambda: not marker.exists()) assert not marker.exists() @NEEDS_COLLECTED_FINALIZATION def test_lease_dropped_where_it_cannot_be_released_stops_its_heartbeat(marker: Path) -> None: # The thread-local context hides the claim from the thread that drops the last reference, so __del__ there cannot # release it; the heartbeat must still stop, letting the unrefreshed claim age out for a peer. before: Final = _lease_heartbeats() leases: Final[list[SoftFileLease]] = [] def acquire() -> None: lease: Final = _lease(marker) lease.acquire() leases.append(lease) acquirer: Final = Thread(target=acquire) acquirer.start() acquirer.join() heartbeats: Final = _lease_heartbeats() - before assert heartbeats leases.clear() # A tracing collector finalizes the lease on the first pass and runs the weak reference callback on the next. gc.collect() gc.collect() for heartbeat in heartbeats: heartbeat.join(timeout=_DURATION * 20) assert not any(heartbeat.is_alive() for heartbeat in heartbeats) # Interpreter exit finalizes module globals by dropping their references, which only a refcounting collector turns into # __del__ calls. A release from another finalizer runs while the heartbeat thread is frozen, so joining it would raise. @NEEDS_PROMPT_FINALIZATION @pytest.mark.parametrize( "script", [ pytest.param( "import sys, filelock; lease = filelock.SoftFileLease(sys.argv[1]); lease.acquire()", id="dropped" ), pytest.param( "import sys, filelock\n" "class Releaser:\n" " def __init__(self, lease): self.lease = lease\n" " def __del__(self): self.lease.release()\n" "releaser = Releaser(filelock.SoftFileLease(sys.argv[1]))\n" "releaser.lease.acquire()", id="released-by-a-finalizer", ), ], ) def test_lease_held_at_interpreter_exit_releases_its_marker(marker: Path, script: str) -> None: result: Final = subprocess.run( [sys.executable, "-c", script, str(marker)], check=True, capture_output=True, text=True, timeout=10 ) assert (marker.exists(), result.stderr) == (False, "") @NEEDS_COLLECTED_FINALIZATION def test_lease_dropped_while_its_heartbeat_finds_the_claim_lost_reports_nothing( marker: Path, mocker: MockerFixture ) -> None: # The last reference goes while the heartbeat is mid-refresh, past the wait the collection would have stopped, and # the refresh then finds the marker gone. There is no holder left to tell, so the heartbeat just ends. seen: Final[list[LeaseCompromise]] = [] leases: Final = [ SoftFileLease( str(marker), thread_local=False, lease_duration=_DURATION, heartbeat_interval=_HEARTBEAT, on_compromise=seen.append, ) ] before: Final = _lease_heartbeats() open_spy: Final = mocker.spy(os, "open") leases[0].acquire() heartbeats: Final = _lease_heartbeats() - before lease_fd: Final = open_spy.spy_return real_lstat: Final = os.lstat real_fstat: Final = os.fstat misses: Final = itertools.count() def lstat(path: str) -> os.stat_result: if leases and path.endswith(marker.name): # A single miss may be a peer's break in flight, so the loss only counts on the second. if next(misses): leases.clear() gc.collect() raise FileNotFoundError(ENOENT, "No such file or directory", marker.name) return real_lstat(path) def fstat(fd: int) -> os.stat_result | SimpleNamespace: # The collected lease has closed its descriptor by the time the heartbeat inspects it. return SimpleNamespace(st_nlink=0, st_dev=0, st_ino=0) if fd == lease_fd else real_fstat(fd) mocker.patch("filelock._lease.os.lstat", autospec=True, side_effect=lstat) mocker.patch("filelock._lease.os.fstat", autospec=True, side_effect=fstat) for heartbeat in heartbeats: heartbeat.join(timeout=_DURATION * 20) assert (leases, seen, any(heartbeat.is_alive() for heartbeat in heartbeats)) == ([], [], False) def _wait_until(condition: Callable[[], bool]) -> None: # Bounded, so a heartbeat that never gets there fails the assertion after it instead of hanging the run. deadline: Final = time.monotonic() + _DURATION * 20 while not condition() and time.monotonic() < deadline: time.sleep(_HEARTBEAT / 2) def _lease_heartbeats() -> set[threading.Thread]: return {thread for thread in threading.enumerate() if thread.name.startswith("filelock-lease-")} @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @NEEDS_STALE_RECLAIM def test_lease_takes_over_a_dead_owners_marker_with_its_own_lease_record( # pragma: win32 no cover tmp_path: Path, ) -> None: (lock := tmp_path / "a.lock").write_text( f"filelock/2\npid={2**22 + 1}\nhost={host_name()}\nmode=exclusive\n", encoding="utf-8" ) lease: Final = SoftFileLease(lock, lease_duration=5) lease.acquire(blocking=False) try: owner: Final = lease.owner assert owner is not None assert (owner.mode, owner.token, owner.lease_duration, lease.is_lock_held_by_us) == ( "lease", lease.token, 5.0, True, ) finally: lease.release() filelock-4.2.8/tests/test_soft_lifetime_warning.py0000644000000000000000000002145415272561277017420 0ustar00from __future__ import annotations import os import subprocess # ruff:ignore[suspicious-subprocess-import] # only a fresh interpreter has the default filters import sys import warnings from typing import TYPE_CHECKING, Final, Literal import pytest from filelock import ( AsyncSoftFileLock, AsyncUnixFileLock, AsyncWindowsFileLock, SoftFileLock, SoftFileLockLifetimeWarning, Timeout, UnixFileLock, WindowsFileLock, ) if TYPE_CHECKING: from pathlib import Path @pytest.mark.parametrize( ("lock_type", "strict_lock", "lease"), [ pytest.param(SoftFileLock, "StrictSoftFileLock", "SoftFileLease", id="sync"), pytest.param(AsyncSoftFileLock, "AsyncStrictSoftFileLock", "AsyncSoftFileLease", id="async"), ], ) @pytest.mark.parametrize( "entry_point", [pytest.param("constructor", id="constructor"), pytest.param("setter", id="setter")], ) def test_soft_lifetime_warning_identifies_caller( lock_type: type[SoftFileLock | AsyncSoftFileLock], strict_lock: str, lease: str, entry_point: Literal["constructor", "setter"], tmp_path: Path, ) -> None: with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") if entry_point == "constructor": warning_line = sys._getframe().f_lineno + 1 lock_type(tmp_path / "test.lock", lifetime=10) else: lock = lock_type(tmp_path / "test.lock") warning_line = sys._getframe().f_lineno + 1 lock.lifetime = 10 assert [(item.category, str(item.message), item.filename, item.lineno) for item in caught] == [ ( SoftFileLockLifetimeWarning, ( f"{lock_type.__name__}(lifetime=...) uses age-based expiry and can overlap a live holder; " f"use {lease} for expiry or {strict_lock} for fail-closed locking" ), __file__, warning_line, ) ] @pytest.mark.parametrize( "lock_type", [pytest.param(SoftFileLock, id="sync"), pytest.param(AsyncSoftFileLock, id="async")], ) @pytest.mark.parametrize( "entry_point", [pytest.param("constructor", id="constructor"), pytest.param("setter", id="setter")], ) def test_soft_lifetime_none_does_not_warn( lock_type: type[SoftFileLock | AsyncSoftFileLock], entry_point: Literal["constructor", "setter"], tmp_path: Path ) -> None: with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") if entry_point == "constructor": lock_type(tmp_path / "test.lock", lifetime=None) else: lock = lock_type(tmp_path / "test.lock") lock.lifetime = None assert caught == [] @pytest.mark.parametrize( "lock_type", [pytest.param(SoftFileLock, id="sync"), pytest.param(AsyncSoftFileLock, id="async")], ) @pytest.mark.parametrize( "entry_point", [pytest.param("constructor", id="constructor"), pytest.param("setter", id="setter")], ) def test_invalid_soft_lifetime_does_not_warn( lock_type: type[SoftFileLock | AsyncSoftFileLock], entry_point: Literal["constructor", "setter"], tmp_path: Path ) -> None: with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") with pytest.raises(ValueError, match="finite and non-negative"): _set_invalid_lifetime(lock_type, entry_point, tmp_path / "test.lock") assert caught == [] @pytest.mark.parametrize( "lock_type", [ pytest.param(UnixFileLock, marks=pytest.mark.skipif(sys.platform == "win32", reason="Unix backend"), id="sync"), pytest.param( AsyncUnixFileLock, marks=pytest.mark.skipif(sys.platform == "win32", reason="Unix backend"), id="async", ), pytest.param( WindowsFileLock, marks=pytest.mark.skipif(sys.platform != "win32", reason="Windows backend"), id="sync" ), pytest.param( AsyncWindowsFileLock, marks=pytest.mark.skipif(sys.platform != "win32", reason="Windows backend"), id="async", ), ], ) @pytest.mark.parametrize( "entry_point", [pytest.param("constructor", id="constructor"), pytest.param("setter", id="setter")], ) def test_native_lifetime_warning_identifies_caller( lock_type: type[UnixFileLock | AsyncUnixFileLock | WindowsFileLock | AsyncWindowsFileLock], entry_point: Literal["constructor", "setter"], tmp_path: Path, ) -> None: with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") if entry_point == "constructor": warning_line = sys._getframe().f_lineno + 1 lock_type(tmp_path / "test.lock", lifetime=10) else: lock = lock_type(tmp_path / "test.lock") warning_line = sys._getframe().f_lineno + 1 lock.lifetime = 10 assert [(item.category, str(item.message), item.filename, item.lineno) for item in caught] == [ ( UserWarning, ( f"lifetime is ignored for {lock_type.__name__}: a native OS lock cannot be broken safely by file age; " "only SoftFileLock supports lifetime-based expiry" ), __file__, warning_line, ) ] @pytest.mark.parametrize( "lock_type", [pytest.param(SoftFileLock, id="sync"), pytest.param(AsyncSoftFileLock, id="async")], ) def test_soft_lifetime_singleton_match_warns_for_each_configuration( lock_type: type[SoftFileLock | AsyncSoftFileLock], tmp_path: Path ) -> None: with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") first = lock_type(tmp_path / "test.lock", lifetime=10, is_singleton=True) second = lock_type(tmp_path / "test.lock", lifetime=10, is_singleton=True) assert (first is second, len(caught)) == (True, 2) @pytest.mark.parametrize( "lock_type", [pytest.param(SoftFileLock, id="sync"), pytest.param(AsyncSoftFileLock, id="async")], ) def test_soft_lifetime_singleton_mismatch_warns_before_rejection( lock_type: type[SoftFileLock | AsyncSoftFileLock], tmp_path: Path ) -> None: with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") first = lock_type(tmp_path / "test.lock", lifetime=10, is_singleton=True) with pytest.raises(ValueError, match="lifetime"): lock_type(tmp_path / "test.lock", lifetime=20, is_singleton=True) assert (first.lifetime, len(caught)) == (10, 2) def test_soft_lifetime_polling_does_not_repeat_warning(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" holder = SoftFileLock(lock_path) with pytest.warns(SoftFileLockLifetimeWarning): contender = SoftFileLock(lock_path, lifetime=60, poll_interval=0.001, timeout=0.01) with holder, warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") with pytest.raises(Timeout): contender.acquire() assert caught == [] @pytest.mark.asyncio async def test_async_soft_lifetime_polling_does_not_repeat_warning(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" holder = AsyncSoftFileLock(lock_path) with pytest.warns(SoftFileLockLifetimeWarning): contender = AsyncSoftFileLock(lock_path, lifetime=60, poll_interval=0.001, timeout=0.01) async with holder: with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") # The holder never releases here, so the acquire always times out. with pytest.raises(Timeout): # pragma: no branch await contender.acquire() assert caught == [] def test_soft_lifetime_warning_shows_under_default_filters(tmp_path: Path) -> None: # Python hides a DeprecationWarning raised outside __main__ by default, so configure the lock from a library module. (tmp_path / "library.py").write_text( "from filelock import SoftFileLock\n\ndef configure(path):\n return SoftFileLock(path, lifetime=5)\n", encoding="utf-8", ) script: Final = "import sys; sys.path.insert(0, sys.argv[1]); import library; library.configure(sys.argv[2])" result: Final = subprocess.run( [sys.executable, "-c", script, str(tmp_path), str(tmp_path / "test.lock")], check=True, capture_output=True, text=True, env={key: value for key, value in os.environ.items() if key not in {"PYTHONWARNINGS", "PYTHONDEVMODE"}}, timeout=30, ) assert "SoftFileLockLifetimeWarning" in result.stderr def _set_invalid_lifetime( lock_type: type[SoftFileLock | AsyncSoftFileLock], entry_point: Literal["constructor", "setter"], lock_path: Path, ) -> None: if entry_point == "constructor": lock_type(lock_path, lifetime=-1) else: lock = lock_type(lock_path) lock.lifetime = -1 filelock-4.2.8/tests/test_soft_stale.py0000644000000000000000000023333315272561277015206 0ustar00from __future__ import annotations import gc import itertools import os import socket import sys import threading import time from contextlib import contextmanager, suppress from errno import EACCES, EINTR, EINVAL, EIO, ENODEV, ENOSPC, EPERM from multiprocessing import get_context from pathlib import Path from types import SimpleNamespace from typing import TYPE_CHECKING, Final import pytest from capabilities import CAPABILITIES import filelock._soft from filelock import CloseErrorPolicy, MarkerSoftFileLock, SoftFileLease, SoftFileLock, Timeout from filelock._identity import host_name, process_start_token from filelock._soft import _MAX_LOCK_FILE_SIZE, MALFORMED_LOCK_AGE_THRESHOLD, _begin_claim, _end_claim, _read_lock_file from filelock._soft_protocol import STRICT_SOFT_SENTINEL_RECORD from filelock._util import exchange_names from tests.capability_marks import NEEDS_POSIX_SIGNALS, NEEDS_STALE_RECLAIM, NEEDS_SYMLINK, NEEDS_UNLINK_OPEN_FILE from tests.conftest import DEAD_OWNER, STALE_BREAKERS, acquire_after_reclaim, held_after_reclaim from tests.pid_namespace_helpers import pin_pid_namespace from tests.process_helpers import break_then_hang_after_exchange, call_within, cleanup_processes if TYPE_CHECKING: from collections.abc import Callable, Generator from unittest.mock import MagicMock from pytest_mock import MockerFixture _WINDOWS_ONLY: Final[pytest.MarkDecorator] = pytest.mark.skipif(sys.platform != "win32", reason="windows-only") _HOST: Final[str] = host_name() _DEAD_PID: Final[int] = 2**22 + 1 _WIN_ERROR_ACCESS_DENIED: Final[int] = 5 _WIN_ERROR_INVALID_PARAMETER: Final[int] = 87 #: A spawned GraalPy interpreter alone can take seconds to start on a loaded runner. _PROCESS_DEADLINE: Final[int] = 30 @pytest.fixture def lock_path(tmp_path: Path) -> Path: return tmp_path / "test.lock" def _holder(pid: int, *, host: str = _HOST, start: int | None = None) -> str: return "\n".join([str(pid), host, *([] if start is None else [str(start)])]) + "\n" def test_lock_writes_pid_and_hostname(lock_path: Path) -> None: with SoftFileLock(lock_path): lines = lock_path.read_text(encoding="utf-8").strip().splitlines() assert lines[:2] == [str(os.getpid()), _HOST] # Every platform with a proven start time writes it as an integer third line; one without writes two lines. if (token := process_start_token(os.getpid())) is not None: assert lines == [str(os.getpid()), _HOST, str(token)] else: # pragma: no cover # every CI platform exposes a start time assert lines == [str(os.getpid()), _HOST] @pytest.mark.parametrize( "content", [ pytest.param(_holder(_DEAD_PID), id="two_line"), pytest.param(_holder(_DEAD_PID, start=123456789), id="three_line"), ], ) @NEEDS_STALE_RECLAIM def test_stale_lock_broken_when_process_dead(lock_path: Path, mocker: MockerFixture, content: str) -> None: lock_path.write_text(content, encoding="utf-8") mocker.patch("filelock._identity.process_alive", return_value=False) _assert_self_heals(lock_path) def test_stale_lock_not_broken_when_process_alive(lock_path: Path, mocker: MockerFixture) -> None: lock_path.write_text(_holder(os.getpid()), encoding="utf-8") mocker.patch("filelock._identity.process_alive", return_value=True) _assert_times_out(lock_path) def test_stale_lock_not_broken_different_hostname(lock_path: Path) -> None: lock_path.write_text(_holder(_DEAD_PID, host="other-host.example.com"), encoding="utf-8") _assert_times_out(lock_path) @pytest.mark.parametrize( ("lock_type", "record"), [ pytest.param(SoftFileLock, _holder(_DEAD_PID, host="other-host.example.com"), id="soft"), pytest.param(MarkerSoftFileLock, _holder(_DEAD_PID, host="other-host.example.com"), id="marker-v1"), pytest.param( MarkerSoftFileLock, f"filelock/2\npid={_DEAD_PID}\nhost=other-host.example.com\nmode=exclusive\n", id="marker-v2", ), ], ) def test_timeout_names_a_holder_on_another_host(lock_path: Path, lock_type: type[SoftFileLock], record: str) -> None: lock_path.write_text(record, encoding="utf-8") with pytest.raises(Timeout, match=f"held by pid {_DEAD_PID} on other-host.example.com, which this host cannot"): lock_type(lock_path, timeout=0).acquire() def test_timeout_leaves_out_a_holder_on_this_host(lock_path: Path) -> None: with SoftFileLock(lock_path), pytest.raises(Timeout) as caught: SoftFileLock(lock_path, timeout=0).acquire() assert str(caught.value) == f"The file lock '{lock_path}' could not be acquired." _REQUIRES_START_TOKEN: Final[pytest.MarkDecorator] = pytest.mark.skipif( process_start_token(os.getpid()) is None, reason="platform exposes no proven process start time" ) @_REQUIRES_START_TOKEN def test_recycled_pid_marker_self_heals(lock_path: Path) -> None: # A live PID (our own) whose recorded start token differs is a recycled PID: the process that wrote the marker is # gone even though the number is now in use. No mock: the real start token is read and found not to match. token = process_start_token(os.getpid()) assert token is not None lock_path.write_text(_holder(os.getpid(), start=token + 1), encoding="utf-8") _assert_self_heals(lock_path) @_REQUIRES_START_TOKEN @pytest.mark.parametrize( ("contender_namespace", "reclaimed"), [pytest.param(0xF0000001, True, id="same-namespace"), pytest.param(0xF0000002, False, id="sibling-namespace")], ) def test_recycled_pid_marker_self_heals_only_in_its_pid_namespace( lock_path: Path, mocker: MockerFixture, contender_namespace: int, *, reclaimed: bool ) -> None: token: Final = process_start_token(os.getpid()) assert token is not None pin_pid_namespace(mocker, 0xF0000001) lock_path.write_text(_holder(os.getpid(), host=host_name(), start=token + 1), encoding="utf-8") pin_pid_namespace(mocker, contender_namespace) lock: Final[SoftFileLock] = SoftFileLock(lock_path) acquire_after_reclaim(lock) assert lock.is_locked is reclaimed lock.release() @_REQUIRES_START_TOKEN def test_live_process_with_matching_start_token_retained(lock_path: Path) -> None: # Our own PID and our own start token: the recorded owner is this live process, so the marker must stand. lock_path.write_text(_holder(os.getpid(), start=process_start_token(os.getpid())), encoding="utf-8") _assert_times_out(lock_path) def test_live_process_without_start_token_falls_back_to_pid(lock_path: Path) -> None: # A two-line marker carries no start token, so liveness rests on the PID alone; a live PID is left held. lock_path.write_text(_holder(os.getpid()), encoding="utf-8") _assert_times_out(lock_path) @NEEDS_POSIX_SIGNALS # pragma: needs posix-signals @pytest.mark.parametrize( "errno", [pytest.param(EPERM, id="eperm"), pytest.param(ENODEV, id="unexpected_device")], ) def test_stale_lock_not_broken_on_kill_error(lock_path: Path, mocker: MockerFixture, errno: int) -> None: lock_path.write_text(_holder(99999), encoding="utf-8") mocker.patch("filelock._identity.os.kill", side_effect=OSError(errno, "kill failed")) _assert_times_out(lock_path) @pytest.mark.usefixtures("unchanged_past_grace") @pytest.mark.parametrize("lock_type", [SoftFileLock, MarkerSoftFileLock, SoftFileLease]) def test_empty_lock_not_evicted_when_old(lock_path: Path, lock_type: type[SoftFileLock]) -> None: # A SoftFileLock before 3.22 writes nothing into its marker and holds it for as long as it runs. lock_path.write_bytes(b"") contender: Final = lock_type(lock_path) for _ in range(2): with pytest.raises(Timeout): contender.acquire(blocking=False) assert lock_path.read_bytes() == b"" @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_restores_a_stranded_empty_marker( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") _break_name(lock).write_bytes(b"") with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert (lock.read_bytes(), list(tmp_path.glob("test.lock.break.*"))) == (b"", []) def test_timeout_names_an_owner_that_recorded_no_pid(lock_path: Path) -> None: lock_path.write_bytes(b"") with pytest.raises(Timeout, match="held by an owner that recorded no PID"): SoftFileLock(lock_path).acquire(blocking=False) @pytest.mark.parametrize( "content", [ pytest.param(b"not-a-pid\n", id="malformed"), pytest.param(b"x" * (_MAX_LOCK_FILE_SIZE + 1), id="oversized"), pytest.param(b"not-a-pid\nhostname\n", id="two_line_bad_pid"), pytest.param(f"{_DEAD_PID}\nhostname\nnot-a-token\n".encode(), id="three_line_bad_start_token"), pytest.param(f"{_DEAD_PID}\nother-ho".encode(), id="truncated_hostname"), ], ) @pytest.mark.usefixtures("unchanged_past_grace") @NEEDS_STALE_RECLAIM def test_unparseable_lock_evicted_when_old(lock_path: Path, content: bytes) -> None: lock_path.write_bytes(content) # An unreadable lock (bad line count, non-integer pid/start token, or oversized) must self-heal # instead of staying stuck; a matching line count alone does not make a file well-formed. _assert_self_heals(lock_path) @pytest.mark.parametrize( "content", [ pytest.param(b"not-a-pid\n", id="malformed"), pytest.param(b"", id="empty"), pytest.param(b"not-a-pid\nhostname\n", id="two_line_bad_pid"), ], ) def test_unparseable_lock_not_evicted_when_fresh(lock_path: Path, content: bytes) -> None: lock_path.write_bytes(content) _assert_times_out(lock_path) @pytest.mark.skipif(sys.platform == "win32", reason="st_ctime is the creation time on Windows, which no change moves") @pytest.mark.usefixtures("unchanged_past_grace") def test_unparseable_lock_changed_under_its_old_mtime_and_inode_not_evicted( # pragma: win32 no cover lock_path: Path, ) -> None: # Another file can repeat both: its owner sets the mtime, and ext4 hands a freed inode number to the next file. lock_path.write_bytes(b"not-a-pid\n") watched: Final = lock_path.stat() contender: Final = SoftFileLock(lock_path) with pytest.raises(Timeout): contender.acquire(blocking=False) while lock_path.stat().st_ctime_ns == watched.st_ctime_ns: os.utime(lock_path, ns=(watched.st_atime_ns, watched.st_mtime_ns)) with pytest.raises(Timeout): contender.acquire(blocking=False) assert lock_path.exists() @_REQUIRES_START_TOKEN def test_fresh_marker_with_truncated_start_token_not_evicted(lock_path: Path) -> None: # A reader racing our own write sees a cut start token, which must not pass for a recycled PID and break our lock. lock_path.write_text(f"{os.getpid()}\n{_HOST}\n{process_start_token(os.getpid())}"[:-1], encoding="utf-8") _assert_times_out(lock_path) @pytest.mark.parametrize( "pid", [ pytest.param(0, id="zero"), pytest.param(-1, id="negative"), pytest.param(2**31, id="oversized"), ], ) @pytest.mark.usefixtures("unchanged_past_grace") @NEEDS_STALE_RECLAIM def test_out_of_range_pid_self_heals_when_old(lock_path: Path, pid: int) -> None: lock_path.write_text(_holder(pid), encoding="utf-8") # pid 0 or -1 makes os.kill probe the caller's own process group (reads as alive), so the lock is never # reclaimed; an oversized pid raises OverflowError out of stale detection. Both are malformed and must # self-heal, matching what _parse_marker_bytes rejects. _assert_self_heals(lock_path) def test_out_of_range_pid_not_evicted_when_fresh(lock_path: Path) -> None: lock_path.write_text(_holder(0), encoding="utf-8") # A fresh out-of-range pid is malformed too, but like any malformed lock it is left alone until it ages # past the threshold, so a peer mid-write is not mistaken for a stale lock. _assert_times_out(lock_path) @pytest.mark.usefixtures("break_route") @pytest.mark.parametrize("break_route", ["no-exchange"], indirect=True) def test_stale_lock_rename_race(lock_path: Path, mocker: MockerFixture) -> None: lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") mocker.patch("filelock._identity.process_alive", return_value=False) mocker.patch.object(Path, "rename", side_effect=FileNotFoundError("already gone")) _assert_times_out(lock_path) def test_stale_break_racing_a_successor_admits_no_third_holder( lock_path: Path, mocker: MockerFixture, on_break_recheck: Callable[[Path, Callable[[], None]], None] ) -> None: lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") successor: Final[SoftFileLock] = SoftFileLock(lock_path, timeout=0) def successor_breaks_and_acquires_first() -> None: lock_path.unlink() successor.acquire() on_break_recheck(lock_path, successor_breaks_and_acquires_first) _assert_times_out(lock_path) mocker.stopall() _assert_times_out(lock_path) successor.release() @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @NEEDS_STALE_RECLAIM def test_stale_break_keeps_the_lock_when_a_peer_swaps_its_marker_aside( # pragma: win32 no cover lock_path: Path, mocker: MockerFixture ) -> None: lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") (peer := lock_path.with_name("peer")).write_text(_holder(os.getpid()), encoding="utf-8") unlink: Final = Path.unlink def peer_exchanges_then_unlink(path: Path, *, missing_ok: bool = False) -> None: exchange_names(str(lock_path), str(peer)) unlink(path, missing_ok=missing_ok) mocker.patch.object(Path, "unlink", new=peer_exchanges_then_unlink) lock: Final = SoftFileLock(lock_path) lock.acquire(blocking=False) mocker.stopall() exchange_names(str(lock_path), str(peer)) assert (lock.is_locked, lock.is_lock_held_by_us) == (True, True) lock.release() assert list(lock_path.parent.glob("test.lock*")) == [] def _break_name(lock: Path, pid: int = _DEAD_PID) -> Path: # pragma: win32 no cover return lock.with_name(f"test.lock.break.{pid}.{'0' * 32}") def _break_contents(lock: Path) -> list[str]: # pragma: win32 no cover return [path.read_text(encoding="utf-8") for path in lock.parent.glob(f"{lock.name}.break.*")] def _live(record: str) -> str: # pragma: win32 no cover return record.replace(str(_DEAD_PID), str(os.getpid())) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_restores_a_live_marker_a_dead_breaker_left_under_its_break_name( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") _break_name(lock).write_text(_live(record), encoding="utf-8") with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert (lock.read_text(encoding="utf-8"), list(tmp_path.glob("test.lock.break.*"))) == (_live(record), []) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_restores_a_marker_of_another_host_with_the_breakers_pid( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") foreign: Final = record.replace(_HOST, "elsewhere") _break_name(lock).write_text(foreign, encoding="utf-8") with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert (lock.read_text(encoding="utf-8"), list(tmp_path.glob("test.lock.break.*"))) == (foreign, []) def _restore_name(lock: Path, pid: int | str) -> Path: # pragma: win32 no cover return lock.with_name(f"test.lock.restore.{pid}.{'0' * 32}") @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_restores_under_an_exclusive_claim( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") foreign: Final = record.replace(_HOST, "elsewhere") _break_name(lock).write_text(foreign, encoding="utf-8") first_has_checked: Final = threading.Event() release_rival: Final = threading.Event() rival_timeouts: Final[list[bool]] = [] def rival_recovers() -> None: with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) rival_timeouts.append(True) rival: Final = threading.Thread(target=rival_recovers) main: Final = threading.current_thread() exchange: Final = exchange_names unlink: Final = Path.unlink def settle(until: threading.Event | None = None) -> None: while rival.is_alive() and not (until and until.is_set()): rival.join(0.001) def exchange_with_a_rival_between_check_and_exchange(first: str, second: str) -> bool: # The rival reaches its own exchange only when recovery lets two contenders act on one record. if threading.current_thread() is main: rival.start() settle(first_has_checked) return exchange(first, second) first_has_checked.set() # pragma: no cover while not release_rival.is_set(): # pragma: no cover release_rival.wait(0.001) # pragma: no cover return exchange(first, second) # pragma: no cover def unlink_after_the_rival_finished(path: Path, *, missing_ok: bool = False) -> None: release_rival.set() settle() unlink(path, missing_ok=missing_ok) mocker.patch("filelock._soft.exchange_names", new=exchange_with_a_rival_between_check_and_exchange) mocker.patch.object(Path, "unlink", new=unlink_after_the_rival_finished) with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) release_rival.set() settle() assert ( rival_timeouts, lock.read_text(encoding="utf-8"), list(tmp_path.glob("test.lock.break.*")), list(tmp_path.glob("test.lock.restore.*")), ) == ([True], foreign, [], []) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_waits_when_a_peer_claimed_the_record_first( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") foreign: Final = record.replace(_HOST, "elsewhere") _break_name(lock).write_text(foreign, encoding="utf-8") rename: Final = os.rename def peer_claims_first(source: Path, target: str) -> None: rename(source, _restore_name(lock, _DEAD_PID - 1)) rename(source, target) mocker.patch("filelock._soft.os.rename", side_effect=peer_claims_first) with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert (lock.read_text(encoding="utf-8"), _restore_name(lock, _DEAD_PID - 1).read_text(encoding="utf-8")) == ( record, foreign, ) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS def test_stale_break_waits_when_the_record_cannot_be_claimed( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") foreign: Final = record.replace(_HOST, "elsewhere") _break_name(lock).write_text(foreign, encoding="utf-8") mocker.patch("filelock._soft.os.rename", side_effect=OSError(EACCES, "denied")) with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert (lock.read_text(encoding="utf-8"), _break_contents(lock)) == (record, [foreign]) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_reclaims_a_record_a_dead_recoverer_claimed( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") foreign: Final = record.replace(_HOST, "elsewhere") _restore_name(lock, _DEAD_PID).write_text(foreign, encoding="utf-8") with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert (lock.read_text(encoding="utf-8"), list(tmp_path.glob("test.lock.restore.*"))) == (foreign, []) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS def test_stale_break_skips_a_record_a_live_recoverer_claimed( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") foreign: Final = record.replace(_HOST, "elsewhere") (claimed := _restore_name(lock, os.getppid())).write_text(foreign, encoding="utf-8") with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert (lock.read_text(encoding="utf-8"), claimed.read_text(encoding="utf-8")) == (record, foreign) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM @pytest.mark.parametrize( "recoverer", [ pytest.param(str(2**64), id="oversized"), pytest.param("0", id="zero"), # This process's pid in fullwidth digits, which int() reads back as the same number. pytest.param("".join(chr(0xFF10 + int(digit)) for digit in str(os.getpid())), id="wide"), ], ) def test_stale_break_ignores_a_claim_naming_no_valid_pid( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str, recoverer: str ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") foreign: Final = record.replace(_HOST, "elsewhere") (planted := _restore_name(lock, recoverer)).write_text(foreign, encoding="utf-8") with held_after_reclaim(lock_type(lock)) as held: assert held.is_locked assert planted.read_text(encoding="utf-8") == foreign def _refuse_returning_a_claim(mocker: MockerFixture) -> None: # pragma: win32 no cover rename: Final = Path.rename def rename_unless_returning(path: Path, target: str | Path) -> Path: if ".break." in str(target): raise OSError(EACCES, "denied") return rename(path, target) mocker.patch.object(Path, "rename", new=rename_unless_returning) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_retries_a_claim_whose_return_failed( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") foreign: Final = record.replace(_HOST, "elsewhere") _break_name(lock).write_text(foreign, encoding="utf-8") _refuse_returning_a_claim(mocker) mocker.patch("filelock._soft.exchange_names", return_value=False) with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) left: Final = list(tmp_path.glob(f"test.lock.restore.{os.getpid()}.*")) mocker.stopall() with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert (len(left), lock.read_text(encoding="utf-8"), sorted(tmp_path.iterdir())) == (1, foreign, [lock]) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_finishes_a_claim_an_unreadable_record_interrupted( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") foreign: Final = record.replace(_HOST, "elsewhere") _break_name(lock).write_text(foreign, encoding="utf-8") read: Final = _read_lock_file def read_unless_claimed(path: str) -> tuple[str | None, os.stat_result]: if ".restore." in path: raise OSError(EIO, "unreadable") return read(path) _refuse_returning_a_claim(mocker) mocker.patch("filelock._soft._read_lock_file", new=read_unless_claimed) with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) left: Final = list(tmp_path.glob(f"test.lock.restore.{os.getpid()}.*")) mocker.stopall() with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert (len(left), lock.read_text(encoding="utf-8"), sorted(tmp_path.iterdir())) == (1, foreign, [lock]) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_waits_for_a_claim_another_thread_is_working( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") _break_name(lock).write_text(record.replace(_HOST, "elsewhere"), encoding="utf-8") working, release = threading.Event(), threading.Event() exchange: Final = exchange_names def exchange_when_released(first: str, second: str) -> bool: working.set() release.wait(5) return exchange(first, second) mocker.patch("filelock._soft.exchange_names", new=exchange_when_released) worker: Final = threading.Thread(target=_acquire, args=(lock_type, lock)) worker.start() assert working.wait(5) claims_while_working: Final = list(tmp_path.glob(f"test.lock.restore.{os.getpid()}.*")) with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) untouched: Final = list(tmp_path.glob(f"test.lock.restore.{os.getpid()}.*")) release.set() worker.join() assert (len(claims_while_working), untouched == claims_while_working) == (1, True) def _acquire_timing_out( # pragma: win32 no cover lock_type: type[SoftFileLock], lock: Path, outcomes: list[bool] ) -> None: with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) outcomes.append(True) def _acquire(lock_type: type[SoftFileLock], lock: Path) -> None: # pragma: win32 no cover with suppress(Timeout): lock_type(lock).acquire(blocking=False) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_claim_is_registered_before_it_is_visible( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") foreign: Final = record.replace(_HOST, "elsewhere") _break_name(lock).write_text(foreign, encoding="utf-8") rename: Final = Path.rename rival_timeouts: Final[list[bool]] = [] claims_after_rival: Final[list[list[Path]]] = [] def rival_scans_after_the_claim_rename(path: Path, target: str | Path) -> Path: renamed: Final = rename(path, target) mocker.stopall() rival: Final = threading.Thread(target=_acquire_timing_out, args=(lock_type, lock, rival_timeouts)) rival.start() rival.join() claims_after_rival.append(list(tmp_path.glob("test.lock.restore.*"))) return renamed mocker.patch.object(Path, "rename", new=rival_scans_after_the_claim_rename) with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert ( rival_timeouts, len(claims_after_rival[0]), lock.read_text(encoding="utf-8"), sorted(tmp_path.iterdir()), ) == ( [True], 1, foreign, [lock], ) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS def test_stale_break_stops_when_the_claim_is_already_registered( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") foreign: Final = record.replace(_HOST, "elsewhere") _break_name(lock).write_text(foreign, encoding="utf-8") mocker.patch("filelock._soft.secrets.token_hex", return_value="a" * 32) registered: Final = str(lock) + f".restore.{os.getpid()}." + "a" * 32 assert _begin_claim(registered) try: with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) finally: _end_claim(registered) assert (lock.read_text(encoding="utf-8"), _break_contents(lock)) == (record, [foreign]) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS def test_stale_break_claim_identity_ignores_path_spelling( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") foreign: Final = record.replace(_HOST, "elsewhere") _break_name(lock).write_text(foreign, encoding="utf-8") mocker.patch("filelock._soft.secrets.token_hex", return_value="a" * 32) registered: Final = Path(f"{lock}.restore.{os.getpid()}.{'a' * 32}") assert _begin_claim(registered) monkeypatch.chdir(tmp_path) try: with pytest.raises(Timeout): lock_type("./test.lock").acquire(blocking=False) finally: _end_claim(registered) assert (lock.read_text(encoding="utf-8"), _break_contents(lock)) == (record, [foreign]) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @pytest.mark.parametrize("interrupted", ["rename", "read", "exchange"]) @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_releases_the_registration_when_recovery_is_interrupted( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str, interrupted: str, mocker: MockerFixture ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") foreign: Final = record.replace(_HOST, "elsewhere") _break_name(lock).write_text(foreign, encoding="utf-8") rename: Final = Path.rename def rename_then_interrupt(path: Path, target: str | Path) -> Path: rename(path, target) raise KeyboardInterrupt def read_the_claim_then_interrupt(path: str) -> tuple[str | None, os.stat_result]: if ".restore." in path: raise KeyboardInterrupt return _read_lock_file(path) def exchange_then_interrupt(*_names: str) -> bool: raise KeyboardInterrupt target, interruption = { "rename": ("pathlib.Path.rename", rename_then_interrupt), "read": ("filelock._soft._read_lock_file", read_the_claim_then_interrupt), "exchange": ("filelock._soft.exchange_names", exchange_then_interrupt), }[interrupted] mocker.patch(target, new=interruption) with pytest.raises(KeyboardInterrupt): lock_type(lock).acquire(blocking=False) mocker.stopall() (claim,) = tmp_path.glob("test.lock.restore.*") released: Final = _begin_claim(claim) _end_claim(claim) with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert (released, lock.read_text(encoding="utf-8"), sorted(tmp_path.iterdir())) == (True, foreign, [lock]) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_undoes_an_exchange_that_took_a_peers_marker( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") foreign: Final = record.replace(_HOST, "elsewhere") _break_name(lock).write_text(foreign, encoding="utf-8") (peer := tmp_path / "peer").write_text(_live(record), encoding="utf-8") exchange: Final = exchange_names def peer_takes_the_path_then_exchange(first: str, second: str) -> bool: mocker.patch("filelock._soft.exchange_names", new=exchange) peer.replace(lock) return exchange(first, second) mocker.patch("filelock._soft.exchange_names", new=peer_takes_the_path_then_exchange) with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert ( lock.read_text(encoding="utf-8"), _break_contents(lock), list(tmp_path.glob("test.lock.restore.*")), ) == (_live(record), [foreign], []) def _lose_to_a_live_holder( # pragma: win32 no cover lock: Path, live: str, mocker: MockerFixture, then: Callable[[str, str], bool | None] ) -> None: (holder := lock.with_name("holder")).write_text(live, encoding="utf-8") def exchange_after_the_holder_won(aside: str, path: str) -> bool: mocker.patch("filelock._soft.exchange_names", new=exchange_names) holder.replace(lock) exchange_names(aside, path) then(aside, path) return True mocker.patch("filelock._soft.exchange_names", new=exchange_after_the_holder_won) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_exchanges_back_after_the_breaker_that_took_its_marker( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") (peer := tmp_path / "peer").write_text(record.replace(_HOST, "elsewhere"), encoding="utf-8") def peer_exchanges_back(_seconds: float) -> None: exchange_names(str(peer), str(lock)) peer.unlink() _lose_to_a_live_holder(lock, _live(record), mocker, lambda _aside, path: exchange_names(str(peer), path)) mocker.patch("filelock._util.time", wraps=time, sleep=mocker.Mock(side_effect=peer_exchanges_back)) with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert (lock.read_text(encoding="utf-8"), sorted(tmp_path.iterdir())) == (_live(record), [lock]) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_opens_a_ring_of_breakers_holding_each_others_markers( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") foreign: Final = record.replace(_HOST, "elsewhere") (peer := tmp_path / "peer").write_text(foreign, encoding="utf-8") def peer_then_our_exchange_out_of_turn(aside: str, path: str) -> None: exchange_names(str(peer), path) exchange_names(aside, path) def peer_exchanges_back_once_it_stands_at_the_path(_seconds: float) -> None: if lock.read_text(encoding="utf-8") == foreign: exchange_names(str(peer), str(lock)) peer.unlink() _lose_to_a_live_holder(lock, _live(record), mocker, peer_then_our_exchange_out_of_turn) mocker.patch( "filelock._util.time", wraps=time, sleep=mocker.Mock(side_effect=peer_exchanges_back_once_it_stands_at_the_path) ) with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert (lock.read_text(encoding="utf-8"), sorted(tmp_path.iterdir())) == (_live(record), [lock]) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_keeps_every_name_when_the_path_never_comes_back( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") foreign: Final = record.replace(_HOST, "elsewhere") (peer := tmp_path / "peer").write_text(foreign, encoding="utf-8") _lose_to_a_live_holder(lock, _live(record), mocker, lambda _aside, path: exchange_names(str(peer), path)) with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert sorted([lock.read_text(encoding="utf-8"), *_break_contents(lock)]) == sorted([foreign, _live(record)]) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_keeps_its_marker_when_exchange_back_is_refused( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") _lose_to_a_live_holder(lock, _live(record), mocker, lambda _aside, _path: None) mocker.patch("filelock._util.exchange_names", side_effect=[False, AssertionError("retried refused exchange")]) with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert (f"{os.getpid()}\n" in lock.read_text(encoding="utf-8"), _break_contents(lock)) == ( True, [_live(record)], ) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_drops_what_it_took_when_the_holder_released_the_path( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") _lose_to_a_live_holder(lock, _live(record), mocker, lambda _aside, path: Path(path).unlink()) with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert list(tmp_path.iterdir()) == [] @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_leaves_a_marker_taken_since_its_first_look_on_the_path( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture, on_break_recheck: Callable[[Path, Callable[[], None]], None], ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") (holder := tmp_path / "holder").write_text(_live(record), encoding="utf-8") exchange: Final = mocker.spy(filelock._soft, "exchange_names") def peer_takes_the_marker() -> None: holder.replace(lock) on_break_recheck(lock, peer_takes_the_marker) with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert (exchange.call_count, lock.read_text(encoding="utf-8"), sorted(tmp_path.iterdir())) == ( 0, _live(record), [lock], ) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_removes_a_stale_marker_a_dead_breaker_left_under_its_break_name( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") _break_name(lock).write_text(record.replace(str(_DEAD_PID), str(_DEAD_PID - 1)), encoding="utf-8") contender: Final = lock_type(lock) contender.acquire(blocking=False) assert (contender.is_locked, list(tmp_path.glob("test.lock.break.*"))) == (True, []) contender.release() @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_restores_a_strict_sentinel_a_dead_breaker_left_under_its_break_name( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") _break_name(lock).write_text(STRICT_SOFT_SENTINEL_RECORD, encoding="utf-8") with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert lock.read_text(encoding="utf-8") == STRICT_SOFT_SENTINEL_RECORD @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @pytest.mark.parametrize("lock_type", [MarkerSoftFileLock, SoftFileLease]) @NEEDS_STALE_RECLAIM def test_stale_break_restores_a_marker_of_an_unknown_contract_under_its_break_name( # pragma: win32 no cover tmp_path: Path, lock_type: type[MarkerSoftFileLock] ) -> None: (lock := tmp_path / "test.lock").write_text(DEAD_OWNER, encoding="utf-8") future: Final = DEAD_OWNER.replace(str(_DEAD_PID), str(_DEAD_PID - 1)).replace("exclusive", "future") _break_name(lock).write_text(future, encoding="utf-8") with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) assert lock.read_text(encoding="utf-8") == future @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @pytest.mark.usefixtures("unchanged_past_grace") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_restores_a_marker_a_dead_breaker_left_beside_its_placeholder( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str ) -> None: (lock := tmp_path / "test.lock").write_text(f"filelock/break\n{_DEAD_PID}\n", encoding="utf-8") _break_name(lock).write_text(_live(record), encoding="utf-8") contender: Final = lock_type(lock) for _ in range(2): with suppress(Timeout): contender.acquire(blocking=False) assert (contender.is_locked, lock.read_text(encoding="utf-8"), list(tmp_path.glob("test.lock.break.*"))) == ( False, _live(record), [], ) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @pytest.mark.usefixtures("unchanged_past_grace") @pytest.mark.parametrize( "placeholder", [ pytest.param("filelock/break\n", id="no-pid"), pytest.param("filelock/break\nnot-a-pid\n", id="not-a-pid"), pytest.param(f"filelock/break\n{_DEAD_PID}", id="torn-pid"), ], ) @NEEDS_STALE_RECLAIM def test_stale_break_evicts_a_placeholder_that_names_no_breaker( # pragma: win32 no cover tmp_path: Path, placeholder: str ) -> None: (lock := tmp_path / "test.lock").write_text(placeholder, encoding="utf-8") _break_name(lock).write_text(_holder(os.getpid()), encoding="utf-8") contender: Final = SoftFileLock(lock) for _ in range(2): with suppress(Timeout): contender.acquire(blocking=False) assert (contender.is_locked, _break_name(lock).exists()) == (True, True) contender.release() @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @pytest.mark.parametrize("error", [pytest.param(EACCES, id="denied"), pytest.param(EINVAL, id="unsupported")]) def test_stale_break_leaves_a_break_name_it_cannot_exchange( # pragma: win32 no cover lock_path: Path, mocker: MockerFixture, error: int ) -> None: lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") _break_name(lock_path).write_text(_holder(os.getpid()), encoding="utf-8") mocker.patch("filelock._util._rename_call", return_value=(mocker.MagicMock(return_value=error), 2)) with pytest.raises(Timeout): SoftFileLock(lock_path).acquire(blocking=False) assert (lock_path.read_text(encoding="utf-8"), _break_contents(lock_path)) == ( _holder(_DEAD_PID), [_holder(os.getpid())], ) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") def test_stale_break_waits_when_a_break_name_cannot_be_read( # pragma: win32 no cover lock_path: Path, mocker: MockerFixture ) -> None: lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") _break_name(lock_path).write_text(_holder(os.getpid()), encoding="utf-8") open_: Final = os.open def refuse_the_break_name(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: if ".restore." in os.fspath(path): raise OSError(EACCES, "Permission denied", path) return open_(path, flags, mode, dir_fd=dir_fd) mocker.patch.object(os, "open", new=refuse_the_break_name) with pytest.raises(Timeout): SoftFileLock(lock_path).acquire(blocking=False) mocker.stopall() assert (lock_path.read_text(encoding="utf-8"), _break_contents(lock_path)) == ( _holder(_DEAD_PID), [_holder(os.getpid())], ) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @pytest.mark.timeout(_PROCESS_DEADLINE * 2) @NEEDS_STALE_RECLAIM def test_stale_break_after_a_breaker_was_killed_keeps_the_holder_alone( # pragma: win32 no cover lock_path: Path, ) -> None: lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") context: Final = get_context("spawn") ready, go, exchanged = context.Event(), context.Event(), context.Event() breaker: Final = context.Process(target=break_then_hang_after_exchange, args=(str(lock_path), ready, go, exchanged)) holder: Final = SoftFileLock(lock_path) with cleanup_processes([breaker]): breaker.start() assert ready.wait(_PROCESS_DEADLINE) holder.acquire(blocking=False) go.set() assert exchanged.wait(_PROCESS_DEADLINE) breaker.terminate() breaker.join(_PROCESS_DEADLINE) with pytest.raises(Timeout): SoftFileLock(lock_path).acquire(blocking=False) assert (holder.is_locked, holder.is_lock_held_by_us) == (True, True) holder.release() @NEEDS_SYMLINK def test_symlinked_lock_file_is_not_followed(tmp_path: Path, lock_path: Path) -> None: # pragma: needs symlink target = tmp_path / "target" target.write_text(_holder(99999), encoding="utf-8") lock_path.symlink_to(target) # Neither the pid read nor stale detection may follow the symlink onto the target file. assert SoftFileLock(lock_path).pid is None assert target.read_text(encoding="utf-8") == _holder(99999) def test_fifo_lock_file_does_not_block(lock_path: Path) -> None: if sys.platform == "win32" or not CAPABILITIES["fifo"]: # pragma: win32 cover pytest.skip("os.mkfifo is unavailable") # the platform arm also narrows so ty resolves os.mkfifo below # An attacker-placed FIFO must not stall the open; O_NONBLOCK makes the read bail instead of hang. os.mkfifo(lock_path) # pragma: win32 no cover assert SoftFileLock(lock_path).pid is None # pragma: win32 no cover @pytest.mark.usefixtures("unchanged_past_grace") @NEEDS_STALE_RECLAIM def test_fifo_lock_file_with_attached_writer_self_heals(lock_path: Path) -> None: if sys.platform == "win32" or not CAPABILITIES["fifo"]: # pragma: win32 cover pytest.skip("os.mkfifo is unavailable") # the platform arm also narrows so ty resolves os.mkfifo below # A same-UID peer can plant a FIFO with a writer attached so a non-blocking read would raise EAGAIN. The lstat # guard classifies it as a malformed lock before any open, so an aged FIFO self-heals like any other node. os.mkfifo(lock_path) # pragma: win32 no cover reader = os.open(lock_path, os.O_RDONLY | os.O_NONBLOCK) # pragma: win32 no cover writer = os.open(lock_path, os.O_WRONLY | os.O_NONBLOCK) # pragma: win32 no cover try: # pragma: win32 no cover _assert_self_heals(lock_path) finally: os.close(reader) # pragma: win32 no cover os.close(writer) # pragma: win32 no cover @pytest.mark.usefixtures("unchanged_past_grace") @NEEDS_STALE_RECLAIM def test_socket_lock_file_self_heals(lock_path: Path) -> None: if sys.platform == "win32": # pragma: win32 cover pytest.skip("AF_UNIX sockets are unix-only") sock = socket.socket(socket.AF_UNIX) # pragma: win32 no cover try: # pragma: win32 no cover sock.bind(str(lock_path)) except OSError: # pragma: darwin cover sock.close() # pragma: darwin cover pytest.skip("AF_UNIX path too long for this temp dir") # pragma: darwin cover # A Unix-domain socket cannot be os.open()ed as a file. Before the lstat guard the failed open was swallowed by # stale detection so acquisition wedged; an aged socket now self-heals like any other non-regular node. try: # pragma: linux cover _assert_self_heals(lock_path) finally: sock.close() # pragma: linux cover def test_stale_detection_errors_suppressed(lock_path: Path, mocker: MockerFixture) -> None: lock_path.write_text(_holder(os.getpid()), encoding="utf-8") mock_read: MagicMock = mocker.patch("filelock._soft._read_lock_file", side_effect=OSError("read failed")) _assert_times_out(lock_path) mock_read.assert_called() @_WINDOWS_ONLY @pytest.mark.parametrize( ("captured_error", "ambient_error", "acquires"), [ pytest.param(_WIN_ERROR_INVALID_PARAMETER, _WIN_ERROR_ACCESS_DENIED, True, id="dead"), pytest.param(_WIN_ERROR_ACCESS_DENIED, _WIN_ERROR_INVALID_PARAMETER, False, id="access-denied"), ], ) def test_windows_stale_lock_uses_captured_process_error( # pragma: win32 cover lock_path: Path, mocker: MockerFixture, captured_error: int, ambient_error: int, *, acquires: bool, ) -> None: if sys.platform != "win32": # pragma: win32 cover pytest.skip("windows-only") # pragma: no cover # win32-only test; this guard never runs import ctypes def fail_open_process(_access: int, _inherit_handle: bool, _pid: int) -> None: ctypes.set_last_error(captured_error) ctypes.windll.kernel32.SetLastError(ambient_error) # ctypes function pointers do not expose a Python signature for autospec. mocker.patch("filelock._identity._KERNEL32.OpenProcess", side_effect=fail_open_process) lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") if acquires: _assert_self_heals(lock_path) else: _assert_times_out(lock_path) @_WINDOWS_ONLY def test_windows_stale_lock_broken_when_pid_recycled(lock_path: Path) -> None: # pragma: win32 cover process_marker = lock_path.with_suffix(".process") with SoftFileLock(process_marker): start = int(process_marker.read_text(encoding="utf-8").splitlines()[2]) lock_path.write_text(_holder(os.getpid(), start=start + 1), encoding="utf-8") _assert_self_heals(lock_path) @_WINDOWS_ONLY def test_windows_live_process_marker_retained(lock_path: Path) -> None: # pragma: win32 cover lock = SoftFileLock(lock_path) lock.acquire() try: _assert_times_out(lock_path) finally: lock.release() @_WINDOWS_ONLY def test_windows_live_process_marker_without_start_token_retained(lock_path: Path) -> None: # pragma: win32 cover lock_path.write_text(_holder(os.getpid()), encoding="utf-8") _assert_times_out(lock_path) @_WINDOWS_ONLY def test_windows_process_probe_closes_handles(lock_path: Path) -> None: # pragma: win32 cover lock = SoftFileLock(lock_path) lock.acquire() try: handle_count = _current_process_handle_count() for _attempt in range(50): _assert_times_out(lock_path, timeout=0) # A per-probe handle leak would add one handle per iteration, so ~50 over the loop. Assert no growth beyond a # small margin rather than exact equality, which another thread opening or closing a handle would break. assert _current_process_handle_count() <= handle_count + 5 finally: lock.release() @pytest.mark.parametrize( ("content", "expected"), [ pytest.param(None, None, id="no_file"), pytest.param(b"not-a-number\n", None, id="malformed"), pytest.param(b"\xff\xfe\n", None, id="non_utf8"), pytest.param(b"x" * (_MAX_LOCK_FILE_SIZE + 1), None, id="oversized"), pytest.param(b"42\n", None, id="single_line"), pytest.param(f"{os.getpid()}\n{_HOST}".encode(), None, id="truncated"), pytest.param(_holder(0).encode(), None, id="out_of_range_pid"), pytest.param(_holder(os.getpid()).encode(), os.getpid(), id="valid"), ], ) def test_pid(lock_path: Path, content: bytes | None, expected: int | None) -> None: if content is not None: lock_path.write_bytes(content) assert SoftFileLock(lock_path).pid == expected def test_pid_while_locked(lock_path: Path) -> None: lock = SoftFileLock(lock_path) with lock: assert lock.pid == os.getpid() @pytest.mark.parametrize( ("content", "expected"), [ pytest.param(None, False, id="no_file"), pytest.param(_holder(os.getpid() + 1), False, id="different_pid"), pytest.param(_holder(os.getpid()), True, id="same_pid"), pytest.param(_holder(os.getpid(), host="other-host"), False, id="same_pid_different_host"), ], ) def test_is_lock_held_by_us(lock_path: Path, content: str | None, expected: bool) -> None: if content is not None: lock_path.write_text(content, encoding="utf-8") assert SoftFileLock(lock_path).is_lock_held_by_us is expected @_REQUIRES_START_TOKEN @pytest.mark.parametrize( ("offset", "expected"), [pytest.param(0, True, id="matching_start_token"), pytest.param(1, False, id="recycled_pid")], ) def test_is_lock_held_by_us_checks_start_token(lock_path: Path, offset: int, *, expected: bool) -> None: # Acquisition breaks a marker with our PID but another start token as stale, so it must not read as ours either. token: Final = process_start_token(os.getpid()) assert token is not None lock_path.write_text(_holder(os.getpid(), start=token + offset), encoding="utf-8") assert SoftFileLock(lock_path).is_lock_held_by_us is expected def test_is_lock_held_by_us_fails_closed_without_own_start_token(lock_path: Path, mocker: MockerFixture) -> None: lock_path.write_text(_holder(os.getpid(), start=1), encoding="utf-8") mocker.patch("filelock._identity.process_start_token", return_value=None) assert SoftFileLock(lock_path).is_lock_held_by_us is False @pytest.mark.parametrize( "exists", [pytest.param(True, id="exists"), pytest.param(False, id="missing")], ) def test_break_lock(lock_path: Path, *, exists: bool) -> None: if exists: lock_path.write_text(_holder(os.getpid()), encoding="utf-8") SoftFileLock(lock_path).break_lock() assert not lock_path.exists() def test_write_failure_rolls_back_acquire(lock_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._util.os.write", side_effect=OSError(ENOSPC, "No space left on device")) lock = SoftFileLock(lock_path) with pytest.raises(OSError, match="No space left on device"): lock.acquire() assert not lock.is_locked assert not lock_path.exists() def _assert_self_heals(lock_path: Path) -> None: with held_after_reclaim(SoftFileLock(lock_path)) as lock: assert lock.is_locked def _assert_times_out(lock_path: Path, *, timeout: float = 0.1) -> None: with pytest.raises(TimeoutError): SoftFileLock(lock_path, timeout=timeout).acquire() def _current_process_handle_count() -> int: # pragma: win32 cover if sys.platform != "win32": # pragma: win32 cover pytest.skip("windows-only") # pragma: no cover # only ever called on win32, so this guard never runs import ctypes from ctypes import wintypes kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) kernel32.GetCurrentProcess.argtypes = [] kernel32.GetCurrentProcess.restype = wintypes.HANDLE kernel32.GetProcessHandleCount.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] kernel32.GetProcessHandleCount.restype = wintypes.BOOL count = wintypes.DWORD() assert kernel32.GetProcessHandleCount(kernel32.GetCurrentProcess(), ctypes.byref(count)), ctypes.WinError( ctypes.get_last_error() ) return count.value def _hold_reports_foreign_identity(mocker: MockerFixture) -> None: # Make the held descriptor report a different inode than the file on disk, as if a peer replaced the marker at the # path. fstat runs only on our own lock fd, so nothing else in acquire or release is disturbed. mocker.patch("filelock._soft.os.fstat", return_value=mocker.Mock(st_dev=1, st_ino=999)) def test_short_writes_still_write_the_whole_record(lock_path: Path, mocker: MockerFixture) -> None: real_write = os.write mocker.patch("filelock._util.os.write", side_effect=lambda fd, data: real_write(fd, bytes(data[:1]))) with SoftFileLock(lock_path): lines = lock_path.read_text(encoding="utf-8").splitlines() assert lines[0] == str(os.getpid()) assert lines[1] == _HOST def test_zero_write_rolls_back_acquire(lock_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._util.os.write", return_value=0) lock = SoftFileLock(lock_path) with pytest.raises(OSError, match="0 bytes"): lock.acquire() assert not lock.is_locked assert not lock_path.exists() @pytest.mark.skipif(sys.platform == "win32", reason="Windows unlinks a marker only after closing it") def test_failed_acquire_spares_a_successor_on_the_freed_inode_number( # pragma: win32 no cover lock_path: Path, mocker: MockerFixture, reuse_inode: Callable[[Path, os.stat_result], None] ) -> None: def evicted_and_succeeded_then_write_fails(fd: int, data: bytes) -> int: # A contender evicted the still-empty marker, and the next holder's marker gets its inode number once freed. ours: Final = os.fstat(fd) lock_path.unlink() lock_path.write_text("live", encoding="utf-8") os.utime(lock_path, ns=(ours.st_atime_ns, ours.st_mtime_ns)) reuse_inode(lock_path, ours) raise OSError(ENOSPC, f"No space left on device for {len(data)} bytes") mocker.patch("filelock._util.os.write", side_effect=evicted_and_succeeded_then_write_fails) with pytest.raises(OSError, match="No space left on device"): SoftFileLock(lock_path).acquire() mocker.stopall() assert lock_path.read_text(encoding="utf-8") == "live" @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @NEEDS_STALE_RECLAIM def test_failed_acquire_spares_a_file_that_replaced_its_placeholder( # pragma: win32 no cover lock_path: Path, mocker: MockerFixture ) -> None: mocker.patch("filelock._soft.os.fchmod", side_effect=OSError(EIO, "Input/output error")) lstat: Final = os.lstat replaced: list[bool] = [] def peer_replaces_the_placeholder(path: str | os.PathLike[str], *, dir_fd: int | None = None) -> os.stat_result: found: Final = lstat(path, dir_fd=dir_fd) if ".break." in os.fspath(path) and not replaced: replaced.append(True) (other := lock_path.with_name("other")).write_text("other", encoding="utf-8") other.replace(lock_path) return found mocker.patch.object(os, "lstat", new=peer_replaces_the_placeholder) with pytest.raises(OSError, match="Input/output error"): SoftFileLock(lock_path, mode=0o600).acquire() mocker.stopall() assert (lock_path.read_text(encoding="utf-8"), list(lock_path.parent.glob("test.lock.break.*"))) == ("other", []) def test_failed_acquire_cleanup_spares_a_replacement(lock_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._util.os.write", side_effect=OSError(ENOSPC, "No space left on device")) _hold_reports_foreign_identity(mocker) lock = SoftFileLock(lock_path) with pytest.raises(OSError, match="No space left on device"): lock.acquire() assert lock_path.exists() def test_publication_without_identity_leaves_the_marker(lock_path: Path, mocker: MockerFixture) -> None: # The rollback cannot prove the path still names the file it created, so it leaves the marker to stale handling. open_spy: Final = mocker.spy(os, "open") real_fstat: Final = os.fstat def no_identity(fd: int) -> os.stat_result: if fd == open_spy.spy_return: raise OSError(ENODEV, "no identity") return real_fstat(fd) mocker.patch("filelock._soft.os.fstat", autospec=True, side_effect=no_identity) lock: Final = SoftFileLock(lock_path) with pytest.raises(OSError, match="no identity"): lock.acquire() assert (lock.is_locked, lock_path.exists()) == (False, True) def test_transient_identity_failure_still_cleans_the_marker(lock_path: Path, mocker: MockerFixture) -> None: # Only the first read of the new marker's identity fails. The rollback reads it again through the descriptor it # still owns, so on Windows it recognizes the marker it created; an empty marker left there is never broken. real_fstat: Final = os.fstat calls: Final = itertools.count() def fail_first(fd: int) -> os.stat_result: if next(calls) == 0: raise OSError(EIO, "Input/output error") return real_fstat(fd) mocker.patch("filelock._soft.os.fstat", side_effect=fail_first) lock: Final = SoftFileLock(lock_path) with pytest.raises(OSError, match="Input/output error"): lock.acquire() assert (lock.is_locked, lock_path.exists()) == (False, False) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @pytest.mark.usefixtures("unchanged_past_grace") @NEEDS_STALE_RECLAIM def test_release_under_a_peers_exchange_leaves_no_marker_naming_this_process( # pragma: win32 no cover lock_path: Path, ) -> None: lock: Final = SoftFileLock(lock_path) lock.acquire() (peer := lock_path.with_name("peer")).write_text(_holder(_DEAD_PID), encoding="utf-8") exchange_names(str(lock_path), str(peer)) lock.release() exchange_names(str(lock_path), str(peer)) restored: Final = lock_path.read_text(encoding="utf-8") contender: Final = SoftFileLock(lock_path) for _ in range(2): with suppress(Timeout): contender.acquire(blocking=False) assert (restored.startswith(f"{os.getpid()}\n"), contender.is_locked) == (False, True) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @NEEDS_STALE_RECLAIM def test_release_under_a_peers_exchange_leaves_the_peers_marker(lock_path: Path) -> None: # pragma: win32 no cover lock: Final = SoftFileLock(lock_path) lock.acquire() (peer := lock_path.with_name("peer")).write_text(_holder(_DEAD_PID), encoding="utf-8") exchange_names(str(lock_path), str(peer)) lock.release() assert (lock_path.read_text(encoding="utf-8"), peer.read_text(encoding="utf-8")) == ( _holder(_DEAD_PID), "filelock/released\n", ) def test_release_without_identity_skips_unlink(lock_path: Path, mocker: MockerFixture) -> None: lock = SoftFileLock(lock_path) lock.acquire() held: Final = lock_path.stat() real_fstat: Final = os.fstat def no_identity(fd: int) -> os.stat_result: # The held descriptor can no longer be identified, so release cannot prove the path is still ours: it closes # and clears held state without unlinking rather than risk deleting a successor's marker. if os.path.samestat(found := real_fstat(fd), held): raise OSError return found mocker.patch("filelock._soft.os.fstat", side_effect=no_identity) lock.release() assert not lock.is_locked assert lock_path.exists() def test_normal_release_removes_own_marker(lock_path: Path) -> None: with SoftFileLock(lock_path): assert lock_path.exists() assert not lock_path.exists() def test_del_suppresses_a_release_error(lock_path: Path, mocker: MockerFixture) -> None: # A dropped, still-held lock is finalized by __del__. A release error there must not escape as an unraisable # exception during garbage collection, which pytest would fail on and attribute to an unrelated test. Finalizing # under a raising release and forcing collection here leaves the run clean only if __del__ swallows the error. lock = SoftFileLock(lock_path) lock.acquire() mocker.patch.object(SoftFileLock, "release", side_effect=OSError("release failed")) del lock gc.collect() @NEEDS_UNLINK_OPEN_FILE def test_stale_release_spares_a_successor(lock_path: Path) -> None: # pragma: needs unlink-open-file holder = SoftFileLock(lock_path) holder.acquire() # A peer breaks the stale marker and installs its own at the same path; unlink first so it gets a fresh inode. lock_path.unlink() lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") holder.release() assert lock_path.exists() assert str(_DEAD_PID) in lock_path.read_text(encoding="utf-8") @_WINDOWS_ONLY def test_windows_release_spares_a_replacement(lock_path: Path, mocker: MockerFixture) -> None: # pragma: win32 cover lock = SoftFileLock(lock_path) lock.acquire() _hold_reports_foreign_identity(mocker) lock.release() assert lock_path.exists() lock_path.unlink() @pytest.mark.parametrize( ("policy", "surfaces"), [ pytest.param("default", True, id="default"), pytest.param("raise", True, id="raise"), pytest.param("suppress", False, id="suppress"), ], ) def test_close_error_policy_cleans_marker( lock_path: Path, mocker: MockerFixture, policy: CloseErrorPolicy, *, surfaces: bool, ) -> None: lock = SoftFileLock(lock_path, close_error_policy=policy) lock.acquire() with _close_after_commit(mocker, lock) as (close_error, attempts): if surfaces: with pytest.raises(OSError, match="close failed") as info: lock.release() assert info.value is close_error else: lock.release() assert (len(attempts), lock.is_locked, lock.lock_counter, lock_path.exists()) == (1, False, 0, False) @pytest.mark.parametrize( ("depth", "force"), [pytest.param(2, False, id="nested"), pytest.param(2, True, id="forced")], ) def test_close_error_suppression_releases_once( lock_path: Path, mocker: MockerFixture, depth: int, *, force: bool, ) -> None: lock = SoftFileLock(lock_path, close_error_policy="suppress") for _acquisition in range(depth): lock.acquire() with _close_after_commit(mocker, lock) as (_, attempts): if force: lock.release(force=True) else: for _release in range(depth): lock.release() assert (len(attempts), lock.is_locked, lock.lock_counter, lock_path.exists()) == (1, False, 0, False) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) def test_context_surfaces_close_error_after_cleanup( lock_path: Path, mocker: MockerFixture, *, use_proxy: bool, ) -> None: lock = SoftFileLock(lock_path) with ( _close_after_commit(mocker, lock) as (close_error, attempts), pytest.raises(OSError, match="close failed") as info, lock.acquire() if use_proxy else lock, ): pass assert (info.value, len(attempts), lock.is_locked, lock_path.exists()) == (close_error, 1, False, False) def test_second_release_does_not_close_reused_descriptor( lock_path: Path, tmp_path: Path, mocker: MockerFixture, ) -> None: lock = SoftFileLock(lock_path) lock.acquire() with ( _close_after_commit(mocker, lock) as (close_error, attempts), pytest.raises(OSError, match="close failed") as info, ): lock.release() assert info.value is close_error reused_fd = os.open(tmp_path / "reused", os.O_CREAT | os.O_WRONLY) assert reused_fd == attempts[0] try: lock.release() os.fstat(reused_fd) finally: os.close(reused_fd) @NEEDS_UNLINK_OPEN_FILE # pragma: needs unlink-open-file def test_close_error_spares_successor_marker(lock_path: Path, mocker: MockerFixture) -> None: holder = SoftFileLock(lock_path) holder.acquire() lock_path.unlink() lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") with _close_after_commit(mocker, holder) as (_, _attempts), pytest.raises(OSError, match="close failed"): holder.release() assert lock_path.read_text(encoding="utf-8") == _holder(_DEAD_PID) @NEEDS_UNLINK_OPEN_FILE # pragma: needs unlink-open-file def test_released_record_write_error_spares_successor_marker(lock_path: Path, mocker: MockerFixture) -> None: holder: Final = SoftFileLock(lock_path) holder.acquire() lock_path.unlink() lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") mocker.patch("filelock._soft.os.pwrite", side_effect=PermissionError(EACCES, "record denied")) holder.release() assert not holder.is_locked assert lock_path.read_text(encoding="utf-8") == _holder(_DEAD_PID) @pytest.mark.skipif(sys.platform == "win32", reason="Windows closes the descriptor before it can unlink the marker") def test_marker_cleanup_failure_keeps_the_lock_held_for_a_retry( # pragma: win32 no cover lock_path: Path, mocker: MockerFixture ) -> None: lock: Final = SoftFileLock(lock_path) lock.acquire() cleanup_error: Final = RuntimeError("cleanup failed") unlink: Final = mocker.patch("filelock._soft.Path.unlink", autospec=True, side_effect=cleanup_error) with pytest.raises(RuntimeError) as info: lock.release() held: Final = (lock.is_locked, lock_path.exists()) mocker.stop(unlink) lock.release() assert (info.value, held, lock.is_locked, lock_path.exists()) == (cleanup_error, (True, True), False, False) @pytest.mark.skipif(sys.platform == "win32", reason="Windows always unlinks the marker after the close") def test_marker_unlink_refused_while_open_is_retried_after_the_close( # pragma: win32 no cover lock_path: Path, mocker: MockerFixture ) -> None: lock: Final = SoftFileLock(lock_path) lock.acquire() real_unlink: Final = Path.unlink attempts: Final[list[Path]] = [] def refuse_while_open(path: Path, missing_ok: bool = False) -> None: attempts.append(path) if len(attempts) == 1: raise PermissionError(EACCES, "file in use") real_unlink(path, missing_ok=missing_ok) mocker.patch.object(Path, "unlink", autospec=True, side_effect=refuse_while_open) lock.release() assert (lock.is_locked, lock_path.exists(), len(attempts)) == (False, False, 2) @pytest.mark.skipif(sys.platform == "win32", reason="Windows always unlinks the marker after the close") def test_marker_unlink_retried_after_the_close_spares_a_successor( # pragma: win32 no cover lock_path: Path, mocker: MockerFixture ) -> None: lock: Final = SoftFileLock(lock_path) lock.acquire() def refuse_while_open_and_successor_replaces(path: Path, missing_ok: bool = False) -> None: (successor := path.with_name("successor")).write_text("live", encoding="utf-8") successor.replace(path) raise PermissionError(EACCES, f"file in use, missing_ok={missing_ok}") mocker.patch.object(Path, "unlink", autospec=True, side_effect=refuse_while_open_and_successor_replaces) lock.release() mocker.stopall() assert (lock.is_locked, lock_path.read_text(encoding="utf-8")) == (False, "live") @pytest.mark.skipif(sys.platform != "win32", reason="POSIX unlinks the marker before the close, so both cannot fail") def test_close_and_marker_cleanup_failures_are_grouped( # pragma: win32 cover lock_path: Path, mocker: MockerFixture ) -> None: lock = SoftFileLock(lock_path) lock.acquire() cleanup_error = RuntimeError("cleanup failed") unlink_mock: Final = mocker.patch("filelock._soft.unlink_path", side_effect=cleanup_error) with _close_after_commit(mocker, lock) as (close_error, _attempts), pytest.raises(ExceptionGroup) as info: lock.release() mocker.stop(unlink_mock) assert ( info.value.message, info.value.exceptions, close_error.__context__, cleanup_error.__context__, lock.is_locked, ) == ( "lock descriptor close and marker cleanup both failed", (close_error, cleanup_error), None, None, False, ) @pytest.mark.parametrize( "replaced", [pytest.param(False, id="own-marker-removed"), pytest.param(True, id="replacement-spared")], ) def test_publication_rollback_close_failure_unlinks_only_its_marker( lock_path: Path, mocker: MockerFixture, *, replaced: bool ) -> None: mocker.patch("filelock._util.os.write", side_effect=OSError(ENOSPC, "No space left on device")) if replaced: _hold_reports_foreign_identity(mocker) lock: Final = SoftFileLock(lock_path) with ( _close_after_commit(mocker, lock) as (close_error, attempts), pytest.raises(OSError, match="close failed") as info, ): lock.acquire(timeout=0) assert (info.value, len(attempts), lock.is_locked, lock_path.exists()) == (close_error, 1, False, replaced) def test_close_after_commit_ignores_other_descriptors(lock_path: Path, mocker: MockerFixture) -> None: # The patch binds os.close process-wide, so a descriptor that is not the lock's, whether closed on another thread # or by a finalizer the collector happens to run on this one, must pass through cleanly and stay out of the # recorded attempts. lock = SoftFileLock(lock_path) lock.acquire() closed: list[int] = [] def close_a_pipe() -> None: read_fd, write_fd = os.pipe() os.close(read_fd) os.close(write_fd) closed.extend((read_fd, write_fd)) with _close_after_commit(mocker, lock) as (_close_error, attempts): close_a_pipe() worker = threading.Thread(target=close_a_pipe) worker.start() worker.join() with pytest.raises(OSError, match="close failed"): lock.release() assert (len(closed), len(attempts), attempts[0] in closed) == (4, 1, False) @contextmanager def _close_after_commit(mocker: MockerFixture, lock: SoftFileLock) -> Generator[tuple[OSError, list[int]]]: real_close = os.close close_error = OSError(EINTR, "close failed") attempts: list[int] = [] # close is an attribute of the shared os module, so this patch binds for every close in the process while the # window is open, including one a GC-run finalizer (a multiprocessing pipe from an earlier test, say) makes on this # very thread. Fail only the descriptor this lock lets go of: it clears its bookkeeping right before the one close # attempt, so record the descriptor as it does. released: list[int | None] = [] real_mark_released = lock._mark_descriptor_released def mark_released() -> None: context: Final = lock._context released.append(context.pending_lock_file_fd if context.lock_file_fd is None else context.lock_file_fd) real_mark_released() def close(fd: int) -> None: real_close(fd) if fd not in released: return attempts.append(fd) raise close_error mocker.patch.object(lock, "_mark_descriptor_released", side_effect=mark_released) close_mock = mocker.patch("filelock._soft.os.close", side_effect=close) try: yield close_error, attempts finally: mocker.stop(close_mock) def test_soft_windows_unlink_gives_up_after_every_attempt_is_denied(tmp_path: Path, mocker: MockerFixture) -> None: # Windows can still hold a handle just after close, so the marker unlink retries on EACCES. Deny every attempt to # prove the retry runs and then stops, and that the denial never escapes to the caller. from filelock._soft import _file_identity marker = tmp_path / "a" marker.write_text("x", encoding="utf-8") lock = SoftFileLock(marker) mocker.patch( "filelock._soft.time", new=SimpleNamespace(monotonic=time.monotonic, sleep=mocker.create_autospec(time.sleep)), ) unlink: Final = mocker.patch("filelock._soft.unlink_path", side_effect=PermissionError(EACCES, "handle still open")) lock._windows_unlink_if_ours(_file_identity(os.lstat(marker))) denied: Final = marker.exists() mocker.stop(unlink) # The marker is still ours, so the next acquire finishes the cleanup instead of waiting on it. with lock.acquire(timeout=0): assert denied @pytest.fixture def unchanged_past_grace(mocker: MockerFixture) -> None: # Each look at the marker lands a full grace window after the previous one on the observer's clock, so an unchanged # malformed marker ages out on the second look without the test sleeping through the window. mocker.patch( "filelock._soft.time", new=SimpleNamespace( monotonic=mocker.create_autospec( time.monotonic, side_effect=itertools.count(step=MALFORMED_LOCK_AGE_THRESHOLD) ), sleep=time.sleep, ), ) def test_unparseable_lock_with_an_ancient_mtime_not_evicted_on_first_sight(lock_path: Path) -> None: # The mtime carries the writer's or the file server's clock, so an ancient one may be a marker written a moment ago # by a host whose clock runs behind; only time watched on this process's own clock ages it out. lock_path.write_bytes(b"not-a-pid\n") os.utime(lock_path, (0, 0)) _assert_times_out(lock_path) @pytest.mark.parametrize( "content", [ pytest.param("1_000\nhost\n", id="pid-underscore"), pytest.param("\u0661\u0662\u0663\nhost\n", id="pid-arabic-indic-digits"), pytest.param("+1000\nhost\n", id="pid-sign"), pytest.param("1000\nhost\n1_0\n", id="start-underscore"), ], ) def test_lock_with_a_non_decimal_number_names_no_holder(lock_path: Path, content: str) -> None: # int() reads each of these as a number, but no filelock writes them, so they spell a malformed lock, not a PID. lock_path.write_text(content, encoding="utf-8") assert SoftFileLock(lock_path).pid is None @_WINDOWS_ONLY # pragma: win32 cover @pytest.mark.parametrize("oplocked_lock_path", [_holder(os.getpid())], indirect=True, ids=["live-marker"]) def test_soft_contender_keeps_its_deadline_under_an_oplocked_live_marker(oplocked_lock_path: Path) -> None: lock = SoftFileLock(oplocked_lock_path, is_singleton=False) finished, error = call_within(lambda: lock.acquire(timeout=0.1), 5) assert (finished, type(error)) == (True, Timeout) @_WINDOWS_ONLY # pragma: win32 cover def test_stale_break_keeps_its_deadline_under_an_oplocked_marker( lock_path: Path, hold_oplock: Callable[[Path], Callable[[], None]] ) -> None: lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") close_holder = hold_oplock(lock_path) lock = SoftFileLock(lock_path, is_singleton=False) finished, error = call_within(lambda: lock.acquire(timeout=0.1), 5) close_holder() assert (finished, type(error)) == (True, Timeout) with lock.acquire(timeout=5): assert lock.is_locked filelock-4.2.8/tests/test_strict_soft.py0000644000000000000000000004164415272561277015410 0ustar00from __future__ import annotations import errno import os import socket import time from errno import EBADF, ENOSYS, EXDEV from pathlib import Path from typing import TYPE_CHECKING, Final import pytest from capabilities import CAPABILITIES import filelock._strict from filelock import ( AsyncStrictSoftFileLock, SoftFileLock, SoftFileLockProtocolError, StrictSoftFileClaim, StrictSoftFileClaimState, StrictSoftFileLock, Timeout, ) from filelock._identity import host_name, process_start_token from filelock._strict import _PRIVATE_RECORD_MARKER, _probe_hard_link_unsupported_errnos from tests.capability_marks import NEEDS_FILE_PERMISSIONS if TYPE_CHECKING: from pytest_mock import MockerFixture _SENTINEL: Final[bytes] = b"1\nfilelock-strict-v1\x00\n0\n" pytestmark = pytest.mark.requires_hard_links def test_strict_soft_acquire_publishes_owner_claim(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" lock = StrictSoftFileLock(lock_path) with lock: held, intent = lock.claims owner = { "token": held.token, "pid": os.getpid(), "hostname": host_name(), "start": process_start_token(os.getpid()), } assert (held, intent) == ( StrictSoftFileClaim(name=held.name, state="held", **owner), StrictSoftFileClaim(name=intent.name, state="intent", **owner), ) assert (lock_path.read_bytes(), lock.claims, lock.is_locked) == (_SENTINEL, (), False) def test_strict_soft_is_reentrant(tmp_path: Path) -> None: lock = StrictSoftFileLock(tmp_path / "resource.lock") with lock: with lock: assert (lock.is_locked, lock.lock_counter, len(lock.claims)) == (True, 2, 2) assert (lock.is_locked, lock.lock_counter, len(lock.claims)) == (True, 1, 2) assert (lock.is_locked, lock.lock_counter, lock.claims) == (False, 0, ()) def test_strict_soft_contender_times_out(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" holder = StrictSoftFileLock(lock_path) with holder, pytest.raises(Timeout): StrictSoftFileLock(lock_path, timeout=0).acquire() with StrictSoftFileLock(lock_path, timeout=0) as acquired: assert acquired.is_locked def test_strict_soft_waits_for_legacy_holder(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" legacy = SoftFileLock(lock_path) strict = StrictSoftFileLock(lock_path, timeout=0) with legacy, pytest.raises(Timeout): strict.acquire() with strict: assert strict.is_locked def test_legacy_client_cannot_cross_permanent_sentinel(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" with StrictSoftFileLock(lock_path): pass os.utime(lock_path, (0, 0)) with pytest.raises(Timeout): SoftFileLock(lock_path, timeout=0).acquire() assert lock_path.read_bytes() == _SENTINEL def test_legacy_client_never_reclaims_permanent_sentinel(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" lock_path.write_bytes(_SENTINEL) os.utime(lock_path, (0, 0)) mocker.patch("filelock._identity.socket.gethostname", return_value="filelock-strict-v1\x00") mocker.patch("filelock._identity.os.kill", side_effect=ProcessLookupError) with pytest.raises(Timeout): SoftFileLock(lock_path, timeout=0).acquire() assert lock_path.read_bytes() == _SENTINEL def test_strict_soft_rejects_mutable_descriptor_hook(tmp_path: Path) -> None: with pytest.raises(ValueError, match="on_acquired is not supported"): StrictSoftFileLock(tmp_path / "resource.lock", on_acquired=lambda _fd: None) def test_strict_soft_preserves_sentinel_when_requested(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" with StrictSoftFileLock(lock_path, preserve_lock_file=True): pass assert lock_path.read_bytes() == _SENTINEL @pytest.mark.parametrize( "lock_type", [ pytest.param(StrictSoftFileLock, id="sync"), pytest.param(AsyncStrictSoftFileLock, id="async"), ], ) def test_strict_soft_constructor_rejects_lifetime( tmp_path: Path, lock_type: type[StrictSoftFileLock | AsyncStrictSoftFileLock], ) -> None: with pytest.warns(UserWarning, match=rf"lifetime is ignored for {lock_type.__name__}") as captured: lock = lock_type(tmp_path / "resource.lock", lifetime=1) assert (lock.lifetime, captured[0].filename) == (None, __file__) @pytest.mark.parametrize( "lock_type", [ pytest.param(StrictSoftFileLock, id="sync"), pytest.param(AsyncStrictSoftFileLock, id="async"), ], ) def test_strict_soft_setter_rejects_lifetime( tmp_path: Path, lock_type: type[StrictSoftFileLock | AsyncStrictSoftFileLock], ) -> None: lock = lock_type(tmp_path / "resource.lock") with pytest.warns(UserWarning, match=rf"lifetime is ignored for {lock_type.__name__}") as captured: lock.lifetime = 1 assert (lock.lifetime, captured[0].filename) == (None, __file__) @pytest.mark.parametrize( "claim_name", [ pytest.param("", id="empty"), pytest.param(".private.tmp", id="private"), pytest.param("../claim", id="parent"), pytest.param("directory/claim", id="slash"), pytest.param(r"directory\claim", id="backslash"), pytest.param("claim\0name", id="null"), ], ) def test_strict_soft_force_break_rejects_non_basename(tmp_path: Path, claim_name: str) -> None: with pytest.raises(ValueError, match="one public claim basename"): StrictSoftFileLock(tmp_path / "resource.lock").force_break(claim_name) def test_strict_soft_force_break_names_guarantee_loss(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" holder = StrictSoftFileLock(lock_path) holder.acquire() breaker = StrictSoftFileLock(lock_path) for claim in holder.claims: breaker.force_break(claim.name) with StrictSoftFileLock(lock_path, timeout=0) as contender: assert (holder.is_locked, contender.is_locked) == (True, True) holder.release() def test_strict_soft_force_break_requires_exact_case(tmp_path: Path) -> None: holder = StrictSoftFileLock(tmp_path / "resource.lock") holder.acquire() names = [claim.name for claim in holder.claims] with pytest.raises(FileNotFoundError): StrictSoftFileLock(holder.lock_file).force_break(names[0].upper()) assert [claim.name for claim in holder.claims] == names holder.release() def test_strict_soft_force_break_accepts_newer_version_claim(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) claim_name = "held-v2-opaque.claim" (claims / claim_name).write_text("filelock-strict-v2\n", encoding="ascii", newline="") lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(SoftFileLockProtocolError, match="unknown claim name or protocol version"): lock.acquire() lock.force_break(claim_name) with lock: assert lock.is_locked @pytest.mark.parametrize( ("claim_name", "content", "reason"), [ pytest.param("held-v0-dead.claim", b"old\n", "unknown claim name or protocol version", id="old-version"), pytest.param("unrecognized", b"unknown\n", "unknown claim name or protocol version", id="unknown-name"), pytest.param( "held-v1-gggggggggggggggggggggggggggggggg.claim", b"unknown\n", "unknown claim name or protocol version", id="invalid-token", ), pytest.param( "held-v1-00000000000000000000000000000000.claim", b"truncated\n", "malformed claim record", id="malformed-record", ), ], ) def test_strict_soft_invalid_claim_fails_closed(tmp_path: Path, claim_name: str, content: bytes, reason: str) -> None: lock_path = tmp_path / "resource.lock" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) (claims / claim_name).write_bytes(content) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(SoftFileLockProtocolError, match=reason) as raised: lock.acquire() assert (raised.value.lock_file, raised.value.claim_name, raised.value.reason, lock.is_locked) == ( str(lock_path), claim_name, reason, False, ) @pytest.mark.parametrize("state", [pytest.param("intent", id="intent"), pytest.param("held", id="held")]) def test_strict_soft_orphan_claim_blocks_without_reclamation(tmp_path: Path, state: StrictSoftFileClaimState) -> None: lock_path = tmp_path / "resource.lock" token = "0" * 32 claim_name = f"{state}-v1-{token}.claim" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) (claims / claim_name).write_text( f"filelock-strict-v1\n{token}\n{os.getpid()}\n{socket.gethostname().encode().hex()}\n4242\n", encoding="ascii", newline="", ) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert lock.claims == ( StrictSoftFileClaim( name=claim_name, state=state, token=token, pid=os.getpid(), hostname=socket.gethostname(), start=4242 ), ) def test_strict_soft_rejects_non_directory_coordination_path(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" Path(f"{lock_path}.filelock").write_text("not a directory", encoding="utf-8") with pytest.raises(SoftFileLockProtocolError, match="is not a real directory"): StrictSoftFileLock(lock_path).acquire() @NEEDS_FILE_PERMISSIONS # pragma: needs file-permissions def test_strict_soft_unreadable_claim_fails_closed(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" lock = StrictSoftFileLock(lock_path) lock.acquire() claim = Path(f"{lock_path}.filelock") / "claims" / lock.claims[0].name claim.chmod(0) try: with pytest.raises(SoftFileLockProtocolError, match="cannot read claim"): _ = StrictSoftFileLock(lock_path).claims finally: claim.chmod(0o600) lock.release() @pytest.mark.skipif(not CAPABILITIES["symlink"], reason="stages a claim as a symlink") def test_strict_soft_symlink_claim_fails_closed(tmp_path: Path) -> None: # pragma: needs symlink lock_path = tmp_path / "resource.lock" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) target = tmp_path / "claim-target" target.write_bytes(b"filelock-strict-v1\n" + b"0" * 32 + b"\n1\n686f7374\n") claim = claims / f"held-v1-{'0' * 32}.claim" claim.symlink_to(target) with pytest.raises(SoftFileLockProtocolError, match="cannot read claim"): StrictSoftFileLock(lock_path).acquire() @pytest.mark.parametrize( "error", [ pytest.param(OSError(EXDEV, "cross-device link"), id="filesystem"), pytest.param(OSError(ENOSYS, "link unavailable"), id="kernel"), pytest.param(NotImplementedError("hard links unavailable"), id="runtime"), ], ) def test_strict_soft_hard_link_failure_names_filesystem_contract( tmp_path: Path, mocker: MockerFixture, error: OSError | NotImplementedError ) -> None: mocker.patch("filelock._strict.os.link", side_effect=error) with pytest.raises(SoftFileLockProtocolError, match="atomic no-replace hard-link"): StrictSoftFileLock(tmp_path / "resource.lock").acquire() @pytest.mark.parametrize( ("defined", "expected"), [ pytest.param({"ENOTSUP": 4242, "EOPNOTSUPP": 4343}, {ENOSYS, EXDEV, 4242}, id="enotsup"), pytest.param({"EOPNOTSUPP": 4343}, {ENOSYS, EXDEV, 4343}, id="eopnotsupp"), pytest.param({}, {ENOSYS, EXDEV}, id="neither"), ], ) def test_strict_soft_hard_link_unsupported_errnos( monkeypatch: pytest.MonkeyPatch, defined: dict[str, int], expected: set[int] ) -> None: for name in ("ENOTSUP", "EOPNOTSUPP"): monkeypatch.delattr(errno, name, raising=False) for name, code in defined.items(): monkeypatch.setattr(errno, name, code, raising=False) assert _probe_hard_link_unsupported_errnos() == expected def test_strict_soft_write_failure_leaves_no_public_claim(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" with StrictSoftFileLock(lock_path): pass mocker.patch("filelock._util.os.write", side_effect=OSError("write failed")) lock = StrictSoftFileLock(lock_path) with pytest.raises(OSError, match="write failed"): lock.acquire() assert (lock.claims, tuple((Path(f"{lock_path}.filelock") / "claims").iterdir()), lock.is_locked) == ( (), (), False, ) def test_strict_soft_sentinel_inspection_failure_closes_descriptor(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" with StrictSoftFileLock(lock_path): pass real_fstat = os.fstat inspected_fd: int | None = None def fail_sentinel_inspection(fd: int) -> os.stat_result: nonlocal inspected_fd if os.lseek(fd, 0, os.SEEK_CUR) == len(_SENTINEL): inspected_fd = fd msg = "sentinel inspection failed" raise OSError(msg) return real_fstat(fd) mocker.patch("filelock._strict.os.fstat", side_effect=fail_sentinel_inspection) lock = StrictSoftFileLock(lock_path) with pytest.raises(OSError, match="sentinel inspection failed"): lock.acquire() assert inspected_fd is not None # The error message depends on the OS language; the closed-descriptor errno is stable. with pytest.raises(OSError, check=lambda error: error.errno == EBADF): real_fstat(inspected_fd) assert (lock.is_locked, lock.lock_counter, lock.claims) == (False, 0, ()) def test_strict_soft_publication_and_close_failures_preserve_both_errors(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" real_close = os.close real_open_relative = filelock._strict._open_relative private_fd: int | None = None def capture_private_record(directory_ref: tuple[str, int | None], name: str, flags: int, mode: int) -> int: nonlocal private_fd private_fd = real_open_relative(directory_ref, name, flags, mode) return private_fd # filelock._strict.os is the os module, so this patches os.close process-wide. Key the fault to the descriptor # under test rather than to call order, which a background thread's close would consume instead. def fail_private_close(fd: int) -> None: real_close(fd) # Only a dir_fd release closes a second descriptor, so elsewhere this one is the only one. if fd == private_fd: # pragma: no branch msg = "private close failed" raise OSError(msg) mocker.patch("filelock._util.os.write", side_effect=OSError("record write failed")) mocker.patch("filelock._strict._open_relative", side_effect=capture_private_record) mocker.patch("filelock._strict.os.close", side_effect=fail_private_close) lock = StrictSoftFileLock(lock_path) with pytest.raises(ExceptionGroup) as raised: lock.acquire() assert [str(error) for error in raised.value.exceptions] == ["record write failed", "private close failed"] assert (lock.is_locked, lock.lock_counter, tuple(tmp_path.glob("**/*.tmp"))) == (False, 0, ()) @pytest.mark.asyncio async def test_async_strict_soft_matches_claim_protocol(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" holder = AsyncStrictSoftFileLock(lock_path) async with holder: assert holder.claims[0].state == "held" with pytest.raises(Timeout): await AsyncStrictSoftFileLock(lock_path, timeout=0).acquire() async with AsyncStrictSoftFileLock(lock_path, timeout=0) as contender: assert contender.is_locked # The reacquire only needs proving where the holder's own open handle blocks the unlink; elsewhere release # removes the name outright and there is no sharing rule to exercise. @pytest.mark.skipif( CAPABILITIES["unlink-open-file"], reason="exercises pathname sharing where an open file resists unlink" ) def test_strict_soft_release_allows_reacquire(tmp_path: Path) -> None: # pragma: lacks unlink-open-file lock_path = tmp_path / "resource.lock" with StrictSoftFileLock(lock_path) as first: assert first.claims[0].state == "held" with StrictSoftFileLock(lock_path, timeout=0) as second: assert second.claims[0].state == "held" def test_strict_soft_reclaims_an_aged_sentinel_private_record(tmp_path: Path) -> None: # A crash between creating a private record and linking it leaves it behind; the next acquire reclaims it once # it ages out. The dir_fd reaper cases only proved this where dir_fd exists. lock_path = tmp_path / "resource.lock" stale = tmp_path / f".{lock_path.name}{_PRIVATE_RECORD_MARKER}{'0' * 32}.tmp" stale.write_bytes(b"") aged = time.time() - 3600 os.utime(stale, (aged, aged)) with StrictSoftFileLock(lock_path): assert not stale.exists() filelock-4.2.8/tests/test_strict_soft_compat.py0000644000000000000000000001064715272561277016752 0ustar00from __future__ import annotations import os import subprocess # ruff:ignore[suspicious-subprocess-import] # each client needs an isolated filelock installation import sys from typing import TYPE_CHECKING, Final, TextIO, cast import pytest from capabilities import CAPABILITIES from filelock import SoftFileLock, StrictSoftFileLock, Timeout if TYPE_CHECKING: from pathlib import Path _OLDEST_LEGACY_VERSION: Final[str] = "3.20.0" _OLD_CLIENT_PATH_VARIABLE: Final[str] = "FILELOCK_OLD_CLIENT_PATH" _OLD_HOLDER: Final[str] = """ import sys from filelock import SoftFileLock lock = SoftFileLock(sys.argv[1]) lock.acquire(timeout=5) print("acquired", flush=True) sys.stdin.readline() lock.release() print("released", flush=True) """ _OLD_PROBE: Final[str] = """ import sys from filelock import SoftFileLock, Timeout lock = SoftFileLock(sys.argv[1]) try: lock.acquire(timeout=0) except Timeout: print("blocked") else: lock.release() print("acquired") """ pytestmark = [ pytest.mark.requires_hard_links, pytest.mark.skipif( not CAPABILITIES["old-client"], reason=f"{_OLD_CLIENT_PATH_VARIABLE} must name an installed filelock {_OLDEST_LEGACY_VERSION}", ), ] @pytest.fixture(scope="module") def old_client_env() -> dict[str, str]: # pragma: needs old-client old_client_path = os.environ[_OLD_CLIENT_PATH_VARIABLE] env = os.environ.copy() # Prepend: the inherited PYTHONPATH carries the coverage plugin this child restarts under. env["PYTHONPATH"] = os.pathsep.join(part for part in (old_client_path, env.get("PYTHONPATH")) if part) installed = subprocess.run( [sys.executable, "-c", "import filelock; print(filelock.__version__)"], check=True, capture_output=True, cwd=old_client_path, env=env, text=True, ) assert installed.stdout.strip() == _OLDEST_LEGACY_VERSION return env def test_old_soft_holder_blocks_strict_client( tmp_path: Path, old_client_env: dict[str, str] ) -> None: # pragma: needs old-client lock_path = tmp_path / "resource.lock" with subprocess.Popen( [sys.executable, "-c", _OLD_HOLDER, os.fspath(lock_path)], cwd=tmp_path, env=old_client_env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) as holder: assert cast("TextIO", holder.stdout).readline() == "acquired\n" with pytest.raises(Timeout): StrictSoftFileLock(lock_path, timeout=0).acquire() stdout, stderr = holder.communicate("\n", timeout=5) assert (holder.returncode, stdout, stderr) == (0, "released\n", "") with StrictSoftFileLock(lock_path, timeout=0) as strict: assert strict.is_locked def test_old_soft_holder_blocks_soft_client( tmp_path: Path, old_client_env: dict[str, str] ) -> None: # pragma: needs old-client # The old release writes nothing into its marker; a current contender must wait it out past the malformed grace. lock_path = tmp_path / "resource.lock" with subprocess.Popen( [sys.executable, "-c", _OLD_HOLDER, os.fspath(lock_path)], cwd=tmp_path, env=old_client_env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) as holder: assert cast("TextIO", holder.stdout).readline() == "acquired\n" with pytest.raises(Timeout): SoftFileLock(lock_path, timeout=3).acquire() stdout, stderr = holder.communicate("\n", timeout=5) assert (holder.returncode, stdout, stderr) == (0, "released\n", "") def test_strict_activation_rejects_old_soft_client( tmp_path: Path, old_client_env: dict[str, str] ) -> None: # pragma: needs old-client lock_path = tmp_path / "resource.lock" with StrictSoftFileLock(lock_path): blocked = subprocess.run( [sys.executable, "-c", _OLD_PROBE, os.fspath(lock_path)], check=True, capture_output=True, cwd=tmp_path, env=old_client_env, text=True, ) after_release = subprocess.run( [sys.executable, "-c", _OLD_PROBE, os.fspath(lock_path)], check=True, capture_output=True, cwd=tmp_path, env=old_client_env, text=True, ) assert (blocked.stdout, blocked.stderr, after_release.stdout, after_release.stderr) == ( "blocked\n", "", "blocked\n", "", ) filelock-4.2.8/tests/test_strict_soft_failures.py0000644000000000000000000030453115272561277017277 0ustar00from __future__ import annotations import gc import os import pickle # ruff:ignore[suspicious-pickle-import] # round-trip uses bytes produced in this test import re import secrets import socket import stat import subprocess # ruff:ignore[suspicious-subprocess-import] # a finished child yields a PID that no longer runs import sys import threading import time from errno import EACCES, EEXIST, EIO, ENOENT, ENOSPC, ESTALE, EXDEV from pathlib import Path from types import SimpleNamespace from typing import TYPE_CHECKING, Final, NoReturn import pytest from capabilities import CAPABILITIES import filelock._strict from filelock import MarkerSoftFileLock, SoftFileLock, SoftFileLockProtocolError, StrictSoftFileLock, Timeout from filelock._identity import host_name from filelock._strict import _PRIVATE_RECORD_MARKER, _probe_link_follow_symlinks, _relative_identity from tests.capability_marks import ( NEEDS_COLLECTED_FINALIZATION, NEEDS_FOREIGN_STICKY_TMP, NEEDS_SYMLINK, NEEDS_UNLINK_OPEN_FILE, ) from tests.descriptor_helpers import descriptors_on # These cases inject a failure into a directory-fd cleanup step or the reaper race, both of which only run where the # protocol uses dir_fd descriptors. Without dir_fd those branches never execute and the injected fault never fires; the # cleanup path is covered there instead by the passing stress, contention and release tests. _NEEDS_DIR_FD: Final[pytest.MarkDecorator] = pytest.mark.skipif( os.open not in os.supports_dir_fd, reason="injects a fault into the dir_fd cleanup path, which a runtime without dir_fd never executes", ) # Narrower still: GraalPy takes os.open relative to a directory descriptor but not os.link, so the backend never opens # the directory these two faults are counted against. _NEEDS_LINK_DIR_FD: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["link-dir-fd"], reason="counts the directory the dir_fd link path opens, which a runtime without link dir_fd never opens", ) _NEEDS_FD_DIRECTORY: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["fd-directory"], reason="counting open descriptors needs a /dev/fd or /proc/self/fd view" ) # Only NTFS refuses every open of a name whose deletion is pending while the deleting handle stays open. _WINDOWS_ONLY: Final[pytest.MarkDecorator] = pytest.mark.skipif(sys.platform != "win32", reason="Windows only") if TYPE_CHECKING: from collections.abc import Callable, Iterator from unittest.mock import MagicMock from pytest_mock import MockerFixture _PathValue = str | bytes | os.PathLike[str] | os.PathLike[bytes] _UNPARSABLE_CONTENTS: Final = [pytest.param(b"", id="pre-3.22-empty"), pytest.param(b"4242\nhost", id="cut")] pytestmark = pytest.mark.requires_hard_links def test_strict_soft_protocol_error_pickles() -> None: error = SoftFileLockProtocolError("resource.lock", "held-v2-claim", "unknown version") restored = pickle.loads(pickle.dumps(error)) # ruff:ignore[suspicious-pickle-usage] # input comes from the preceding local value assert (type(restored), restored.lock_file, restored.claim_name, restored.reason, str(restored)) == ( SoftFileLockProtocolError, "resource.lock", "held-v2-claim", "unknown version", "Invalid strict soft-lock state at resource.lock: claim 'held-v2-claim': unknown version", ) def test_strict_soft_claims_empty_before_first_acquire(tmp_path: Path) -> None: assert StrictSoftFileLock(tmp_path / "resource.lock").claims == () def test_strict_soft_intent_token_collision_does_not_claim_lock(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) def collide_intent( _source: _PathValue, _destination: _PathValue, **_options: int | bool | None, ) -> None: # The intent link is the only link this flow attempts before it fails closed, so colliding it unconditionally # is equivalent to matching on the intent- prefix. raise FileExistsError(EEXIST, "claim exists") mocker.patch("filelock._strict.os.link", side_effect=collide_intent) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert (lock.claims, lock.is_locked) == ((), False) def test_strict_soft_held_publication_failure_rolls_back_intent(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_link = os.link def fail_held( source: _PathValue, destination: _PathValue, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: if Path(os.fsdecode(destination)).name.startswith("held-"): raise OSError(EXDEV, "hard links unavailable") real_link( source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks, ) mocker.patch("filelock._strict.os.link", side_effect=fail_held) lock = StrictSoftFileLock(lock_path) with pytest.raises(SoftFileLockProtocolError, match="hard-link publication"): lock.acquire() assert (lock.claims, lock.is_locked) == ((), False) def test_strict_soft_held_collision_does_not_delete_foreign_claim(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) token = "f" * 32 real_link = os.link def publish_foreign_held( source: _PathValue, destination: _PathValue, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: if Path(os.fsdecode(destination)).name.startswith("held-"): _write_foreign_claim(Path(f"{lock_path}.filelock") / "claims", token) raise FileExistsError(EEXIST, "foreign claim won") real_link( source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks, ) mocker.patch("filelock._strict.secrets.token_hex", return_value=token) mocker.patch("filelock._strict.os.link", side_effect=publish_foreign_held) lock = StrictSoftFileLock(lock_path) with pytest.raises(FileExistsError, match="foreign claim won"): lock.acquire() assert ([(claim.state, claim.start) for claim in lock.claims], lock.is_locked) == ([("held", 4242)], False) @pytest.mark.parametrize("state", ["intent", "held"]) def test_strict_soft_lost_link_reply_counts_as_committed(lock_path: Path, mocker: MockerFixture, state: str) -> None: real_link = os.link def link_then_report_exists( source: _PathValue, destination: _PathValue, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: # An NFS retransmit of a LINK whose reply was lost: the first request committed, the retry sees the name. real_link(source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks) if Path(os.fsdecode(destination)).name.startswith(f"{state}-"): raise FileExistsError(EEXIST, "retransmitted link") mocker.patch("filelock._strict.os.link", autospec=True, side_effect=link_then_report_exists) with (lock := StrictSoftFileLock(lock_path, timeout=0)): assert (lock.is_locked, sorted(claim.state for claim in lock.claims)) == (True, ["held", "intent"]) assert lock.claims == () @NEEDS_UNLINK_OPEN_FILE # pragma: needs unlink-open-file def test_strict_soft_lost_link_reply_survives_a_peer_reaping_the_source(lock_path: Path, mocker: MockerFixture) -> None: real_link: Final = os.link def link_then_peer_reaps( source: _PathValue, destination: _PathValue, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: real_link(source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks) if Path(os.fsdecode(destination)).name.startswith("intent-"): # A contender's scan reaps the private record as soon as its link count reaches two, before the # retransmitted LINK answers EEXIST. Path(f"{lock_path}.filelock", "claims", os.fsdecode(source)).unlink() raise FileExistsError(EEXIST, "retransmitted link") mocker.patch("filelock._strict.os.link", autospec=True, side_effect=link_then_peer_reaps) with (lock := StrictSoftFileLock(lock_path, timeout=0)): assert (lock.is_locked, sorted(claim.state for claim in lock.claims)) == (True, ["held", "intent"]) assert lock.claims == () @pytest.mark.parametrize("scan_to_empty", [pytest.param(2, id="intent"), pytest.param(3, id="held")]) def test_strict_soft_force_break_during_doorway_backs_off( tmp_path: Path, mocker: MockerFixture, scan_to_empty: int ) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_scandir = os.scandir scans = 0 def break_before_scan(path: int | str) -> Iterator[os.DirEntry[str]]: nonlocal scans scans += 1 if scans == scan_to_empty: for claim in Path(f"{lock_path}.filelock", "claims").glob("*.claim"): claim.unlink() return real_scandir(path) mocker.patch("filelock._strict.os.scandir", side_effect=break_before_scan) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert (lock.claims, lock.is_locked) == ((), False) def test_strict_soft_force_break_held_during_doorway_backs_off(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_scandir = os.scandir scans = 0 def break_held_before_final_scan(path: int | str) -> Iterator[os.DirEntry[str]]: nonlocal scans scans += 1 if scans == 3: for claim in Path(f"{lock_path}.filelock", "claims").glob("held-*.claim"): claim.unlink() return real_scandir(path) mocker.patch("filelock._strict.os.scandir", side_effect=break_held_before_final_scan) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert (lock.claims, lock.is_locked) == ((), False) @_NEEDS_DIR_FD # pragma: needs dir-fd def test_strict_soft_reaper_removes_private_before_publication(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) token = "0" * 32 private_path = _private_claim_path(lock_path, token) _reap_before_publication(lock_path, token, mocker) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert (lock.claims, lock.is_locked, private_path.exists()) == ((), False, False) def test_strict_soft_reaper_removes_private_after_publication(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) token = "0" * 32 private_path = _private_claim_path(lock_path, token) real_link = os.link def reap_after_link( source: _PathValue, destination: _PathValue, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: real_link( source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks, ) if Path(os.fsdecode(destination)).name.startswith("intent-"): assert len(StrictSoftFileLock(lock_path).claims) == 1 mocker.patch("filelock._strict.secrets.token_hex", return_value=token) mocker.patch("filelock._strict.os.link", side_effect=reap_after_link) with StrictSoftFileLock(lock_path, timeout=0) as lock: assert (lock.claims[0].state, private_path.exists()) == ("held", False) @_NEEDS_DIR_FD # pragma: needs dir-fd def test_strict_soft_reaper_replacement_only_aborts_publisher(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) token = "0" * 32 private_path = _private_claim_path(lock_path, token) displaced_path = private_path.with_suffix(".displaced") real_unlink = os.unlink replaced = False def replace_instead_of_unlink(path: _PathValue, *, dir_fd: int | None = None) -> None: # The reaper's clock probe and the peer's own records are the other os.unlink callers; let them through. nonlocal replaced if os.fsdecode(path) != private_path.name: real_unlink(path, dir_fd=dir_fd) return replaced = True private_path.replace(displaced_path) private_path.write_bytes(b"replacement publisher") _reap_before_publication(lock_path, token, mocker) mocker.patch("filelock._strict.os.unlink", autospec=True, side_effect=replace_instead_of_unlink) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert ( replaced, lock.is_locked, lock.claims, (private_path.parent / f"intent-v1-{token}.claim").exists(), private_path.read_bytes(), displaced_path.exists(), ) == ( True, False, (), False, b"replacement publisher", True, ) def _reap_before_publication(lock_path: Path, token: str, mocker: MockerFixture) -> None: # pragma: needs dir-fd real_link: Final = os.link peer_scanned = False def reap_before_link( source: _PathValue, destination: _PathValue, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: nonlocal peer_scanned # A peer's scan ages out the private record before this publisher links it; the peer's own links pass. if Path(os.fsdecode(destination)).name.startswith("intent-") and not peer_scanned: peer_scanned = True os.utime(_private_claim_path(lock_path, token), (0, 0)) with StrictSoftFileLock(lock_path, timeout=0): pass real_link(source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks) real_token_hex: Final = secrets.token_hex mocker.patch( "filelock._strict.secrets.token_hex", autospec=True, side_effect=lambda nbytes: real_token_hex(nbytes) if peer_scanned else token, ) mocker.patch("filelock._strict.os.link", autospec=True, side_effect=reap_before_link) def test_strict_soft_private_close_failure_removes_public_claim(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_close = os.close real_open_relative = filelock._strict._open_relative private_fd: int | None = None def capture_private_record(directory_ref: tuple[str, int | None], name: str, flags: int, mode: int) -> int: nonlocal private_fd fd = real_open_relative(directory_ref, name, flags, mode) if flags & os.O_CREAT: private_fd = fd return fd # filelock._strict.os is the os module, so this patches os.close process-wide. Key the fault to the descriptor # under test rather than to call order: a background thread closing first would otherwise consume it, which is how # a soft-rw heartbeat still winding down turned these injections red on GraalPy. def fail_private_close(fd: int) -> None: nonlocal private_fd real_close(fd) if fd == private_fd: private_fd = None # the cleanup that follows opens a directory under the same number raise OSError(EIO, "private close failed") mocker.patch("filelock._strict._open_relative", side_effect=capture_private_record) mocker.patch("filelock._strict.os.close", side_effect=fail_private_close) lock = StrictSoftFileLock(lock_path) with pytest.raises(OSError, match="private close failed"): lock.acquire() assert (lock.claims, lock.is_locked) == ((), False) @_NEEDS_DIR_FD # pragma: needs dir-fd def test_strict_soft_link_and_directory_close_failures_preserve_both(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_close = os.close real_fstat = os.fstat link_failed = False directory_close_failed = False def fail_link(_source: _PathValue, _destination: _PathValue, **_options: int | bool | None) -> None: nonlocal link_failed link_failed = True raise OSError(EIO, "link failed") def fail_directory_close(fd: int) -> None: nonlocal directory_close_failed is_directory = stat.S_ISDIR(real_fstat(fd).st_mode) real_close(fd) if is_directory and link_failed and not directory_close_failed: directory_close_failed = True raise OSError(EIO, "directory close failed") mocker.patch("filelock._strict.os.link", side_effect=fail_link) mocker.patch("filelock._strict.os.close", side_effect=fail_directory_close) lock = StrictSoftFileLock(lock_path) with pytest.raises(BaseExceptionGroup) as raised: lock.acquire() assert ([str(error) for error in raised.value.exceptions], lock.claims, lock.is_locked) == ( ["[Errno 5] link failed", "[Errno 5] directory close failed"], (), False, ) @_NEEDS_DIR_FD # pragma: needs dir-fd @_NEEDS_LINK_DIR_FD # pragma: needs link-dir-fd def test_strict_soft_held_directory_close_failure_removes_both_claims(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) _fail_directory_close_after(mocker, "link", "held-", "held directory close failed") lock = StrictSoftFileLock(lock_path) with pytest.raises(OSError, match="held directory close failed"): lock.acquire() assert (lock.claims, lock.is_locked) == ((), False) @pytest.mark.parametrize( "node", [ pytest.param("directory", id="directory"), pytest.param( "symlink", id="symlink", marks=pytest.mark.skipif(not CAPABILITIES["symlink"], reason="stages the private record as a symlink"), ), ], ) def test_strict_soft_reaper_leaves_non_regular_private_node(tmp_path: Path, node: str) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) private_path = _private_claim_path(lock_path, "0" * 32) if node == "directory": private_path.mkdir() else: private_path.symlink_to(tmp_path / "missing") # pragma: needs symlink with pytest.raises(SoftFileLockProtocolError, match="cannot list claim directory"): _ = StrictSoftFileLock(lock_path).claims assert private_path.is_dir() if node == "directory" else private_path.is_symlink() @pytest.mark.parametrize( "node", [ pytest.param("directory", id="directory"), pytest.param( "symlink", id="symlink", marks=pytest.mark.skipif(not CAPABILITIES["symlink"], reason="stages the private record as a symlink"), ), ], ) def test_strict_soft_non_regular_sentinel_private_node_fails_closed(tmp_path: Path, node: str) -> None: lock_path = tmp_path / "resource.lock" private_path = tmp_path / f".{lock_path.name}{_PRIVATE_RECORD_MARKER}{'0' * 32}.tmp" if node == "directory": private_path.mkdir() else: private_path.symlink_to(tmp_path / "missing") # pragma: needs symlink with pytest.raises(SoftFileLockProtocolError, match="cannot reclaim sentinel private records"): StrictSoftFileLock(lock_path, timeout=0).acquire() assert private_path.is_dir() if node == "directory" else private_path.is_symlink() def test_strict_soft_reaper_does_not_retry_sharing_error( abandoned_record: tuple[Path, Path], mocker: MockerFixture ) -> None: lock_path, private_path = abandoned_record real_unlink: Final = os.unlink def deny_abandoned_record(path: _PathValue, *, dir_fd: int | None = None) -> None: if Path(os.fsdecode(path)).name == private_path.name: raise PermissionError(EACCES, "sharing violation") real_unlink(path, dir_fd=dir_fd) unlink = mocker.patch("filelock._strict.os.unlink", autospec=True, side_effect=deny_abandoned_record) sleep: Final = mocker.create_autospec(time.sleep) _patch_strict_clock(mocker, sleep=sleep) # A contender blocked by a foreign claim ages the record out in its scan without removing a claim of its own. _write_foreign_claim(Path(f"{lock_path}.filelock") / "claims", "f" * 32) with pytest.raises(Timeout): StrictSoftFileLock(lock_path, timeout=0).acquire() reaper_unlinks = [Path(os.fsdecode(call.args[0])).name for call in unlink.call_args_list].count(private_path.name) assert (reaper_unlinks, sleep.call_count, private_path.exists()) == (1, 0, True) @pytest.mark.parametrize("record", ["claim", "sentinel"]) def test_strict_soft_reaper_ages_records_by_filesystem_clock( tmp_path: Path, mocker: MockerFixture, record: str ) -> None: lock_path = tmp_path / "resource.lock" if record == "claim": _initialize_protocol(lock_path) private_path = _private_claim_path(lock_path, "0" * 32) else: private_path = tmp_path / f".{lock_path.name}{_PRIVATE_RECORD_MARKER}{'0' * 32}.tmp" private_path.write_bytes(b"in-flight publication") # A client clock an hour ahead of the filesystem's would read the fresh record as abandoned. _patch_strict_clock(mocker, wall=lambda: time.time() + 3600) with StrictSoftFileLock(lock_path, timeout=0): assert private_path.read_bytes() == b"in-flight publication" def test_strict_soft_reaper_without_filesystem_clock_keeps_records( abandoned_record: tuple[Path, Path], mocker: MockerFixture ) -> None: lock_path, private_path = abandoned_record real_open: Final = os.open def deny_clock_probe(path: _PathValue, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: if Path(os.fsdecode(path)).name.startswith(".held-"): raise PermissionError(EACCES, "read-only observer") return real_open(path, flags, mode, dir_fd=dir_fd) mocker.patch("filelock._strict.os.open", autospec=True, side_effect=deny_clock_probe) # A contender blocked by a foreign claim scans with the clock probe without publishing a claim of its own. _write_foreign_claim(Path(f"{lock_path}.filelock") / "claims", "f" * 32) with pytest.raises(Timeout): StrictSoftFileLock(lock_path, timeout=0).acquire() assert private_path.exists() def test_strict_soft_claims_inspection_creates_no_file( abandoned_record: tuple[Path, Path], mocker: MockerFixture ) -> None: lock_path, private_path = abandoned_record opened: Final = mocker.spy(os, "open") assert StrictSoftFileLock(lock_path).claims == () assert ([call.args for call in opened.call_args_list if call.args[1] & os.O_CREAT], private_path.exists()) == ( [], True, ) def _fail_probe_stamp(mocker: MockerFixture) -> None: real_write: Final = os.write def enospc_on_probe(fd: int, data: bytes) -> int: if data == b"\n": raise OSError(ENOSPC, "No space left on device") return real_write(fd, data) mocker.patch("os.write", autospec=True, side_effect=enospc_on_probe) def _fail_probe_removal(mocker: MockerFixture) -> None: real_unlink: Final = os.unlink def eio_on_probe(path: _PathValue, *, dir_fd: int | None = None) -> None: if Path(os.fsdecode(path)).name.startswith(".held-v1-"): raise OSError(EIO, "probe unlink failed") real_unlink(path, dir_fd=dir_fd) mocker.patch("filelock._strict.os.unlink", autospec=True, side_effect=eio_on_probe) # An unstamped probe reads no time, so the record's age is untrusted; a probe left behind still read the time. @pytest.mark.parametrize( ("fail_probe", "kept"), [pytest.param(_fail_probe_stamp, True, id="stamp"), pytest.param(_fail_probe_removal, False, id="remove")], ) def test_strict_soft_failed_clock_probe_does_not_fail_the_scan( abandoned_record: tuple[Path, Path], mocker: MockerFixture, fail_probe: Callable[[MockerFixture], None], *, kept: bool, ) -> None: lock_path, private_path = abandoned_record fail_probe(mocker) with StrictSoftFileLock(lock_path, timeout=0): assert private_path.exists() is kept @_NEEDS_DIR_FD # pragma: needs dir-fd def test_strict_soft_release_error_keeps_remaining_claim(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" lock = StrictSoftFileLock(lock_path) lock.acquire() claim_name = lock.claims[0].name real_unlink = Path.unlink failed = False def fail_once(path: Path, *, missing_ok: bool = False) -> None: nonlocal failed if not failed: failed = True assert path.name == claim_name raise PermissionError(EACCES, "claim is not writable") real_unlink(path, missing_ok=missing_ok) mocker.patch("filelock._strict._UNLINK_SUPPORTS_DIR_FD", new=False) mocker.patch.object(Path, "unlink", autospec=True, side_effect=fail_once) with pytest.raises(PermissionError, match="claim is not writable"): lock.release() assert (lock.is_locked, [claim.name for claim in lock.claims]) == (True, [claim_name]) lock.release() assert (lock.is_locked, lock.claims) == (False, ()) @_NEEDS_DIR_FD # pragma: needs dir-fd def test_strict_soft_release_commits_before_directory_close_error(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" lock = StrictSoftFileLock(lock_path) lock.acquire() _fail_directory_close_after(mocker, "unlink", "held-", "directory close failed") with pytest.raises(OSError, match="directory close failed"): lock.release() mocker.stopall() assert (lock.is_locked, lock.lock_counter, lock.claims) == (False, 0, ()) with StrictSoftFileLock(lock_path, timeout=0) as contender: assert contender.is_locked @_NEEDS_DIR_FD # pragma: needs dir-fd def test_strict_soft_release_preserves_unlink_and_directory_close_errors(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" lock = StrictSoftFileLock(lock_path) lock.acquire() real_close = os.close real_fstat = os.fstat real_unlink = os.unlink unlink_failed = False directory_close_failed = False def fail_claim_unlink(path: _PathValue, *, dir_fd: int | None = None) -> None: nonlocal unlink_failed if Path(os.fsdecode(path)).name.startswith("held-"): unlink_failed = True raise PermissionError(EACCES, "claim unlink failed") real_unlink(path, dir_fd=dir_fd) def fail_directory_close(fd: int) -> None: nonlocal directory_close_failed is_directory = stat.S_ISDIR(real_fstat(fd).st_mode) real_close(fd) if is_directory and unlink_failed and not directory_close_failed: directory_close_failed = True raise OSError(EIO, "directory close failed") unlink_mock = mocker.patch("filelock._strict.os.unlink", side_effect=fail_claim_unlink) supports_dir_fd_mock = mocker.patch.object(os, "supports_dir_fd", os.supports_dir_fd | {unlink_mock}) close_mock = mocker.patch("filelock._strict.os.close", side_effect=fail_directory_close) with pytest.raises(BaseExceptionGroup) as raised: lock.release() mocker.stop(unlink_mock) mocker.stop(supports_dir_fd_mock) mocker.stop(close_mock) assert ( [str(error) for error in raised.value.exceptions], lock.is_locked, lock.lock_counter, [claim.state for claim in lock.claims], ) == ( ["[Errno 13] claim unlink failed", "[Errno 5] directory close failed"], True, 1, ["held"], ) lock.release() @_NEEDS_DIR_FD # pragma: needs dir-fd def test_strict_soft_doorway_preserves_every_claim_cleanup_error(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) owner_token = "f" * 32 competitor_token = "0" * 32 competitor_name = f"held-v1-{competitor_token}.claim" real_close = os.close real_fstat = os.fstat real_scandir = os.scandir real_unlink = os.unlink cleanup_name: str | None = None competitor_inserted = False def add_competitor_before_final_scan(path: int | str) -> Iterator[os.DirEntry[str]]: nonlocal competitor_inserted claim_directory = Path(f"{lock_path}.filelock", "claims") if not competitor_inserted and (claim_directory / f"held-v1-{owner_token}.claim").exists(): competitor_inserted = True (claim_directory / competitor_name).write_text( f"filelock-strict-v1\n{competitor_token}\n{os.getpid()}\n{socket.gethostname().encode().hex()}\n4242\n", encoding="ascii", newline="", ) return real_scandir(claim_directory) return real_scandir(path) def fail_held_unlink(path: _PathValue, *, dir_fd: int | None = None) -> None: nonlocal cleanup_name cleanup_name = Path(os.fsdecode(path)).name if cleanup_name == f"held-v1-{owner_token}.claim": raise PermissionError(EACCES, "held unlink failed") real_unlink(path, dir_fd=dir_fd) def fail_cleanup_directory_close(fd: int) -> None: nonlocal cleanup_name is_directory = stat.S_ISDIR(real_fstat(fd).st_mode) real_close(fd) if not is_directory: return # Only the first directory closed after an unlink is the one it ran in; the next walk closes others. unlinked, cleanup_name = cleanup_name, None if unlinked == f"intent-v1-{owner_token}.claim": raise OSError(EIO, "intent directory close failed") if unlinked == f"held-v1-{owner_token}.claim": raise OSError(EIO, "held directory close failed") mocker.patch("filelock._strict.secrets.token_hex", return_value=owner_token) mocker.patch("filelock._strict.os.scandir", side_effect=add_competitor_before_final_scan) unlink_mock = mocker.patch("filelock._strict.os.unlink", side_effect=fail_held_unlink) supports_dir_fd_mock = mocker.patch.object(os, "supports_dir_fd", os.supports_dir_fd | {unlink_mock}) close_mock = mocker.patch("filelock._strict.os.close", side_effect=fail_cleanup_directory_close) lock = StrictSoftFileLock(lock_path) with pytest.raises(BaseExceptionGroup) as raised: lock.acquire() mocker.stop(unlink_mock) mocker.stop(supports_dir_fd_mock) mocker.stop(close_mock) assert ( _leaf_error_messages(raised.value), [claim.name for claim in lock.claims], lock.is_locked, ) == ( [ "[Errno 13] held unlink failed", "[Errno 5] held directory close failed", "[Errno 5] intent directory close failed", ], [competitor_name, f"held-v1-{owner_token}.claim"], False, ) lock.force_break(competitor_name) with lock: assert len(lock.claims) == 2 @pytest.mark.parametrize( ("sentinel", "acquires"), [ pytest.param(b"1\nfilelock-strict-v1\x00\n0\n", True, id="strict-winner"), pytest.param(f"{os.getpid()}\n{host_name()}\n".encode(), False, id="legacy-winner"), ], ) def test_strict_soft_sentinel_publication_race( tmp_path: Path, mocker: MockerFixture, sentinel: bytes, *, acquires: bool ) -> None: lock_path = tmp_path / "resource.lock" real_link = os.link raced = False def publish_competitor( source: _PathValue, destination: _PathValue, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: nonlocal raced if not raced: raced = True lock_path.write_bytes(sentinel) raise FileExistsError(EEXIST, "sentinel exists") real_link( source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks, ) mocker.patch("filelock._strict.os.link", side_effect=publish_competitor) lock = StrictSoftFileLock(lock_path, timeout=0) if acquires: with lock: assert lock.is_locked else: with pytest.raises(Timeout): lock.acquire() assert lock_path.read_bytes() == sentinel def test_strict_soft_sentinel_uses_unique_private_names(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" private_names: list[str] = [] def reject_publication(source: _PathValue, _destination: _PathValue, **_options: int | bool | None) -> None: private_names.append(Path(os.fsdecode(source)).name) raise OSError(EIO, "publication failed") mocker.patch("filelock._strict.secrets.token_hex", side_effect=["0" * 32, "1" * 32]) mocker.patch("filelock._strict.os.link", side_effect=reject_publication) for _attempt in range(2): with pytest.raises(OSError, match="publication failed"): StrictSoftFileLock(lock_path).acquire() assert private_names == [ ".resource.lock.private-v1-00000000000000000000000000000000.tmp", ".resource.lock.private-v1-11111111111111111111111111111111.tmp", ] @pytest.mark.parametrize( ("occupant", "found"), [ pytest.param("directory", "a directory", id="directory"), pytest.param("symlink", "a symlink", id="symlink", marks=NEEDS_SYMLINK), pytest.param( "fifo", "a special file", id="fifo", marks=pytest.mark.skipif(not CAPABILITIES["fifo"], reason="needs os.mkfifo"), ), pytest.param("oversized", "a file it cannot read", id="oversized"), ], ) def test_strict_soft_damaged_lock_path_names_occupant(tmp_path: Path, occupant: str, found: str) -> None: lock_path = tmp_path / "resource.lock" match occupant: case "directory": lock_path.mkdir() case "symlink": lock_path.symlink_to(tmp_path / "missing") # pragma: needs symlink case "fifo": assert sys.platform != "win32" # pragma: needs fifo # narrows os.mkfifo for ty os.mkfifo(lock_path) # pragma: needs fifo case _: lock_path.write_bytes(b"x" * 4096) lock = StrictSoftFileLock(lock_path) with pytest.raises( SoftFileLockProtocolError, match=f"expected the strict sentinel at the lock path, found {found}" ): lock.acquire() assert (lock.lock_path_occupant or "").startswith(found) @pytest.mark.parametrize("content", _UNPARSABLE_CONTENTS) def test_strict_soft_unparsable_lock_path_blocks(tmp_path: Path, content: bytes) -> None: lock_path = tmp_path / "resource.lock" lock_path.write_bytes(content) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert (lock.lock_path_occupant, lock_path.read_bytes()) == ( "a file that is neither the sentinel nor a soft-lock marker it can parse", content, ) @pytest.mark.parametrize("content", _UNPARSABLE_CONTENTS) def test_strict_soft_unparsable_lock_path_raises_once_unchanged( tmp_path: Path, mocker: MockerFixture, content: bytes ) -> None: lock_path = tmp_path / "resource.lock" lock_path.write_bytes(content) _patch_strict_clock(mocker, monotonic=mocker.create_autospec(time.monotonic, side_effect=[0.0, 2.0])) lock = StrictSoftFileLock(lock_path, poll_interval=0) with pytest.raises(SoftFileLockProtocolError, match="marker it can parse, unchanged for 2 seconds"): lock.acquire() assert lock_path.read_bytes() == content def test_strict_soft_unreadable_lock_path_blocks(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" lock_path.write_bytes(b"") real_open = os.open def sharing_violation(path: _PathValue, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: # Windows refuses to open a marker its legacy holder keeps open. if Path(os.fsdecode(path)).name == lock_path.name: raise PermissionError(EACCES, "sharing violation") return real_open(path, flags, mode, dir_fd=dir_fd) # pragma: no cover # an occupied path publishes nothing _route_claim_opens_through_os_open(mocker) mocker.patch("filelock._strict.os.open", autospec=True, side_effect=sharing_violation) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert lock.lock_path_occupant == "a file it cannot read (sharing violation)" def test_strict_soft_exited_soft_lock_owner_raises_without_waiting(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" pid = subprocess.run( [sys.executable, "-c", "import os; print(os.getpid())"], check=True, capture_output=True, text=True ).stdout.strip() lock_path.write_text(f"{pid}\n{host_name()}\n", encoding="ascii") found = f"a soft-lock marker left by pid {pid} on {host_name()}, which has exited" sleep: Final = mocker.create_autospec(time.sleep) _patch_strict_clock(mocker, sleep=sleep) lock = StrictSoftFileLock(lock_path) with pytest.raises(SoftFileLockProtocolError, match=re.escape(f"found {found}")): lock.acquire() assert (sleep.call_count, lock.lock_path_occupant, lock_path.exists()) == (0, found, True) @pytest.mark.parametrize( "legacy", [pytest.param(SoftFileLock, id="soft"), pytest.param(MarkerSoftFileLock, id="marker")] ) def test_strict_soft_live_soft_lock_owner_blocks(tmp_path: Path, legacy: type[SoftFileLock]) -> None: lock_path = tmp_path / "resource.lock" with legacy(lock_path): lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert (lock.claims, lock.lock_path_occupant) == ( (), f"a soft-lock marker held by pid {os.getpid()} on {host_name()}", ) def test_strict_soft_lock_path_occupant_is_none_for_the_sentinel(tmp_path: Path) -> None: lock = StrictSoftFileLock(tmp_path / "resource.lock") before = lock.lock_path_occupant with lock: assert (before, lock.lock_path_occupant) == (None, None) def test_strict_soft_sentinel_removed_after_publication_backs_off(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" real_publish = filelock._strict._publish_record def legacy_break_lock_after_publication(public_path: str, record: bytes, mode: int) -> BaseException | None: # With timeout=0 the acquire gives up before any claim, so the sentinel is the only record published. cleanup_error = real_publish(public_path, record, mode) Path(public_path).unlink() return cleanup_error mocker.patch("filelock._strict._publish_record", autospec=True, side_effect=legacy_break_lock_after_publication) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert (lock.is_locked, lock_path.exists()) == (False, False) def test_strict_soft_sentinel_vanishing_before_open_is_republished(lock_path: Path, mocker: MockerFixture) -> None: real_open = os.open vanished = False def vanish_once(path: _PathValue, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: # A legacy break_lock() removes the sentinel between the inspection's lstat and its open. nonlocal vanished if not vanished and Path(os.fsdecode(path)).name == lock_path.name: vanished = True raise FileNotFoundError(ENOENT, "sentinel removed", os.fsdecode(path)) return real_open(path, flags, mode, dir_fd=dir_fd) _route_claim_opens_through_os_open(mocker) mocker.patch("filelock._strict.os.open", autospec=True, side_effect=vanish_once) with StrictSoftFileLock(lock_path, timeout=0) as lock: assert (vanished, lock.is_locked) == (True, True) def test_strict_soft_rides_out_a_transient_non_sentinel(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" lock_path.mkdir() _patch_strict_clock( mocker, sleep=mocker.create_autospec(time.sleep, side_effect=lambda _seconds: lock_path.rmdir()) ) with StrictSoftFileLock(lock_path, timeout=0) as lock: assert lock.is_locked def test_strict_soft_sentinel_race_with_non_regular_winner_blocks(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" def publish_directory( _source: _PathValue, _destination: _PathValue, **_options: int | bool | None, ) -> None: lock_path.mkdir() raise FileExistsError(EEXIST, "sentinel exists") mocker.patch("filelock._strict.os.link", side_effect=publish_directory) with pytest.raises(SoftFileLockProtocolError, match="found a directory"): StrictSoftFileLock(lock_path, timeout=0).acquire() assert lock_path.is_dir() @_NEEDS_DIR_FD # pragma: needs dir-fd def test_strict_soft_sentinel_remains_after_private_cleanup_failure(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" real_unlink = os.unlink failed = False def fail_first_private_cleanup(path: _PathValue, *, dir_fd: int | None = None) -> None: nonlocal failed if not failed and Path(os.fsdecode(path)).name.startswith(".resource.lock.private-"): failed = True raise OSError(EIO, "private cleanup failed") real_unlink(path, dir_fd=dir_fd) mocker.patch("filelock._strict.os.unlink", side_effect=fail_first_private_cleanup) with pytest.raises(OSError, match="private cleanup failed"): StrictSoftFileLock(lock_path).acquire() with pytest.raises(Timeout): SoftFileLock(lock_path, timeout=0).acquire() with StrictSoftFileLock(lock_path, timeout=0) as strict: assert (lock_path.read_bytes(), strict.is_locked) == (b"1\nfilelock-strict-v1\x00\n0\n", True) def test_strict_soft_claim_directory_read_error_fails_closed(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) mocker.patch("filelock._strict.os.scandir", side_effect=PermissionError(EACCES, "cannot list")) with pytest.raises(SoftFileLockProtocolError, match="cannot list claim directory"): StrictSoftFileLock(lock_path).acquire() @pytest.mark.parametrize( "content", [ pytest.param(b"filelock-strict-v1\n" + b"0" * 32 + b"\n0\n00\n4242\n", id="zero-pid"), pytest.param(b"filelock-strict-v1\n" + b"1" * 32 + b"\n1\n00\n4242\n", id="wrong-token"), pytest.param(b"filelock-strict-v1\n" + b"0" * 32 + b"\n1\n686f7374\nnot-int\n", id="non-integer-start"), pytest.param(b"filelock-strict-v1\n" + b"0" * 32 + b"\n1\n686f7374\n-5\n", id="negative-start"), pytest.param(b"filelock-strict-v1\n" + b"0" * 32 + b"\n1\n686f7374\n007\n", id="noncanonical-start"), ], ) def test_strict_soft_structurally_invalid_record_fails_closed(tmp_path: Path, content: bytes) -> None: lock_path = tmp_path / "resource.lock" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) (claims / f"held-v1-{'0' * 32}.claim").write_bytes(content) with pytest.raises(SoftFileLockProtocolError, match="malformed claim record"): StrictSoftFileLock(lock_path).acquire() def test_strict_soft_accepts_maximum_pid(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" token = "0" * 32 claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) (claims / f"held-v1-{token}.claim").write_text( f"filelock-strict-v1\n{token}\n2147483647\n686f7374\n4242\n", encoding="ascii", newline="", ) assert StrictSoftFileLock(lock_path).claims[0].pid == 2**31 - 1 @pytest.mark.parametrize( ("pid", "hostname_hex"), [ pytest.param("01", "686f7374", id="leading-zero-pid"), pytest.param("2147483648", "686f7374", id="pid-above-signed-int"), pytest.param("1", "686F7374", id="uppercase-hostname-hex"), pytest.param("1", "20686f737420", id="spaced-hostname"), pytest.param("1", "686f737400", id="null-hostname"), pytest.param("1", "1b686f7374", id="escape-hostname"), ], ) def test_strict_soft_rejects_noncanonical_owner_record(tmp_path: Path, pid: str, hostname_hex: str) -> None: lock_path = tmp_path / "resource.lock" token = "0" * 32 claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) (claims / f"held-v1-{token}.claim").write_text( f"filelock-strict-v1\n{token}\n{pid}\n{hostname_hex}\n4242\n", encoding="ascii", newline="", ) with pytest.raises(SoftFileLockProtocolError, match="malformed claim record"): _ = StrictSoftFileLock(lock_path).claims @pytest.mark.parametrize( "node", [ pytest.param("directory", id="non-regular"), pytest.param("oversized", id="oversized"), ], ) def test_strict_soft_invalid_record_node_fails_closed(tmp_path: Path, node: str) -> None: lock_path = tmp_path / "resource.lock" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) claim = claims / f"held-v1-{'0' * 32}.claim" if node == "directory": claim.mkdir() else: claim.write_bytes(b"x" * 1025) with pytest.raises(SoftFileLockProtocolError, match="cannot read claim"): StrictSoftFileLock(lock_path).acquire() @pytest.mark.parametrize( ("replacement", "message"), [ pytest.param("file", "changed while opening", id="different-file"), pytest.param("directory", "is not a regular file", id="directory"), ], ) def test_strict_soft_claim_replaced_while_opening_fails_closed( tmp_path: Path, mocker: MockerFixture, replacement: str, message: str ) -> None: lock_path = tmp_path / "resource.lock" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) claim = claims / f"held-v1-{'0' * 32}.claim" record = b"filelock-strict-v1\n" + b"0" * 32 + b"\n1\n686f7374\n" claim.write_bytes(record) real_open = os.open replaced = False def replace_before_open( path: _PathValue, flags: int, mode: int = 0o777, *, dir_fd: int | None = None, ) -> int: nonlocal replaced if not replaced and Path(os.fsdecode(path)).name == claim.name: replaced = True claim.replace(claim.with_name(f".{claim.name}.replaced")) if replacement == "file": claim.write_bytes(record) else: claim.mkdir() return real_open(path, flags, mode, dir_fd=dir_fd) _route_claim_opens_through_os_open(mocker) mocker.patch("filelock._strict.os.open", side_effect=replace_before_open) with pytest.raises(SoftFileLockProtocolError, match=message): StrictSoftFileLock(lock_path).acquire() def _route_claim_opens_through_os_open(mocker: MockerFixture) -> None: # Windows opens a claim through NtCreateFile so its owner can still unlink it, which an os.open fault would miss. if sys.platform == "win32": # pragma: win32 cover mocker.patch( "filelock._strict._open_deletable_for_reading", side_effect=lambda path: os.open(path, os.O_RDONLY | getattr(os, "O_BINARY", 0)), ) def _write_held_claim(claims: Path) -> Path: claims.mkdir(parents=True) claim = claims / f"held-v1-{'0' * 32}.claim" claim.write_bytes(b"filelock-strict-v1\n" + b"0" * 32 + b"\n1\n686f7374\n\n") return claim def _open_raising_for(claim: Path, error: OSError, mocker: MockerFixture) -> MagicMock: real_open = os.open def failing_open(path: _PathValue, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: if Path(os.fsdecode(path)).name == claim.name: raise error # The flows using this helper read only the single target claim, so only the directory walk passes through. return real_open(path, flags, mode, dir_fd=dir_fd) # pragma: needs dir-fd _route_claim_opens_through_os_open(mocker) return mocker.patch("filelock._strict.os.open", side_effect=failing_open) def test_strict_soft_stale_claim_read_is_skipped(tmp_path: Path, mocker: MockerFixture) -> None: # An NFS peer that unlinks its own claim leaves this client's cached filehandle stale, so the open returns ESTALE # rather than ENOENT. A stale handle that outlives revalidation is a vanished claim, not an unreadable one. lock_path = tmp_path / "resource.lock" claim = _write_held_claim(Path(f"{lock_path}.filelock") / "claims") mocker.patch("filelock._strict._CLAIM_READ_GRACE", 0.02) _open_raising_for(claim, OSError(ESTALE, "Stale file handle"), mocker) assert StrictSoftFileLock(lock_path).claims == () def test_strict_soft_stale_cached_claim_is_skipped(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" claim = _write_held_claim(Path(f"{lock_path}.filelock") / "claims") mocker.patch("filelock._strict.os.link", autospec=True, side_effect=OSError(ESTALE, "Stale file handle")) assert filelock._strict._read_existing_claims(str(lock_path), claim.parent, verify_server=True) == () def test_strict_soft_live_claim_survives_server_probe(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" claim = _write_held_claim(Path(f"{lock_path}.filelock") / "claims") found = filelock._strict._read_existing_claims(str(lock_path), claim.parent, verify_server=True) assert [item.name for item in found] == [claim.name] assert [item.name for item in claim.parent.iterdir()] == [claim.name] def test_strict_soft_claim_probe_error_fails_closed(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" claim = _write_held_claim(Path(f"{lock_path}.filelock") / "claims") mocker.patch("filelock._strict.os.link", autospec=True, side_effect=OSError(EIO, "I/O error")) with pytest.raises(SoftFileLockProtocolError, match="cannot verify claim"): filelock._strict._read_existing_claims(str(lock_path), claim.parent, verify_server=True) def test_strict_soft_claim_probe_cleanup_error_fails_closed(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" claim = _write_held_claim(Path(f"{lock_path}.filelock") / "claims") mocker.patch("filelock._strict.os.unlink", autospec=True, side_effect=OSError(EIO, "I/O error")) with pytest.raises(SoftFileLockProtocolError, match="cannot remove claim probe"): filelock._strict._read_existing_claims(str(lock_path), claim.parent, verify_server=True) def test_strict_soft_claim_probe_removed_by_peer(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" claim = _write_held_claim(Path(f"{lock_path}.filelock") / "claims") real_unlink: Final = os.unlink def remove_before_cleanup(path: _PathValue, *, dir_fd: int | None = None) -> NoReturn: real_unlink(path, dir_fd=dir_fd) raise FileNotFoundError(ENOENT, "already removed") mocker.patch("filelock._strict.os.unlink", autospec=True, side_effect=remove_before_cleanup) assert [ item.name for item in filelock._strict._read_existing_claims(str(lock_path), claim.parent, verify_server=True) ] == [claim.name] def test_strict_soft_claim_probe_requires_hard_links(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" claim = _write_held_claim(Path(f"{lock_path}.filelock") / "claims") mocker.patch("filelock._strict.os.link", autospec=True, side_effect=NotImplementedError) with pytest.raises(SoftFileLockProtocolError, match="hard links unavailable"): filelock._strict._read_existing_claims(str(lock_path), claim.parent, verify_server=True) def test_strict_soft_abandoned_claim_probe_is_reclaimed(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" claim_directory = Path(f"{lock_path}.filelock") / "claims" claim_directory.mkdir(parents=True) probe = claim_directory / f".probe-v1-{'0' * 32}.tmp" probe.write_bytes(b"abandoned") os.utime(probe, (time.time() - 10, time.time() - 10)) with StrictSoftFileLock(lock_path, timeout=0): assert not probe.exists() def test_strict_soft_stale_claim_read_revalidates_to_gone(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" claim = _write_held_claim(Path(f"{lock_path}.filelock") / "claims") real_open = os.open stale_raised = False def stale_then_vanish(path: _PathValue, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: nonlocal stale_raised if not stale_raised and Path(os.fsdecode(path)).name == claim.name: stale_raised = True claim.unlink() raise OSError(ESTALE, "Stale file handle") # The retry re-lstats the vanished claim and fails there before reopening, so only the directory walk passes. return real_open(path, flags, mode, dir_fd=dir_fd) # pragma: needs dir-fd _route_claim_opens_through_os_open(mocker) mocker.patch("filelock._strict.os.open", side_effect=stale_then_vanish) assert StrictSoftFileLock(lock_path).claims == () assert stale_raised def test_strict_soft_io_error_claim_read_fails_closed(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" claim = _write_held_claim(Path(f"{lock_path}.filelock") / "claims") _open_raising_for(claim, OSError(EIO, "I/O error"), mocker) with pytest.raises(SoftFileLockProtocolError, match="cannot read claim"): _ = StrictSoftFileLock(lock_path).claims def test_strict_soft_directory_inspection_error_fails_closed(tmp_path: Path, mocker: MockerFixture) -> None: real_mkdir = Path.mkdir def fail_protocol_mkdir(path: Path, mode: int = 0o777, parents: bool = False, exist_ok: bool = False) -> None: if str(path).endswith(".filelock"): raise FileExistsError real_mkdir(path, mode=mode, parents=parents, exist_ok=exist_ok) mocker.patch.object(Path, "mkdir", autospec=True, side_effect=fail_protocol_mkdir) mocker.patch.object(Path, "lstat", autospec=True, side_effect=PermissionError(EACCES, "cannot inspect")) with pytest.raises(SoftFileLockProtocolError, match="cannot inspect"): StrictSoftFileLock(tmp_path / "resource.lock").acquire() @_NEEDS_DIR_FD # pragma: needs dir-fd @NEEDS_SYMLINK # pragma: needs symlink @pytest.mark.parametrize( ("function", "suffix", "occurrence", "match"), [ pytest.param("mkdir", "claims", 1, "cannot open claim directory", id="before-creating-claims"), pytest.param("open", ".filelock", 1, "cannot open .*filelock$", id="before-settling-the-mode"), pytest.param("open", ".filelock", 2, "cannot open .*filelock$", id="before-opening-coordination"), ], ) def test_strict_soft_coordination_directory_swapped_for_symlink_leaves_target_alone( tmp_path: Path, mocker: MockerFixture, function: str, suffix: str, occurrence: int, match: str ) -> None: lock_path: Final = tmp_path / "resource.lock" (coordination := Path(f"{lock_path}.filelock")).mkdir() (target := tmp_path / "target").mkdir() real: Final = getattr(os, function) matches = 0 def swap_at_the_occurrence(path: _PathValue, *args: int, **options: int | None) -> int | None: # A process that can rename entries in the lock's directory can swap the name after the lock saw a directory. nonlocal matches matches += os.fsdecode(path).endswith(suffix) if matches == occurrence and not coordination.is_symlink(): coordination.rename(tmp_path / "aside") coordination.symlink_to(target, target_is_directory=True) return real(path, *args, **options) mocker.patch(f"filelock._strict.os.{function}", autospec=True, side_effect=swap_at_the_occurrence) with pytest.raises(SoftFileLockProtocolError, match=match): StrictSoftFileLock(lock_path, timeout=0).acquire() assert list(target.iterdir()) == [] @_NEEDS_DIR_FD # pragma: needs dir-fd @NEEDS_SYMLINK # pragma: needs symlink def test_strict_soft_release_keeps_claims_while_coordination_directory_is_a_symlink( lock_path: Path, tmp_path: Path ) -> None: coordination, aside, target = Path(f"{lock_path}.filelock"), tmp_path / "aside", tmp_path / "target" (target / "claims").mkdir(parents=True) lock = StrictSoftFileLock(lock_path) lock.acquire() coordination.rename(aside) coordination.symlink_to(target, target_is_directory=True) # Following the symlink would find no claim in the target, count both as removed and strand them. with pytest.raises(BaseExceptionGroup, match="strict claim release failed"): lock.release() assert (lock.is_locked, len(list((aside / "claims").iterdir()))) == (True, 2) coordination.unlink() aside.rename(coordination) lock.release() assert (lock.is_locked, lock.claims) == (False, ()) @_NEEDS_DIR_FD # pragma: needs dir-fd @NEEDS_SYMLINK # pragma: needs symlink @pytest.mark.parametrize("symlink", ["resource.lock.filelock", "claims"]) def test_strict_soft_symlinked_protocol_directory_is_neither_listed_nor_broken(tmp_path: Path, symlink: str) -> None: lock_path = tmp_path / "resource.lock" claim = _write_held_claim(tmp_path / "target" / "claims") if symlink == "claims": Path(f"{lock_path}.filelock").mkdir() Path(f"{lock_path}.filelock", "claims").symlink_to(claim.parent, target_is_directory=True) else: Path(f"{lock_path}.filelock").symlink_to(claim.parent.parent, target_is_directory=True) lock = StrictSoftFileLock(lock_path) with pytest.raises(SoftFileLockProtocolError, match="cannot open claim directory"): _ = lock.claims with pytest.raises(OSError, match=re.escape(f"{symlink}'")): lock.force_break(claim.name) assert claim.exists() @NEEDS_FOREIGN_STICKY_TMP @pytest.mark.usefixtures("directories_of_another_account") def test_strict_soft_refuses_coordination_directory_of_another_account( # pragma: needs foreign-sticky-tmp lock_path_in_shared_tmp: Path, ) -> None: (coordination := Path(f"{lock_path_in_shared_tmp}.filelock")).mkdir() with pytest.raises(SoftFileLockProtocolError, match="not to this process's account"): StrictSoftFileLock(lock_path_in_shared_tmp, timeout=0).acquire() assert list(coordination.iterdir()) == [] def test_strict_soft_coordination_directory_replaced_in_a_writable_parent_splits_holders(tmp_path: Path) -> None: # The documented directory boundary: a process that can rename entries in a non-sticky parent can move even this # account's own protocol directory aside and start a fresh one, which the lock cannot tell from a first use. lock_path: Final = tmp_path / "resource.lock" (first := StrictSoftFileLock(lock_path)).acquire() Path(f"{lock_path}.filelock").rename(tmp_path / "aside") (second := StrictSoftFileLock(lock_path, timeout=0)).acquire() assert (first.is_locked, second.is_locked) == (True, True) @pytest.fixture def lock_path(tmp_path: Path) -> Path: _initialize_protocol(path := tmp_path / "resource.lock") return path @pytest.fixture def abandoned_record(lock_path: Path) -> tuple[Path, Path]: (private_path := _private_claim_path(lock_path, "0" * 32)).write_bytes(b"abandoned") os.utime(private_path, (0, 0)) return lock_path, private_path def _initialize_protocol(lock_path: Path) -> None: with StrictSoftFileLock(lock_path): pass def _private_claim_path(lock_path: Path, token: str) -> Path: return Path(f"{lock_path}.filelock") / "claims" / f".intent-v1-{token}.claim.private-v1-{token}.tmp" def _fail_directory_close_after( # pragma: needs dir-fd mocker: MockerFixture, function: str, prefix: str, message: str ) -> None: # Every claim operation walks to its directory through descriptors it closes on the way, so fail the first # directory close after os.link or os.unlink touched the name rather than count the directory closes. Where os.link # takes no dir_fd it gets full paths, so match the final component. real_close: Final = os.close real_fstat: Final = os.fstat real_function: Final = getattr(os, function) armed = False def arm_after(*paths: _PathValue, **options: int | bool | None) -> None: nonlocal armed real_function(*paths, **options) armed = armed or Path(os.fsdecode(paths[-1])).name.startswith(prefix) def fail_directory_close(fd: int) -> None: nonlocal armed is_directory = stat.S_ISDIR(real_fstat(fd).st_mode) real_close(fd) if armed and is_directory: armed = False raise OSError(EIO, message) mocker.patch(f"filelock._strict.os.{function}", side_effect=arm_after) mocker.patch("filelock._strict.os.close", side_effect=fail_directory_close) def _leaf_error_messages(error: BaseException) -> list[str]: # pragma: needs dir-fd if isinstance(error, BaseExceptionGroup): return [message for child in error.exceptions for message in _leaf_error_messages(child)] return [str(error)] @pytest.mark.parametrize( "error", [ pytest.param(OSError(EACCES, "denied"), id="oserror"), pytest.param(NotImplementedError("no follow_symlinks"), id="notimplemented"), pytest.param(ValueError("bad option"), id="valueerror"), ], ) def test_strict_soft_link_follow_probe_reports_unhonored(mocker: MockerFixture, error: Exception) -> None: mocker.patch("filelock._strict.os.link", side_effect=error) assert _probe_link_follow_symlinks() is False def test_strict_soft_sentinel_inspection_and_close_failure_group(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_fstat = os.fstat real_close = os.close real_open_sentinel = filelock._strict._open_sentinel sentinel_fd: int | None = None def capture_sentinel(lock_file: str, path: Path, unchanged_for: Callable[[os.stat_result], float]) -> int | None: nonlocal sentinel_fd sentinel_fd = real_open_sentinel(lock_file, path, unchanged_for) return sentinel_fd # The record read inside _open_sentinel stats the same descriptor, but it runs before the capture above, so # keying on the descriptor still leaves _acquire's own inspection as the first stat to fail. def fail_sentinel_inspection(fd: int) -> os.stat_result: if fd == sentinel_fd: raise OSError(EIO, "sentinel inspection failed") return real_fstat(fd) def fail_sentinel_close(fd: int) -> None: if fd == sentinel_fd: raise OSError(EIO, "sentinel close failed") # Besides the sentinel this flow closes only the descriptor the lock creates the claim directory against. real_close(fd) # pragma: needs dir-fd mocker.patch("filelock._strict._open_sentinel", side_effect=capture_sentinel) mocker.patch("filelock._strict.os.fstat", side_effect=fail_sentinel_inspection) mocker.patch("filelock._strict.os.close", side_effect=fail_sentinel_close) lock = StrictSoftFileLock(lock_path) with pytest.raises(BaseExceptionGroup) as raised: lock.acquire() assert ([str(error) for error in raised.value.exceptions], lock.is_locked) == ( ["[Errno 5] sentinel inspection failed", "[Errno 5] sentinel close failed"], False, ) @_NEEDS_DIR_FD # pragma: needs dir-fd @pytest.mark.parametrize("initialized", [pytest.param(False, id="sentinel"), pytest.param(True, id="intent")]) def test_strict_soft_publication_directory_close_failure( tmp_path: Path, mocker: MockerFixture, initialized: bool ) -> None: lock_path = tmp_path / "resource.lock" if initialized: _initialize_protocol(lock_path) published = "intent-" if initialized else lock_path.name _fail_directory_close_after(mocker, "link", published, "publication directory close failed") lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(OSError, match="publication directory close failed"): lock.acquire() assert lock.is_locked is False @_NEEDS_DIR_FD # pragma: needs dir-fd def test_strict_soft_force_break_directory_close_failure(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" lock = StrictSoftFileLock(lock_path) lock.acquire() claim_name = lock.claims[0].name _fail_directory_close_after(mocker, "unlink", claim_name, "force break directory close failed") with pytest.raises(OSError, match="force break directory close failed"): lock.force_break(claim_name) lock.release() assert (lock.is_locked, lock.claims) == (False, ()) def test_strict_soft_release_sentinel_close_failure(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" lock = StrictSoftFileLock(lock_path) lock.acquire() sentinel_fd = lock._context.lock_file_fd real_close = os.close def fail_sentinel_close(fd: int) -> None: if fd == sentinel_fd: raise OSError(EIO, "sentinel close failed") # Only posix release closes a second, non-sentinel descriptor (the dir_fd); win32 closes only the sentinel. real_close(fd) # pragma: needs dir-fd mocker.patch("filelock._strict.os.close", side_effect=fail_sentinel_close) with pytest.raises(OSError, match="sentinel close failed"): lock.release() assert (lock.is_locked, lock.claims) == (False, ()) def test_strict_soft_discard_sentinel_close_failure(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) _write_foreign_claim(Path(f"{lock_path}.filelock") / "claims", "a" * 32) real_close = os.close real_fstat = os.fstat sentinel = lock_path.stat() def fail_sentinel_close(fd: int) -> None: if os.path.samestat(real_fstat(fd), sentinel): raise OSError(EIO, "doorway sentinel close failed") real_close(fd) mocker.patch("filelock._strict.os.close", side_effect=fail_sentinel_close) lock = StrictSoftFileLock(lock_path) with pytest.raises(OSError, match="doorway sentinel close failed"): lock.acquire() assert (lock.is_locked, [claim.state for claim in lock.claims]) == (False, ["held"]) @pytest.mark.parametrize("initialized", [pytest.param(False, id="sentinel"), pytest.param(True, id="intent")]) def test_strict_soft_publication_record_replaced_aborts( tmp_path: Path, mocker: MockerFixture, initialized: bool ) -> None: lock_path = tmp_path / "resource.lock" if initialized: _initialize_protocol(lock_path) real_identity = _relative_identity def mismatch_public(directory_ref: tuple[str, int | None], name: str) -> tuple[int, int] | None: identity = real_identity(directory_ref, name) if identity is not None and not name.startswith("."): return identity[0], identity[1] + 1 return identity mocker.patch("filelock._strict._relative_identity", side_effect=mismatch_public) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert lock.is_locked is False def test_strict_soft_publication_record_reclaimed_aborts(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_identity = _relative_identity def source_gone(directory_ref: tuple[str, int | None], name: str) -> tuple[int, int] | None: if name.startswith("."): return None # Only the dot-prefixed private record is inspected in this flow, so this passthrough never runs. return real_identity(directory_ref, name) # pragma: no cover mocker.patch("filelock._strict._relative_identity", side_effect=source_gone) mocker.patch("filelock._strict._link_relative", side_effect=FileNotFoundError(ENOENT, "source vanished")) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert lock.is_locked is False def test_strict_soft_private_inspection_failure_unlinks_vanished_record(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_fstat = os.fstat real_open_relative = filelock._strict._open_relative private_fd: int | None = None def capture_private_record(directory_ref: tuple[str, int | None], name: str, flags: int, mode: int) -> int: nonlocal private_fd fd = real_open_relative(directory_ref, name, flags, mode) if flags & os.O_CREAT: private_fd = fd return fd def fail_private_inspection(fd: int) -> os.stat_result: if fd == private_fd: raise OSError(EIO, "private inspection failed") return real_fstat(fd) mocker.patch("filelock._strict._open_relative", side_effect=capture_private_record) mocker.patch("filelock._strict.os.fstat", side_effect=fail_private_inspection) mocker.patch("filelock._strict._unlink_relative", side_effect=FileNotFoundError(ENOENT, "record gone")) lock = StrictSoftFileLock(lock_path) with pytest.raises(OSError, match="private inspection failed"): lock.acquire() assert lock.is_locked is False def test_strict_soft_private_record_reclaimed_after_close_is_tolerated(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_close = os.close real_open_relative = filelock._strict._open_relative private_records: dict[int, Path] = {} def capture_private_record(directory_ref: tuple[str, int | None], name: str, flags: int, mode: int) -> int: fd = real_open_relative(directory_ref, name, flags, mode) if flags & os.O_CREAT: private_records[fd] = Path(directory_ref[0], name) return fd def reclaim_on_close(fd: int) -> None: real_close(fd) if (record := private_records.pop(fd, None)) is not None: record.unlink() mocker.patch("filelock._strict._open_relative", side_effect=capture_private_record) mocker.patch("filelock._strict.os.close", side_effect=reclaim_on_close) lock = StrictSoftFileLock(lock_path, timeout=0) lock.acquire() assert lock.is_locked is True lock.release() assert lock.is_locked is False def test_strict_soft_record_finalization_close_and_unlink_failures_group(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_close = os.close real_open_relative = filelock._strict._open_relative private_fd: int | None = None def capture_private_record(directory_ref: tuple[str, int | None], name: str, flags: int, mode: int) -> int: nonlocal private_fd fd = real_open_relative(directory_ref, name, flags, mode) if flags & os.O_CREAT: private_fd = fd return fd def fail_private_close(fd: int) -> None: nonlocal private_fd real_close(fd) if fd == private_fd: private_fd = None # the cleanup that follows opens a directory under the same number raise OSError(EIO, "private close failed") mocker.patch("filelock._strict._open_relative", side_effect=capture_private_record) mocker.patch("filelock._strict.os.close", side_effect=fail_private_close) mocker.patch("filelock._strict._unlink_relative", side_effect=PermissionError(EACCES, "unlink denied")) lock = StrictSoftFileLock(lock_path) with pytest.raises(BaseExceptionGroup) as raised: lock.acquire() assert (sorted(str(error) for error in raised.value.exceptions), lock.is_locked) == ( ["[Errno 13] unlink denied", "[Errno 5] private close failed"], False, ) def test_strict_soft_release_claim_unlink_failures_group(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" lock = StrictSoftFileLock(lock_path) lock.acquire() unlink_mock = mocker.patch( "filelock._strict._unlink_in_directory", side_effect=PermissionError(EACCES, "claim unlink denied"), ) with pytest.raises(BaseExceptionGroup) as raised: lock.release() assert ([str(error) for error in raised.value.exceptions], lock.is_locked, len(lock.claims)) == ( ["[Errno 13] claim unlink denied", "[Errno 13] claim unlink denied"], True, 2, ) mocker.stop(unlink_mock) lock.release() assert (lock.is_locked, lock.claims) == (False, ()) def test_strict_soft_doorway_claim_unlink_failure_stays_pending(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) owner_token = "f" * 32 competitor_name = f"held-v1-{'0' * 32}.claim" intent_name = f"intent-v1-{owner_token}.claim" real_scandir = os.scandir injected = False def add_competitor_once_intent_is_published(path: int | str) -> Iterator[os.DirEntry[str]]: # Key on directory content, not scandir call count: the setup and publication paths scan a differing number # of times per platform, so plant the held competitor on the first rescan that already lists our own intent. nonlocal injected if not injected: with real_scandir(path) as entries: intent_published = any(entry.name == intent_name for entry in entries) if intent_published: _write_foreign_claim(Path(f"{lock_path}.filelock") / "claims", "0" * 32) injected = True return real_scandir(path) real_unlink_in_directory = filelock._strict._unlink_in_directory def deny_only_the_owned_intent_unlink(directory: Path, name: str) -> BaseException | None: # Fail only the doorway's own intent-claim unlink. A blanket failure also breaks the Windows-only private # record cleanup inside publication (_unlink_relative unlinks a differently named private file there), firing # the fault before the doorway ever discards and masking the commit-owned path this test targets. if name == intent_name: raise PermissionError(EACCES, "intent unlink denied") return real_unlink_in_directory(directory, name) # pragma: lacks dir-fd mocker.patch("filelock._strict.secrets.token_hex", return_value=owner_token) scandir_mock = mocker.patch("filelock._strict.os.scandir", side_effect=add_competitor_once_intent_is_published) unlink_mock = mocker.patch("filelock._strict._unlink_in_directory", side_effect=deny_only_the_owned_intent_unlink) lock = StrictSoftFileLock(lock_path) with pytest.raises(PermissionError, match="intent unlink denied"): lock.acquire() assert (lock.is_locked, sorted(claim.name for claim in lock.claims)) == (False, [competitor_name, intent_name]) # The leftover intent is a pending cleanup, not a hold: retrying acquires nothing while the competitor holds. with pytest.raises(PermissionError, match="intent unlink denied"): lock.acquire(timeout=0) mocker.stop(scandir_mock) mocker.stop(unlink_mock) with pytest.raises(Timeout): lock.acquire(timeout=0) assert ([claim.name for claim in lock.claims], lock.is_locked) == ([competitor_name], False) lock.force_break(competitor_name) def _leave_pending_doorway_here( # pragma: needs collected-finalization lock: StrictSoftFileLock, mocker: MockerFixture ) -> None: _fail_the_doorway(mocker) with pytest.raises(PermissionError, match="intent unlink denied"): lock.acquire() mocker.stopall() assert [claim.state for claim in lock.claims] == ["intent"] def _leave_pending_doorway_in_exited_thread(lock: StrictSoftFileLock, mocker: MockerFixture) -> None: (linger := threading.Event()).set() _leave_pending_doorway(lock, mocker, linger=linger).join() @NEEDS_COLLECTED_FINALIZATION @pytest.mark.parametrize( ("leave", "thread_local"), [ pytest.param(_leave_pending_doorway_here, True, id="this-thread"), pytest.param(_leave_pending_doorway_in_exited_thread, True, id="exited-thread"), pytest.param(_leave_pending_doorway_here, False, id="shared-context"), ], ) def test_strict_soft_dropped_instance_removes_pending_claims( # pragma: needs collected-finalization lock_path: Path, mocker: MockerFixture, leave: Callable[[StrictSoftFileLock, MockerFixture], None], thread_local: bool, ) -> None: lock = StrictSoftFileLock(lock_path, thread_local=thread_local) leave(lock, mocker) del lock gc.collect() assert StrictSoftFileLock(lock_path).claims == () @_NEEDS_FD_DIRECTORY # pragma: needs fd-directory def test_strict_soft_failed_doorway_cleanup_keeps_no_descriptor(lock_path: Path, mocker: MockerFixture) -> None: real_link: Final = os.link def fail_held_link(source: _PathValue, destination: _PathValue, **options: int | bool | None) -> None: if Path(os.fsdecode(destination)).name.startswith("held-"): raise OSError(EIO, "held link failed") real_link(source, destination, **options) # ty: ignore[invalid-argument-type] mocker.patch("filelock._strict.os.link", autospec=True, side_effect=fail_held_link) _deny_claim_unlinks(mocker) lock: Final = StrictSoftFileLock(lock_path) with pytest.raises(BaseExceptionGroup, match="strict doorway claim cleanup failed"): lock.acquire() assert (descriptors_on(lock_path.stat()), lock.is_locked) == (0, False) @_NEEDS_FD_DIRECTORY # pragma: needs fd-directory @NEEDS_COLLECTED_FINALIZATION @pytest.mark.parametrize("thread_local", [pytest.param(True, id="thread-local"), pytest.param(False, id="shared")]) def test_strict_soft_dropped_hold_with_stuck_claims_closes_its_descriptor( # pragma: needs collected-finalization lock_path: Path, mocker: MockerFixture, thread_local: bool ) -> None: lock = StrictSoftFileLock(lock_path, thread_local=thread_local) lock.acquire() _deny_claim_unlinks(mocker) del lock gc.collect() assert descriptors_on(lock_path.stat()) == 0 def _deny_claim_unlinks(mocker: MockerFixture) -> None: # pragma: needs fd-directory real_unlink: Final = os.unlink def deny(path: _PathValue, *, dir_fd: int | None = None) -> None: if os.fsdecode(path).endswith(".claim"): raise PermissionError(EACCES, "claim unlink denied") real_unlink(path, dir_fd=dir_fd) mocker.patch("filelock._strict.os.unlink", autospec=True, side_effect=deny) def test_strict_soft_acquire_leaves_a_live_threads_pending_doorway_alone( lock_path: Path, mocker: MockerFixture ) -> None: lock: Final = StrictSoftFileLock(lock_path, timeout=0) linger: Final = threading.Event() worker: Final = _leave_pending_doorway(lock, mocker, linger=linger) try: # The worker finishes its own doorway on its next acquire; finishing it here would race that. with pytest.raises(Timeout): lock.acquire() assert [claim.state for claim in lock.claims] == ["intent"] finally: linger.set() worker.join() def test_strict_soft_acquire_finishes_an_exited_threads_pending_doorway(lock_path: Path, mocker: MockerFixture) -> None: lock: Final = StrictSoftFileLock(lock_path, timeout=0) _leave_pending_doorway_in_exited_thread(lock, mocker) with lock: assert [claim.state for claim in lock.claims] == ["held", "intent"] assert lock.claims == () def _leave_pending_doorway( lock: StrictSoftFileLock, mocker: MockerFixture, *, linger: threading.Event ) -> threading.Thread: _fail_the_doorway(mocker) failed: Final = threading.Event() def acquire_then_linger() -> None: with pytest.raises(PermissionError, match="intent unlink denied"): lock.acquire() failed.set() linger.wait() (worker := threading.Thread(target=acquire_then_linger)).start() failed.wait() mocker.stopall() assert [claim.state for claim in lock.claims] == ["intent"] return worker def _fail_the_doorway(mocker: MockerFixture) -> None: real_unlink_in_directory: Final = filelock._strict._unlink_in_directory def deny_intent_unlink(directory: Path, name: str) -> BaseException | None: if name.startswith("intent-"): raise PermissionError(EACCES, "intent unlink denied") return real_unlink_in_directory(directory, name) def fail_held_link(_directory: Path, _source: str, _destination: str) -> NoReturn: # Raise a fresh error each call: a shared side_effect instance would keep its traceback, and with it the lock. raise OSError(EIO, "held link failed") mocker.patch("filelock._strict._link_no_replace", autospec=True, side_effect=fail_held_link) mocker.patch("filelock._strict._unlink_in_directory", autospec=True, side_effect=deny_intent_unlink) @pytest.mark.parametrize("vanished", [pytest.param(True, id="removed"), pytest.param(False, id="still-listed")]) def test_strict_soft_denied_claim_rechecks_directory(tmp_path: Path, mocker: MockerFixture, *, vanished: bool) -> None: lock_path: Final = tmp_path / "resource.lock" claim: Final = _write_held_claim(Path(f"{lock_path}.filelock") / "claims") clock: Final = iter([0, 1]) def tick() -> int: # Remove the claim as the grace runs out, after the last denied open, so only the listing recheck sees it go. if (now := next(clock)) and vanished: claim.unlink() return now _patch_strict_clock( mocker, monotonic=mocker.create_autospec(time.monotonic, side_effect=tick), sleep=mocker.create_autospec(time.sleep), ) _open_raising_for(claim, PermissionError(EACCES, "sharing violation"), mocker) if vanished: assert StrictSoftFileLock(lock_path).claims == () else: with pytest.raises(SoftFileLockProtocolError, match="cannot read claim"): _ = StrictSoftFileLock(lock_path).claims @pytest.mark.skipif(sys.platform == "win32", reason="Windows has no directory mode to settle") def test_strict_soft_directory_mode_failure_fails_closed( # pragma: win32 no cover tmp_path: Path, mocker: MockerFixture ) -> None: mocker.patch("filelock._strict.os.fchmod", autospec=True, side_effect=PermissionError(EACCES, "read-only mount")) with pytest.raises(SoftFileLockProtocolError, match="cannot set the mode"): StrictSoftFileLock(tmp_path / "resource.lock").acquire() def test_strict_soft_claims_removed_one_by_one_share_one_read_grace(tmp_path: Path, mocker: MockerFixture) -> None: lock_path: Final = tmp_path / "resource.lock" (claims := Path(f"{lock_path}.filelock") / "claims").mkdir(parents=True) denied: Final = [_write_foreign_claim(claims, f"{index:032x}") for index in range(10)] now = 0.0 def tick(seconds: float) -> None: # Another process removes the lowest unreadable claim every 0.2 s, inside the grace a single claim gets. nonlocal now before, now = now, now + seconds if int(now / 0.2) > int(before / 0.2) and (remaining := [claim for claim in denied if claim.exists()]): remaining[0].unlink() real_open: Final = os.open def deny_claims(path: _PathValue, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: if os.fsdecode(path).endswith(".claim"): raise PermissionError(EACCES, "claim read denied") return real_open(path, flags, mode, dir_fd=dir_fd) # pragma: needs dir-fd _patch_strict_clock( mocker, monotonic=mocker.create_autospec(time.monotonic, side_effect=lambda: now), sleep=mocker.create_autospec(time.sleep, side_effect=tick), ) _route_claim_opens_through_os_open(mocker) mocker.patch("filelock._strict.os.open", autospec=True, side_effect=deny_claims) with pytest.raises(SoftFileLockProtocolError, match="cannot read claim"): _ = StrictSoftFileLock(lock_path).claims assert now < 1 @_WINDOWS_ONLY # pragma: win32 cover def test_strict_soft_claim_pending_deletion_is_skipped(tmp_path: Path) -> None: # A peer's unlink sets the claim's delete disposition, then closes its handle. Until that close, every open fails # with EACCES, and a peer stalled in between can hold that past the read grace, yet the claim is already withdrawn. # The marker skips the run; the guard only narrows ctypes.WinDLL for ty, so it goes one way. if sys.platform == "win32": # pragma: no branch import ctypes lock_path = tmp_path / "resource.lock" claim = _write_held_claim(Path(f"{lock_path}.filelock") / "claims") kernel32 = ctypes.WinDLL("kernel32") # a private instance, so the restype below stays local kernel32.CreateFileW.restype = ctypes.c_void_p delete_access, share_all, open_existing, file_disposition_info = 0x10000, 0x1 | 0x2 | 0x4, 3, 4 handle = ctypes.c_void_p( kernel32.CreateFileW(str(claim), delete_access, share_all, None, open_existing, 0, None) ) delete_file = ctypes.c_bool(True) # the FILE_DISPOSITION_INFO BOOLEAN try: assert kernel32.SetFileInformationByHandle( handle, file_disposition_info, ctypes.byref(delete_file), ctypes.sizeof(delete_file) ) assert StrictSoftFileLock(lock_path).claims == () finally: kernel32.CloseHandle(handle) def test_strict_soft_claim_removed_between_inspection_and_open_is_skipped( tmp_path: Path, mocker: MockerFixture ) -> None: claim: Final = _write_held_claim(Path(f"{tmp_path / 'resource.lock'}.filelock") / "claims") def remove_after_inspection(inspect: Callable[..., os.stat_result]) -> Callable[..., os.stat_result]: def inspect_then_remove(path: _PathValue, **options: int | bool | None) -> os.stat_result: found = inspect(path, **options) # Windows inspects nothing else in this scan; with dir_fd the claim directory is stat-ed too. if Path(os.fsdecode(path)).name == claim.name: # pragma: no branch claim.unlink() return found return inspect_then_remove # The lock inspects the claim with lstat by path, or with stat relative to its directory where dir_fd works. for function in ("lstat", "stat"): mocker.patch(f"filelock._strict.os.{function}", side_effect=remove_after_inspection(getattr(os, function))) assert StrictSoftFileLock(tmp_path / "resource.lock").claims == () def test_strict_soft_owner_releases_while_a_contender_reads_its_claim(lock_path: Path, mocker: MockerFixture) -> None: # On Windows a reader that opened the claim without FILE_SHARE_DELETE made the owner's unlink fail until it closed. (holder := StrictSoftFileLock(lock_path)).acquire() real_read: Final = os.read def release_mid_read(fd: int, length: int) -> bytes: # The scan reads this one claim and nothing else, so the holder releases once. holder.release() return real_read(fd, length) mocker.patch("filelock._strict.os.read", autospec=True, side_effect=release_mid_read) _ = StrictSoftFileLock(lock_path).claims assert (holder.is_locked, StrictSoftFileLock(lock_path).claims) == (False, ()) @_WINDOWS_ONLY # pragma: win32 cover @pytest.mark.parametrize( ("operation", "match"), [ pytest.param(lambda lock: lock.acquire(), "is not a real directory", id="acquire"), pytest.param(lambda lock: lock.claims, "cannot open claim directory", id="claims"), ], ) def test_strict_soft_refuses_a_junction_for_the_claim_directory( tmp_path: Path, create_junction: Callable[[Path, Path], None], operation: Callable[[StrictSoftFileLock], object], match: str, ) -> None: lock_path: Final = tmp_path / "resource.lock" Path(f"{lock_path}.filelock").mkdir() (target := tmp_path / "target").mkdir() create_junction(Path(f"{lock_path}.filelock", "claims"), target) with pytest.raises(SoftFileLockProtocolError, match=match): operation(StrictSoftFileLock(lock_path, timeout=0)) assert list(target.iterdir()) == [] @_WINDOWS_ONLY # pragma: win32 cover def test_strict_soft_coordination_directory_swapped_for_a_junction_leaves_target_alone( tmp_path: Path, mocker: MockerFixture, create_junction: Callable[[Path, Path], None] ) -> None: lock_path: Final = tmp_path / "resource.lock" (coordination := Path(f"{lock_path}.filelock")).mkdir() (target := tmp_path / "target").mkdir() swapped: list[bool] = [] def swap_after_the_inspection(inspect: Callable[..., os.stat_result]) -> Callable[..., os.stat_result]: def inspect_then_swap(path: _PathValue, **options: int | bool | None) -> os.stat_result: # The name was a real directory at inspection; by the time the lock creates claims in it, it is a junction. found = inspect(path, **options) if not swapped and os.fsdecode(path) == str(coordination): swapped.append(True) coordination.rename(tmp_path / "aside") create_junction(coordination, target) return found return inspect_then_swap # pathlib inspects the directory with os.lstat on 3.14 and with os.stat(follow_symlinks=False) before that. for function in ("lstat", "stat"): mocker.patch(f"filelock._strict.os.{function}", side_effect=swap_after_the_inspection(getattr(os, function))) with pytest.raises(SoftFileLockProtocolError, match="cannot open"): StrictSoftFileLock(lock_path, timeout=0).acquire() assert list(target.iterdir()) == [] @_WINDOWS_ONLY # pragma: win32 cover def test_strict_soft_keeps_the_claim_directory_in_place_while_it_scans(lock_path: Path, mocker: MockerFixture) -> None: # Without dir_fd every operation resolves the claim directory by name, so nothing may rename it meanwhile. real_scandir: Final = os.scandir aside: Final = lock_path.parent / "aside" refused: list[bool] = [] def try_to_move_it_aside(path: int | str) -> Iterator[os.DirEntry[str]]: try: Path(f"{lock_path}.filelock").rename(aside) except PermissionError: refused.append(True) return real_scandir(path) mocker.patch("filelock._strict.os.scandir", side_effect=try_to_move_it_aside) with StrictSoftFileLock(lock_path, timeout=0) as lock: assert lock.is_locked assert (bool(refused), aside.exists()) == (True, False) def test_strict_soft_claims_recheck_after_a_denied_claim_creates_no_file(tmp_path: Path, mocker: MockerFixture) -> None: lock_path: Final = tmp_path / "resource.lock" claim: Final = _write_held_claim(Path(f"{lock_path}.filelock") / "claims") (private_path := _private_claim_path(lock_path, "0" * 32)).write_bytes(b"abandoned") os.utime(private_path, (0, 0)) _patch_strict_clock( mocker, monotonic=mocker.create_autospec(time.monotonic, side_effect=[0, 1]), sleep=mocker.create_autospec(time.sleep), ) opened: Final = _open_raising_for(claim, PermissionError(EACCES, "sharing violation"), mocker) with pytest.raises(SoftFileLockProtocolError, match="cannot read claim"): _ = StrictSoftFileLock(lock_path).claims assert ([call.args for call in opened.call_args_list if call.args[1] & os.O_CREAT], private_path.exists()) == ( [], True, ) def test_strict_soft_claim_read_retries_after_a_slow_denied_attempt(tmp_path: Path, mocker: MockerFixture) -> None: # The grace window opens at the first pending result, so a first attempt that itself outlasts the window still # gets a retry rather than failing closed on a transient denial. lock_path = tmp_path / "resource.lock" claim = _write_held_claim(Path(f"{lock_path}.filelock") / "claims") mocker.patch("filelock._strict._CLAIM_READ_GRACE", 0.02) real_open = os.open denied = False def deny_slowly_once(path: _PathValue, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: nonlocal denied if not denied and Path(os.fsdecode(path)).name == claim.name: denied = True time.sleep(0.05) raise PermissionError(EACCES, "sharing violation") return real_open(path, flags, mode, dir_fd=dir_fd) _route_claim_opens_through_os_open(mocker) mocker.patch("filelock._strict.os.open", side_effect=deny_slowly_once) assert ([found.name for found in StrictSoftFileLock(lock_path).claims], denied) == ([claim.name], True) def test_strict_soft_ignores_malformed_private_record_name(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) (claims / ".decoy.tmp").write_bytes(b"junk") assert StrictSoftFileLock(lock_path).claims == () def test_strict_soft_record_read_and_close_failure_group(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) (claims / f"held-v1-{'0' * 32}.claim").write_bytes(b"x" * 1025) real_close = os.close real_fstat = os.fstat def fail_close(fd: int) -> None: # Reading the single oversized claim closes one record, so failing every such close still fails once. is_directory = stat.S_ISDIR(real_fstat(fd).st_mode) real_close(fd) if not is_directory: # pragma: needs dir-fd raise OSError(EIO, "record close failed") mocker.patch("filelock._strict.os.close", side_effect=fail_close) with pytest.raises(BaseExceptionGroup) as raised: _ = StrictSoftFileLock(lock_path).claims messages = [str(error) for error in raised.value.exceptions] assert ( any("exceeds 1024 bytes" in message for message in messages), "[Errno 5] record close failed" in messages, ) == ( True, True, ) def test_strict_soft_acquires_without_dir_fd(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" mocker.patch("filelock._strict._OPEN_SUPPORTS_DIR_FD", new=False) mocker.patch("filelock._strict._UNLINK_SUPPORTS_DIR_FD", new=False) mocker.patch("filelock._strict._STAT_SUPPORTS_DIR_FD", new=False) mocker.patch("filelock._strict._LINK_SUPPORTS_DIR_FD", new=False) mocker.patch("filelock._strict._MKDIR_SUPPORTS_DIR_FD", new=False) mocker.patch("filelock._strict._SCANDIR_SUPPORTS_FD", new=False) with StrictSoftFileLock(lock_path) as lock: assert (lock.is_locked, [claim.state for claim in lock.claims]) == (True, ["held", "intent"]) assert StrictSoftFileLock(lock_path).claims == () @_NEEDS_DIR_FD # pragma: needs dir-fd @_NEEDS_LINK_DIR_FD # pragma: needs link-dir-fd def test_strict_soft_held_link_and_directory_close_failure(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_close = os.close real_fstat = os.fstat real_link = os.link held_failed = False directory_closed = False def fail_held_link( source: _PathValue, destination: _PathValue, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: nonlocal held_failed if Path(os.fsdecode(destination)).name.startswith("held-"): held_failed = True raise OSError(EIO, "held link failed") real_link(source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks) def fail_directory_close_after_held(fd: int) -> None: nonlocal directory_closed is_directory = stat.S_ISDIR(real_fstat(fd).st_mode) real_close(fd) if is_directory and held_failed and not directory_closed: directory_closed = True raise OSError(EIO, "held link directory close failed") mocker.patch("filelock._strict.os.link", side_effect=fail_held_link) mocker.patch("filelock._strict.os.close", side_effect=fail_directory_close_after_held) lock = StrictSoftFileLock(lock_path) with pytest.raises(BaseExceptionGroup) as raised: lock.acquire() assert ([str(error) for error in raised.value.exceptions], lock.is_locked, lock.claims) == ( ["[Errno 5] held link failed", "[Errno 5] held link directory close failed"], False, (), ) def test_strict_soft_opened_record_non_regular_fails_closed(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) (claims / f"held-v1-{'0' * 32}.claim").write_bytes(b"filelock-strict-v1\n" + b"0" * 32 + b"\n1\n686f7374\n4242\n") real_fstat = os.fstat def non_regular_fstat(fd: int) -> os.stat_result: original = real_fstat(fd) return os.stat_result((stat.S_IFIFO | 0o644, *original[1:])) mocker.patch("filelock._strict.os.fstat", side_effect=non_regular_fstat) with pytest.raises(SoftFileLockProtocolError, match="is not a regular file"): _ = StrictSoftFileLock(lock_path).claims @_WINDOWS_ONLY # pragma: win32 cover def test_strict_soft_release_rides_out_a_transient_unlink_denial(lock_path: Path, mocker: MockerFixture) -> None: # Another process can hold a claim open for a moment after its owner finished with it, so the release retries once. (lock := StrictSoftFileLock(lock_path)).acquire() real_unlink: Final = os.unlink denied: list[str] = [] def deny_once(path: _PathValue, *, dir_fd: int | None = None) -> None: if not denied and os.fsdecode(path).endswith(".claim"): denied.append(os.fsdecode(path)) raise PermissionError(EACCES, "sharing violation") real_unlink(path, dir_fd=dir_fd) mocker.patch("filelock._strict.os.unlink", side_effect=deny_once) sleep: Final = mocker.create_autospec(time.sleep) _patch_strict_clock(mocker, sleep=sleep) lock.release() assert (len(denied), sleep.call_args_list, lock.claims) == (1, [mocker.call(0.001)], ()) def _write_foreign_claim(claims: Path, token: str, state: str = "held") -> Path: claim = claims / f"{state}-v1-{token}.claim" claim.write_bytes(b"filelock-strict-v1\n" + token.encode() + f"\n{os.getpid()}\n686f7374\n4242\n".encode()) return claim def _patch_strict_clock( mocker: MockerFixture, *, monotonic: Callable[[], float] = time.monotonic, sleep: Callable[[float], None] = time.sleep, wall: Callable[[], float] = time.time, ) -> None: mocker.patch("filelock._strict.time", new=SimpleNamespace(monotonic=monotonic, sleep=sleep, time=wall)) filelock-4.2.8/tests/test_strict_soft_paths.py0000644000000000000000000001376515272561277016612 0ustar00from __future__ import annotations import asyncio import os import threading from concurrent.futures import ThreadPoolExecutor from contextlib import suppress from functools import partial from typing import TYPE_CHECKING, Final import pytest from filelock import AsyncStrictSoftFileLock, SoftFileLockProtocolError, StrictSoftFileLock from tests.capability_marks import NEEDS_PARENT_SYMLINK_COLLAPSE, NEEDS_SYMLINK if TYPE_CHECKING: from pathlib import Path _STRICT_SENTINEL: Final[bytes] = b"1\nfilelock-strict-v1\x00\n0\n" # Windows resolves a lock's parent with abspath, so a symlinked parent stays a distinct key and never collapses. pytestmark = pytest.mark.requires_hard_links def test_strict_soft_relative_release_uses_acquisition_directory( working_directories: tuple[Path, Path], ) -> None: first, second = working_directories lock = StrictSoftFileLock("resource.lock") lock.acquire() assert tuple(claim.state for claim in StrictSoftFileLock(first / "resource.lock").claims) == ("held", "intent") os.chdir(second) lock.release() assert ( StrictSoftFileLock(first / "resource.lock").claims, StrictSoftFileLock(second / "resource.lock").claims, ) == ((), ()) @pytest.mark.asyncio async def test_async_strict_soft_relative_release_uses_acquisition_directory( working_directories: tuple[Path, Path], ) -> None: first, second = working_directories lock = AsyncStrictSoftFileLock("resource.lock", run_in_executor=False) await lock.acquire() assert tuple(claim.state for claim in StrictSoftFileLock(first / "resource.lock").claims) == ("held", "intent") os.chdir(second) await lock.release() assert ( StrictSoftFileLock(first / "resource.lock").claims, StrictSoftFileLock(second / "resource.lock").claims, ) == ((), ()) def test_strict_soft_waiter_keeps_acquisition_directory( working_directories: tuple[Path, Path], ) -> None: first, second = working_directories holder = StrictSoftFileLock(first / "resource.lock") holder.acquire() polling = threading.Event() acquired = threading.Event() release = threading.Event() try: with ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(_hold_relative_lock, polling, acquired, release) assert polling.wait(timeout=2) os.chdir(second) holder.release() assert acquired.wait(timeout=2) assert ( tuple(claim.state for claim in StrictSoftFileLock(first / "resource.lock").claims), StrictSoftFileLock(second / "resource.lock").claims, ) == (("held", "intent"), ()) release.set() future.result(timeout=2) finally: release.set() holder.release(force=True) @pytest.mark.asyncio async def test_async_strict_soft_waiter_keeps_acquisition_directory( working_directories: tuple[Path, Path], ) -> None: first, second = working_directories holder = AsyncStrictSoftFileLock(first / "resource.lock", run_in_executor=False) await holder.acquire() polling = asyncio.Event() contender = AsyncStrictSoftFileLock("resource.lock", timeout=2, poll_interval=0.001, run_in_executor=False) task = asyncio.create_task(contender.acquire(cancel_check=partial(_signal_async_poll, polling))) try: await asyncio.wait_for(polling.wait(), timeout=2) os.chdir(second) await holder.release() await asyncio.wait_for(task, timeout=2) assert ( tuple(claim.state for claim in StrictSoftFileLock(first / "resource.lock").claims), StrictSoftFileLock(second / "resource.lock").claims, ) == (("held", "intent"), ()) finally: if not task.done(): # pragma: no cover # the contender wins before teardown, so cancellation cleanup is rare task.cancel() with suppress(asyncio.CancelledError): await task await holder.release(force=True) await contender.release(force=True) @NEEDS_PARENT_SYMLINK_COLLAPSE # pragma: win32 no cover def test_strict_soft_release_uses_original_symlink_parent(tmp_path: Path) -> None: original = tmp_path / "original" replacement = tmp_path / "replacement" original.mkdir() replacement.mkdir() link = tmp_path / "link" link.symlink_to(original, target_is_directory=True) original_holder = StrictSoftFileLock(link / "resource.lock") replacement_holder = StrictSoftFileLock(replacement / "resource.lock") original_holder.acquire() replacement_holder.acquire() try: link.unlink() link.symlink_to(replacement, target_is_directory=True) original_holder.release() assert ( StrictSoftFileLock(original / "resource.lock").claims, tuple(claim.state for claim in replacement_holder.claims), ) == ((), ("held", "intent")) finally: original_holder.release(force=True) replacement_holder.release(force=True) @NEEDS_SYMLINK # pragma: needs symlink def test_strict_soft_final_symlink_fails_closed_without_touching_target(tmp_path: Path) -> None: target = tmp_path / "target" target.write_bytes(_STRICT_SENTINEL) lock_path = tmp_path / "resource.lock" lock_path.symlink_to(target) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(SoftFileLockProtocolError, match="found a symlink"): lock.acquire() assert (target.read_bytes(), lock_path.is_symlink(), lock.claims) == (_STRICT_SENTINEL, True, ()) def _hold_relative_lock(polling: threading.Event, acquired: threading.Event, release: threading.Event) -> None: with StrictSoftFileLock("resource.lock", timeout=2, poll_interval=0.001).acquire( cancel_check=partial(_signal_thread_poll, polling) ): acquired.set() assert release.wait(timeout=2) def _signal_thread_poll(polling: threading.Event) -> bool: polling.set() return False def _signal_async_poll(polling: asyncio.Event) -> bool: polling.set() return False filelock-4.2.8/tests/test_strict_soft_races.py0000644000000000000000000002477215272561277016570 0ustar00from __future__ import annotations import os import threading import time from errno import EIO from pathlib import Path from typing import TYPE_CHECKING, Literal import pytest from filelock import StrictSoftFileLock, Timeout from filelock._strict import _CLAIM_MAGIC if TYPE_CHECKING: from pytest_mock import MockerFixture pytestmark = pytest.mark.requires_hard_links def test_strict_soft_lower_intent_delayed_until_higher_selects(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) lower_intent_waiting = threading.Event() higher_held_attempted = threading.Event() lower_held_linked = threading.Event() higher_held_linked = threading.Event() real_link = os.link def ordered_link( source: str, destination: str, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: name = Path(destination).name if threading.current_thread().name == "lower" and name.startswith("intent-"): lower_intent_waiting.set() assert higher_held_attempted.wait(timeout=2) if threading.current_thread().name == "higher" and name.startswith("held-"): higher_held_attempted.set() assert lower_held_linked.wait(timeout=2) real_link( source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks, ) if threading.current_thread().name == "lower" and name.startswith("held-"): lower_held_linked.set() assert higher_held_linked.wait(timeout=2) elif threading.current_thread().name == "higher" and name.startswith("held-"): higher_held_linked.set() mocker.patch("filelock._strict.os.link", side_effect=ordered_link) mocker.patch( "filelock._strict.secrets.token_hex", side_effect=lambda _size: "0" * 32 if threading.current_thread().name == "lower" else "f" * 32, ) acquisition_order: list[str] = [] inside, state_lock = 0, threading.Lock() def acquire(label: str) -> None: nonlocal inside with StrictSoftFileLock(lock_path, timeout=3, poll_interval=0.001): with state_lock: inside += 1 assert inside == 1 acquisition_order.append(label) time.sleep(0.01) with state_lock: inside -= 1 lower = threading.Thread(target=acquire, args=("lower",), name="lower") higher = threading.Thread(target=acquire, args=("higher",), name="higher") lower.start() assert lower_intent_waiting.wait(timeout=2) higher.start() _join_threads(lower, higher) assert (acquisition_order, inside) == (["lower", "higher"], 0) def test_strict_soft_lower_intent_delayed_until_higher_enters(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) lower_intent_waiting = threading.Event() higher_entered = threading.Event() release_higher = threading.Event() real_link = os.link def delayed_link( source: str, destination: str, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: if threading.current_thread().name == "lower" and Path(destination).name.startswith("intent-"): lower_intent_waiting.set() assert higher_entered.wait(timeout=2) real_link( source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks, ) mocker.patch("filelock._strict.os.link", side_effect=delayed_link) mocker.patch( "filelock._strict.secrets.token_hex", side_effect=lambda _size: "0" * 32 if threading.current_thread().name == "lower" else "f" * 32, ) acquisition_order: list[str] = [] def acquire(label: str) -> None: with StrictSoftFileLock(lock_path, timeout=3, poll_interval=0.001): acquisition_order.append(label) if label == "higher": higher_entered.set() assert release_higher.wait(timeout=2) lower = threading.Thread(target=acquire, args=("lower",), name="lower") higher = threading.Thread(target=acquire, args=("higher",), name="higher") lower.start() assert lower_intent_waiting.wait(timeout=2) higher.start() assert higher_entered.wait(timeout=2) assert acquisition_order == ["higher"] release_higher.set() _join_threads(lower, higher) assert acquisition_order == ["higher", "lower"] def test_strict_soft_private_partial_record_is_not_a_claim(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) partial_written = threading.Event() resume_write = threading.Event() real_write = os.write paused = False # The patch binds os.write process-wide, so key the pause to the claim record itself: a finalizer the collector # runs on the "partial" thread may write to the resource tracker's pipe meanwhile, and truncating that command # breaks the tracker rather than this test. def paused_write(fd: int, data: bytes | bytearray | memoryview) -> int: nonlocal paused if threading.current_thread().name != "partial" or paused or not bytes(data).startswith(_CLAIM_MAGIC.encode()): return real_write(fd, data) paused = True written = real_write(fd, data[:1]) partial_written.set() assert resume_write.wait(timeout=2) return written mocker.patch("filelock._util.os.write", side_effect=paused_write) def acquire_partial() -> None: with StrictSoftFileLock(lock_path, timeout=3, poll_interval=0.001): pass partial = threading.Thread(target=acquire_partial, name="partial") partial.start() assert partial_written.wait(timeout=2) with StrictSoftFileLock(lock_path, timeout=0) as contender: assert contender.is_locked resume_write.set() _join_threads(partial) def test_strict_soft_shared_instance_waits_for_failed_doorway(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) lock = StrictSoftFileLock(lock_path, thread_local=False, timeout=2, poll_interval=0.001) first_doorway = threading.Event() fail_first = threading.Event() second_entered = threading.Event() first_errors: list[OSError] = [] real_link = os.link def pause_first_intent( source: str, destination: str, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: if threading.current_thread().name == "first" and Path(destination).name.startswith("intent-"): first_doorway.set() assert fail_first.wait(timeout=2) raise OSError(EIO, "doorway failed") real_link( source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks, ) def acquire_first() -> None: try: lock.acquire() except OSError as error: first_errors.append(error) def acquire_second() -> None: with lock: second_entered.set() mocker.patch("filelock._strict.os.link", side_effect=pause_first_intent) first = threading.Thread(target=acquire_first, name="first") second = threading.Thread(target=acquire_second, name="second") first.start() assert first_doorway.wait(timeout=2) second.start() assert not second_entered.wait(timeout=0.05) fail_first.set() _join_threads(first, second) assert ([str(error) for error in first_errors], second_entered.is_set(), lock.is_locked) == ( ["[Errno 5] doorway failed"], True, False, ) @pytest.mark.parametrize( "admission", [ pytest.param("timeout", id="zero-timeout"), pytest.param("blocking", id="nonblocking"), pytest.param("cancel", id="cancel-check"), ], ) def test_strict_soft_shared_instance_transition_respects_admission( tmp_path: Path, mocker: MockerFixture, admission: Literal["timeout", "blocking", "cancel"], ) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) lock = StrictSoftFileLock(lock_path, thread_local=False, timeout=2, poll_interval=0.001) first_doorway = threading.Event() continue_first = threading.Event() release_first = threading.Event() real_link = os.link def pause_first_intent( source: str, destination: str, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: if threading.current_thread().name == "first" and Path(destination).name.startswith("intent-"): first_doorway.set() assert continue_first.wait(timeout=2) real_link( source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks, ) def acquire_first() -> None: with lock: assert release_first.wait(timeout=2) mocker.patch("filelock._strict.os.link", side_effect=pause_first_intent) first = threading.Thread(target=acquire_first, name="first") first.start() assert first_doorway.wait(timeout=2) started = time.perf_counter() with pytest.raises(Timeout): _acquire_during_transition(lock, admission) elapsed = time.perf_counter() - started continue_first.set() release_first.set() _join_threads(first) assert (elapsed < 0.2, lock.is_locked) == (True, False) def _acquire_during_transition( lock: StrictSoftFileLock, admission: Literal["timeout", "blocking", "cancel"], ) -> None: if admission == "timeout": lock.acquire(timeout=0) elif admission == "blocking": lock.acquire(blocking=False) else: lock.acquire(cancel_check=lambda: True) def _initialize_protocol(lock_path: Path) -> None: with StrictSoftFileLock(lock_path): pass def _join_threads(*threads: threading.Thread) -> None: for thread in threads: thread.join(timeout=3) assert [thread.name for thread in threads if thread.is_alive()] == [] filelock-4.2.8/tests/test_strict_soft_recovery.py0000644000000000000000000001117615272561277017323 0ustar00from __future__ import annotations import os import subprocess # ruff:ignore[suspicious-subprocess-import] # the process must crash between two filesystem operations import sys import textwrap from typing import TYPE_CHECKING, Final import pytest from filelock import StrictSoftFileLock, Timeout if TYPE_CHECKING: from pathlib import Path # The crash points are hit through sys.addaudithook on os.link and os.remove events, which only CPython emits. PyPy # runs the audit hook but never fires those events, so the child would finish the acquisition instead of crashing. pytestmark = [ pytest.mark.skipif( sys.implementation.name != "cpython", reason="crash injection needs the os.link and os.remove audit events CPython emits", ), pytest.mark.requires_hard_links, ] _CRASH_STATUS: Final[int] = 73 def test_strict_soft_recovers_every_claim_for_crashed_token(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" result = subprocess.run( [sys.executable, "-c", _crash_while_holding(), os.fspath(lock_path)], check=False, capture_output=True, text=True, ) lock = StrictSoftFileLock(lock_path, timeout=0) claims = lock.claims assert (result.returncode, result.stdout, result.stderr, [claim.state for claim in claims]) == ( _CRASH_STATUS, "", "", ["held", "intent"], ) assert len({claim.token for claim in claims}) == 1 with pytest.raises(Timeout): lock.acquire() for claim in lock.claims: lock.force_break(claim.name) with lock: assert lock.is_locked def test_strict_soft_reclaims_crash_before_private_publication(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" result = subprocess.run( [sys.executable, "-c", _crash_during_private_publication("before-link"), os.fspath(lock_path)], check=False, capture_output=True, text=True, ) private_records = tuple(tmp_path.glob("**/.*.private-v1-*.tmp")) assert (result.returncode, result.stdout, result.stderr, len(private_records)) == (_CRASH_STATUS, "", "", 1) os.utime(private_records[0], (0, 0)) with StrictSoftFileLock(lock_path, timeout=0): assert tuple(tmp_path.glob("**/.*.private-v1-*.tmp")) == () def test_strict_soft_reclaims_crash_after_private_publication(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" result = subprocess.run( [sys.executable, "-c", _crash_during_private_publication("after-link"), os.fspath(lock_path)], check=False, capture_output=True, text=True, ) private_records = tuple(tmp_path.glob("**/.*.private-v1-*.tmp")) lock = StrictSoftFileLock(lock_path, timeout=0) claims = lock.claims assert (result.returncode, result.stdout, result.stderr, len(private_records), len(claims)) == ( _CRASH_STATUS, "", "", 1, 1, ) assert tuple(tmp_path.glob("**/.*.private-v1-*.tmp")) == () lock.force_break(claims[0].name) with lock: assert lock.is_locked def _crash_while_holding() -> str: # A strict holder keeps both its held and intent claims for the whole hold, so a hard crash mid-hold leaves the # pair behind. os._exit skips every finalizer, reproducing a killed process that never releases. return textwrap.dedent( f""" from __future__ import annotations import os import sys from filelock import StrictSoftFileLock lock = StrictSoftFileLock(sys.argv[1]) lock.acquire() os._exit({_CRASH_STATUS}) """ ) def _crash_during_private_publication(stage: str) -> str: return textwrap.dedent( f""" from __future__ import annotations import os import sys from filelock import StrictSoftFileLock def exit_at_publication_stage(event: str, args: tuple[str | int, ...]) -> None: if event not in {{"os.link", "os.remove"}}: return path = os.path.basename(os.fsdecode(args[1] if event == "os.link" else args[0])) before_link = {stage!r} == "before-link" and event == "os.link" and path.startswith("intent-") after_link = ( {stage!r} == "after-link" and event == "os.remove" and path.startswith(".intent-") and ".private-v1-" in path and path.endswith(".tmp") ) if before_link or after_link: os._exit({_CRASH_STATUS}) sys.addaudithook(exit_at_publication_stage) StrictSoftFileLock(sys.argv[1]).acquire() """ ) filelock-4.2.8/tests/test_strict_soft_stress.py0000644000000000000000000001025615272561277017006 0ustar00from __future__ import annotations import os import signal import subprocess # ruff:ignore[suspicious-subprocess-import] # isolated interpreters exercise cross-process exclusion import sys import time from typing import TYPE_CHECKING, Final import pytest if TYPE_CHECKING: from pathlib import Path _PROCESS_COUNT: Final[int] = 8 _ACQUISITIONS_PER_PROCESS: Final[int] = 500 # A guard against a deadlock, not a throughput assertion: overlap fails immediately through the markers below, so this # budget only bounds a hang. The four thousand contended acquisitions cost CPython about three seconds and GraalPy about # twenty-eight on a ten-core machine, and the CI runners have far fewer, so it is sized for the slowest interpreter on # the smallest runner rather than for the fastest, which is what a seventy-five second guard encoded. _WORKER_DEADLINE: Final[int] = 300 # Catch overlap two independent ways. An occupancy marker fails immediately the instant a second holder enters the # critical section, and a counter read-modify-write fails afterwards when two holders read the same value and one # increment vanishes. Both beat an O_EXCL create/unlink pair, which tripped over Windows' delete-pending lag where the # previous holder's unlink had not finished before the next holder's create ran. The check lives in the worker's exit # status, not in its stderr: interpreters are free to print to stderr (PyPy warns when it cannot size the CPU cache), # so demanding empty stderr flakes without proving anything about exclusion. _WORKER: Final[str] = """ import sys import time from pathlib import Path from filelock import StrictSoftFileLock lock_path, start_path, counter_path, occupied_path = map(Path, sys.argv[1:5]) while not start_path.exists(): time.sleep(0.001) for _ in range(int(sys.argv[5])): with StrictSoftFileLock(lock_path, timeout=30, poll_interval=0.0005): if occupied_path.exists(): raise SystemExit("two holders entered the critical section at once") occupied_path.write_text("held") count = int(counter_path.read_text()) if counter_path.exists() else 0 counter_path.write_text(str(count + 1)) occupied_path.unlink() """ pytestmark = pytest.mark.requires_hard_links @pytest.mark.timeout(_WORKER_DEADLINE + 60) def test_strict_soft_eight_process_contention_has_no_overlap(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" start_path = tmp_path / "start" counter_path = tmp_path / "counter" occupied_path = tmp_path / "occupied" processes = [ subprocess.Popen( [ sys.executable, "-c", _WORKER, str(lock_path), str(start_path), str(counter_path), str(occupied_path), str(_ACQUISITIONS_PER_PROCESS), ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=sys.platform != "win32", creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0, ) for _ in range(_PROCESS_COUNT) ] start_path.touch() deadline = time.monotonic() + _WORKER_DEADLINE outputs: list[tuple[str, str]] = [] try: outputs.extend(process.communicate(timeout=max(0.1, deadline - time.monotonic())) for process in processes) except subprocess.TimeoutExpired: # pragma: no cover - test failure cleanup _kill_processes(processes) pytest.fail(f"strict soft-lock stress workers exceeded {_WORKER_DEADLINE} seconds") recorded = int(counter_path.read_text()) if counter_path.exists() else 0 assert ([process.returncode for process in processes], recorded) == ( [0] * _PROCESS_COUNT, _PROCESS_COUNT * _ACQUISITIONS_PER_PROCESS, ), outputs def _kill_processes(processes: list[subprocess.Popen[str]]) -> None: # pragma: no cover - test failure cleanup for process in processes: if process.poll() is None: if sys.platform == "win32": process.kill() else: os.killpg(process.pid, signal.SIGKILL) for process in processes: process.communicate() filelock-4.2.8/tests/test_subclass_options.py0000644000000000000000000003157615272561277016442 0ustar00from __future__ import annotations import asyncio import gc from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING, Final, Literal, Protocol, TypedDict, cast from weakref import ref import pytest from filelock import BaseAsyncFileLock, BaseFileLock, CloseErrorPolicy, ContextErrorPolicy, FileLock from tests.capability_marks import NEEDS_CLASS_COLLECTION if TYPE_CHECKING: from collections.abc import Callable from concurrent.futures import Executor from pathlib import Path from typing_extensions import Unpack @pytest.mark.parametrize( ("option", "value"), [ pytest.param("timeout", 1, id="timeout"), pytest.param("mode", 0o600, id="mode"), pytest.param("thread_local", False, id="thread-local"), pytest.param("blocking", False, id="blocking"), pytest.param("is_singleton", True, id="singleton"), pytest.param("poll_interval", 0.1, id="poll-interval"), pytest.param("lifetime", 1, id="lifetime"), pytest.param("context_error_policy", "group", id="context-error-policy"), pytest.param("close_error_policy", "raise", id="close-error-policy"), pytest.param("fallback_to_soft", False, id="fallback-to-soft"), pytest.param("preserve_lock_file", True, id="preserve-lock-file"), pytest.param("on_acquired", list[int]().append, id="on-acquired"), ], ) def test_narrow_sync_subclass_rejects_non_default_option( option: str, value: float | str | Callable[[int], None], tmp_path: Path, ) -> None: with pytest.raises(TypeError, match=option): _sync_constructor()( str(tmp_path / "lock"), **cast("_FileLockOptions", {option: value}), ) def test_narrow_sync_subclass_rejects_unknown_option(tmp_path: Path) -> None: with pytest.raises(TypeError, match="unknown"): _unknown_constructor()(str(tmp_path / "lock"), unknown=True) def test_singleton_cache_hit_rejects_unknown_option(tmp_path: Path) -> None: lock_path: Final = str(tmp_path / "lock") first: Final = FileLock(lock_path, is_singleton=True) with pytest.raises(TypeError, match="unknown"): cast("_UnknownSingletonConstructor", FileLock)(lock_path, is_singleton=True, unknown=True) assert FileLock(lock_path, is_singleton=True) is first @pytest.mark.parametrize( "option", [ pytest.param("thread_local", id="thread-local"), pytest.param("loop", id="loop"), pytest.param("run_in_executor", id="run-in-executor"), pytest.param("executor", id="executor"), ], ) def test_narrow_async_subclass_rejects_non_default_option( option: Literal["thread_local", "loop", "run_in_executor", "executor"], tmp_path: Path, ) -> None: loop = asyncio.new_event_loop() try: with ThreadPoolExecutor(max_workers=1) as executor, pytest.raises(TypeError, match=option): _async_constructor()( str(tmp_path / "lock"), **_unsupported_async_options(option, loop, executor), ) finally: loop.close() @pytest.mark.parametrize( "kind", [pytest.param("sync", id="sync"), pytest.param("async", id="async")], ) def test_narrow_subclass_accepts_explicit_base_defaults( kind: Literal["sync", "async"], tmp_path: Path, ) -> None: lock_path = str(tmp_path / "lock") assert _build_with_explicit_defaults(kind, lock_path).lock_file == lock_path def test_narrow_subclass_accepts_named_option(tmp_path: Path) -> None: assert _NamedFileLock(str(tmp_path / "lock"), marker=7).marker == 7 def test_sync_subclass_forwards_kwargs(tmp_path: Path) -> None: hook: Callable[[int], None] = list[int]().append lock = _ForwardingFileLock( str(tmp_path / "lock"), timeout=2, mode=0o600, thread_local=False, blocking=False, is_singleton=True, poll_interval=0.1, lifetime=3, context_error_policy="group", close_error_policy="suppress", fallback_to_soft=False, preserve_lock_file=True, on_acquired=hook, ) assert { "timeout": lock.timeout, "mode": lock.mode, "thread_local": lock.is_thread_local(), "blocking": lock.blocking, "is_singleton": lock.is_singleton, "poll_interval": lock.poll_interval, "lifetime": lock.lifetime, "context_error_policy": lock.context_error_policy, "close_error_policy": lock.close_error_policy, "fallback_to_soft": lock.fallback_to_soft, "preserve_lock_file": lock.preserve_lock_file, "on_acquired": lock.on_acquired, } == { "timeout": 2, "mode": 0o600, "thread_local": False, "blocking": False, "is_singleton": True, "poll_interval": 0.1, "lifetime": 3, "context_error_policy": "group", "close_error_policy": "suppress", "fallback_to_soft": False, "preserve_lock_file": True, "on_acquired": hook, } def test_async_subclass_forwards_kwargs(tmp_path: Path) -> None: hook: Callable[[int], None] = list[int]().append loop = asyncio.new_event_loop() try: with ThreadPoolExecutor(max_workers=1) as executor: lock = _ForwardingAsyncFileLock( str(tmp_path / "lock"), timeout=2, mode=0o600, thread_local=True, blocking=False, is_singleton=True, poll_interval=0.1, lifetime=3, context_error_policy="group", close_error_policy="suppress", fallback_to_soft=False, preserve_lock_file=True, on_acquired=hook, loop=loop, run_in_executor=False, executor=executor, ) assert { "timeout": lock.timeout, "mode": lock.mode, "thread_local": lock.is_thread_local(), "blocking": lock.blocking, "is_singleton": lock.is_singleton, "poll_interval": lock.poll_interval, "lifetime": lock.lifetime, "context_error_policy": lock.context_error_policy, "close_error_policy": lock.close_error_policy, "fallback_to_soft": lock.fallback_to_soft, "preserve_lock_file": lock.preserve_lock_file, "on_acquired": lock.on_acquired, "loop": lock.loop, "run_in_executor": lock.run_in_executor, "executor": lock.executor, } == { "timeout": 2, "mode": 0o600, "thread_local": True, "blocking": False, "is_singleton": True, "poll_interval": 0.1, "lifetime": 3, "context_error_policy": "group", "close_error_policy": "suppress", "fallback_to_soft": False, "preserve_lock_file": True, "on_acquired": hook, "loop": loop, "run_in_executor": False, "executor": executor, } finally: loop.close() @pytest.mark.parametrize( "option", [pytest.param("hook", id="hook"), pytest.param("policy", id="policy")], ) def test_forwarding_subclass_singleton_rejects_changed_option( option: Literal["hook", "policy"], tmp_path: Path, ) -> None: hook: Callable[[int], None] = list[int]().append lock_path = str(tmp_path / "lock") first = _ForwardingFileLock(lock_path, is_singleton=True, on_acquired=hook) with pytest.raises(ValueError, match="on_acquired" if option == "hook" else "context_error_policy"): _change_singleton_option(option, lock_path, hook) assert first.on_acquired is hook @NEEDS_CLASS_COLLECTION def test_constructor_model_does_not_retain_dynamic_subclass(tmp_path: Path) -> None: lock_type = _dynamic_constructor() class_ref = ref(lock_type) lock_type(str(tmp_path / "lock")) del lock_type gc.collect() assert class_ref() is None def _sync_constructor() -> type[BaseFileLock]: return cast("type[BaseFileLock]", _NarrowFileLock) def _async_constructor() -> type[BaseAsyncFileLock]: return cast("type[BaseAsyncFileLock]", _NarrowAsyncFileLock) def _unknown_constructor() -> _UnknownFileLockConstructor: return cast("_UnknownFileLockConstructor", _NarrowFileLock) def _unsupported_async_options( option: Literal["thread_local", "loop", "run_in_executor", "executor"], loop: asyncio.AbstractEventLoop, executor: Executor, ) -> _AsyncFileLockOptions: if option == "thread_local": return {"thread_local": True, "run_in_executor": False} if option == "loop": return {"loop": loop} if option == "run_in_executor": return {"run_in_executor": False} return {"executor": executor} def _build_with_explicit_defaults(kind: Literal["sync", "async"], lock_path: str) -> BaseFileLock: if kind == "sync": return _sync_constructor()( lock_path, timeout=-1, mode=-1, thread_local=True, blocking=True, is_singleton=False, poll_interval=0.05, lifetime=None, context_error_policy="chain", close_error_policy="default", fallback_to_soft=True, preserve_lock_file=False, on_acquired=None, ) return _async_constructor()( lock_path, timeout=-1, mode=-1, thread_local=False, blocking=True, is_singleton=False, poll_interval=0.05, lifetime=None, context_error_policy="chain", close_error_policy="default", fallback_to_soft=True, preserve_lock_file=False, on_acquired=None, loop=None, run_in_executor=True, executor=None, ) def _change_singleton_option( option: Literal["hook", "policy"], lock_path: str, hook: Callable[[int], None], ) -> BaseFileLock: if option == "hook": return _ForwardingFileLock(lock_path, is_singleton=True, on_acquired=list[int]().append) return _ForwardingFileLock( lock_path, is_singleton=True, context_error_policy="group", on_acquired=hook, ) def _dynamic_constructor() -> type[BaseFileLock]: class DynamicFileLock(BaseFileLock): def _acquire(self) -> None: raise NotImplementedError def _release(self) -> None: raise NotImplementedError return DynamicFileLock class _NarrowFileLock(BaseFileLock): _lifetime_supported = True def __init__(self, lock_file: str) -> None: super().__init__(lock_file) def _acquire(self) -> None: raise NotImplementedError def _release(self) -> None: raise NotImplementedError class _NamedFileLock(BaseFileLock): def __init__(self, lock_file: str, *, marker: int = 0) -> None: super().__init__(lock_file) self.marker = marker def _acquire(self) -> None: raise NotImplementedError def _release(self) -> None: raise NotImplementedError class _NarrowAsyncFileLock(BaseAsyncFileLock): _lifetime_supported = True def __init__(self, lock_file: str) -> None: super().__init__(lock_file) def _acquire(self) -> None: raise NotImplementedError def _release(self) -> None: raise NotImplementedError class _ForwardingFileLock(BaseFileLock): _lifetime_supported = True def __init__(self, lock_file: str, **kwargs: Unpack[_FileLockOptions]) -> None: super().__init__(lock_file, **kwargs) def _acquire(self) -> None: raise NotImplementedError def _release(self) -> None: raise NotImplementedError class _ForwardingAsyncFileLock(BaseAsyncFileLock): _lifetime_supported = True def __init__(self, lock_file: str, **kwargs: Unpack[_AsyncFileLockOptions]) -> None: super().__init__(lock_file, **kwargs) def _acquire(self) -> None: raise NotImplementedError def _release(self) -> None: raise NotImplementedError class _FileLockOptions(TypedDict, total=False): timeout: float mode: int thread_local: bool blocking: bool is_singleton: bool poll_interval: float lifetime: float | None context_error_policy: ContextErrorPolicy close_error_policy: CloseErrorPolicy fallback_to_soft: bool preserve_lock_file: bool on_acquired: Callable[[int], None] | None class _AsyncFileLockOptions(_FileLockOptions, total=False): loop: asyncio.AbstractEventLoop | None run_in_executor: bool executor: Executor | None class _UnknownFileLockConstructor(Protocol): def __call__(self, lock_file: str, *, unknown: bool) -> BaseFileLock: ... class _UnknownSingletonConstructor(Protocol): def __call__(self, lock_file: str, *, is_singleton: bool, unknown: bool) -> BaseFileLock: ... filelock-4.2.8/tests/test_unix_fallback.py0000644000000000000000000003332415272561277015643 0ustar00from __future__ import annotations import os import subprocess # ruff:ignore[suspicious-subprocess-import] # a clean interpreter isolates the blocked fcntl import import sys import warnings from errno import EACCES, EIO, ENOSYS, EWOULDBLOCK from textwrap import dedent from typing import TYPE_CHECKING, Final import pytest from filelock import SoftFileLock, Timeout, UnixFileLock from filelock._identity import host_name, process_start_token from tests.capability_marks import NEEDS_FCNTL if TYPE_CHECKING: from pathlib import Path from unittest.mock import MagicMock from pytest_mock import MockerFixture @NEEDS_FCNTL def test_import_without_fcntl_uses_soft_aliases_and_descriptor_errors() -> None: # pragma: needs fcntl script: Final[str] = dedent( """ import json import sys import warnings from collections.abc import Callable from errno import ENOSYS sys.modules["fcntl"] = None with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") import filelock def operation_errno(operation: Callable[[int], bool | None]) -> int | None: try: operation(-1) except OSError as exception: return exception.errno return None print(json.dumps({ "aliases": [ filelock.FileLock is filelock.SoftFileLock, filelock.AsyncFileLock is filelock.AsyncSoftFileLock, ], "errors": [ operation_errno(filelock.lock_descriptor) == ENOSYS, operation_errno(filelock.unlock_descriptor) == ENOSYS, ], "warnings": [str(item.message) for item in caught], }, sort_keys=True)) """, ) result: Final[subprocess.CompletedProcess[str]] = subprocess.run( [sys.executable, "-c", script], check=True, capture_output=True, text=True ) assert (result.stdout, result.stderr) == ( '{"aliases": [true, true], "errors": [true, true], "warnings": ["only soft file lock is available"]}\n', "", ) @NEEDS_FCNTL @pytest.mark.usefixtures("unsupported_flock") def test_fallback_emits_warning(tmp_path: Path) -> None: # pragma: needs fcntl lock = UnixFileLock(tmp_path / "test.lock") with pytest.warns(UserWarning, match="flock not supported on this filesystem, falling back to SoftFileLock"): lock.acquire() lock.release() @NEEDS_FCNTL def test_fchmod_enosys_is_not_a_missing_flock(tmp_path: Path, mocker: MockerFixture) -> None: # pragma: needs fcntl mocker.patch("os.fchmod", side_effect=OSError(ENOSYS, "Function not implemented")) lock = UnixFileLock(tmp_path / "test.lock", mode=0o644) with warnings.catch_warnings(record=True) as recorded: warnings.simplefilter("always") with pytest.raises(OSError, match="Function not implemented"): lock.acquire() assert not recorded assert not isinstance(lock, SoftFileLock) @NEEDS_FCNTL @pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning") @pytest.mark.usefixtures("unsupported_flock") def test_fallback_swaps_to_soft(tmp_path: Path) -> None: # pragma: needs fcntl lock = UnixFileLock(tmp_path / "test.lock") with lock: assert lock.is_locked assert isinstance(lock, SoftFileLock) @NEEDS_FCNTL @pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning") @pytest.mark.usefixtures("unsupported_flock") def test_fallback_writes_pid_and_hostname(tmp_path: Path) -> None: # pragma: needs fcntl lock_path = tmp_path / "test.lock" with UnixFileLock(lock_path): lines = lock_path.read_text(encoding="utf-8").splitlines() expected = [str(os.getpid()), host_name()] if (token := process_start_token(os.getpid())) is not None: # pragma: no branch # CI always exposes a start time expected.append(str(token)) assert lines == expected @NEEDS_FCNTL @pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning") @pytest.mark.usefixtures("unsupported_flock") def test_fallback_release_unlinks_file(tmp_path: Path) -> None: # pragma: needs fcntl lock_path = tmp_path / "test.lock" lock = UnixFileLock(lock_path) lock.acquire() assert lock_path.exists() lock.release() assert not lock_path.exists() @NEEDS_FCNTL @pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning") # pragma: needs fcntl def test_fallback_subsequent_acquire_skips_flock(tmp_path: Path, unsupported_flock: MagicMock) -> None: lock = UnixFileLock(tmp_path / "test.lock") lock.acquire() lock.release() unsupported_flock.reset_mock() lock.acquire() lock.release() unsupported_flock.assert_not_called() @NEEDS_FCNTL @pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning") @pytest.mark.usefixtures("unsupported_flock") def test_fallback_reentrant_locking(tmp_path: Path) -> None: # pragma: needs fcntl lock = UnixFileLock(tmp_path / "test.lock") with lock: with lock: assert lock.is_locked assert lock.is_locked assert not lock.is_locked @NEEDS_FCNTL @pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning") @pytest.mark.usefixtures("unsupported_flock") def test_fallback_removes_placeholder_it_created(tmp_path: Path) -> None: # pragma: needs fcntl lock = UnixFileLock(tmp_path / "test.lock") lock.acquire(timeout=0) assert lock.is_locked lock.release() @NEEDS_FCNTL @pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning") @pytest.mark.usefixtures("unsupported_flock") def test_fallback_keeps_peer_soft_marker(tmp_path: Path) -> None: # pragma: needs fcntl lock_path = tmp_path / "test.lock" holder = UnixFileLock(lock_path) holder.acquire() marker = lock_path.stat().st_ino, lock_path.read_bytes() with pytest.raises(Timeout): UnixFileLock(lock_path).acquire(timeout=0) assert (lock_path.stat().st_ino, lock_path.read_bytes()) == marker holder.release() @NEEDS_FCNTL @pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning") def test_fallback_spares_a_peer_marker_that_replaced_its_placeholder( # pragma: needs fcntl tmp_path: Path, mocker: MockerFixture ) -> None: lock_path: Final = tmp_path / "test.lock" peer_marker: Final = f"{os.getpid()}\n{host_name()}\n" lstat: Final = os.lstat stalled: list[bool] = [] def flock_missing(fd: int, operation: int) -> None: stalled.append(False) raise OSError(ENOSYS, f"Function not implemented for {fd} with {operation}") def lstat_then_peer_replaces_placeholder(path: str, *, dir_fd: int | None = None) -> os.stat_result: found: Final = lstat(path, dir_fd=dir_fd) if stalled == [False] and os.fspath(path) == os.fspath(lock_path): # The cleanup stalls past the grace a waiting soft lock gives an empty file, which evicts the placeholder # and publishes its own marker. stalled[0] = True lock_path.unlink() lock_path.write_text(peer_marker, encoding="utf-8") return found mocker.patch("filelock._unix.fcntl.flock", side_effect=flock_missing) mocker.patch("os.lstat", side_effect=lstat_then_peer_replaces_placeholder) with pytest.raises(Timeout): UnixFileLock(lock_path).acquire(timeout=0) mocker.stopall() # Where os.link cannot restore it, the peer's marker waits under a break name, but it is never deleted. assert peer_marker in [path.read_text(encoding="utf-8") for path in tmp_path.glob("test.lock*")] @NEEDS_FCNTL @pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning") @pytest.mark.usefixtures("unsupported_flock", "write_denied_lock_file") def test_fallback_waits_on_a_peer_marker_it_cannot_write(tmp_path: Path) -> None: # pragma: needs fcntl lock_path = tmp_path / "test.lock" holder = UnixFileLock(lock_path) holder.acquire() with pytest.raises(Timeout): UnixFileLock(lock_path).acquire(timeout=0) holder.release() with (contender := UnixFileLock(lock_path)).acquire(timeout=0): assert contender.is_locked @NEEDS_FCNTL @pytest.mark.usefixtures("write_denied_lock_file") def test_lock_file_it_cannot_write_stays_denied_where_flock_works(tmp_path: Path) -> None: # pragma: needs fcntl (lock_path := tmp_path / "test.lock").touch() with pytest.raises(PermissionError): UnixFileLock(lock_path).acquire(timeout=0) @NEEDS_FCNTL def test_denied_lock_file_is_reopened_in_the_acquisition_directory( # pragma: needs fcntl working_directories: tuple[Path, Path], mocker: MockerFixture ) -> None: # Resolved against the working directory of the moment, a chdir between the refused O_RDWR open and the read-only # reopen probes a file in another directory, or none. first, second = working_directories (first / "test.lock").touch() real_open: Final = os.open read_only_opens: list[str] = [] def deny_write_then_chdir(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: if not flags & os.O_CREAT and "test.lock" in path: if flags & os.O_RDWR: os.chdir(second) raise PermissionError(EACCES, "Permission denied", path) read_only_opens.append(path) return real_open(path, flags, mode, dir_fd=dir_fd) mocker.patch("os.open", side_effect=deny_write_then_chdir) with pytest.raises(PermissionError): UnixFileLock("test.lock").acquire(timeout=0) assert read_only_opens == [str(first.resolve() / "test.lock")] @NEEDS_FCNTL @pytest.mark.parametrize("write_denied_lock_file", ["leased"], indirect=True) @pytest.mark.usefixtures("write_denied_lock_file") def test_leased_lock_file_it_cannot_write_is_contention_where_flock_works( tmp_path: Path, ) -> None: # pragma: needs fcntl (lock_path := tmp_path / "test.lock").touch() with pytest.raises(Timeout): UnixFileLock(lock_path).acquire(timeout=0) @NEEDS_FCNTL @pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning") @pytest.mark.parametrize("write_denied_lock_file", ["leased"], indirect=True) @pytest.mark.usefixtures("unsupported_flock", "write_denied_lock_file") def test_fallback_treats_a_leased_marker_it_cannot_write_as_contention(tmp_path: Path) -> None: # pragma: needs fcntl (lock_path := tmp_path / "test.lock").touch() with pytest.raises(Timeout): UnixFileLock(lock_path).acquire(timeout=0) @NEEDS_FCNTL @pytest.mark.parametrize("write_denied_lock_file", ["unlink"], ids=["unlinked-under-the-denial"], indirect=True) @pytest.mark.usefixtures("write_denied_lock_file") def test_lock_file_unlinked_under_a_denied_open_is_retried(tmp_path: Path) -> None: # pragma: needs fcntl (lock_path := tmp_path / "test.lock").touch() with (lock := UnixFileLock(lock_path)).acquire(timeout=1): assert lock.is_locked @pytest.fixture def write_denied_lock_file( request: pytest.FixtureRequest, tmp_path: Path, mocker: MockerFixture ) -> None: # pragma: needs fcntl # Stand in for a marker another account owns: an O_RDWR open is refused, a read works unless the file is leased. behavior = getattr(request, "param", "deny") real_open = os.open def deny_write(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: if not flags & os.O_CREAT and str(tmp_path / "test.lock") == path: if flags & os.O_RDWR: if behavior == "unlink": (tmp_path / "test.lock").unlink() raise PermissionError(EACCES, "Permission denied", path) if behavior == "leased": raise BlockingIOError(EWOULDBLOCK, "Resource temporarily unavailable", path) return real_open(path, flags, mode, dir_fd=dir_fd) mocker.patch("os.open", side_effect=deny_write) @NEEDS_FCNTL # pragma: needs fcntl def test_trailing_slash_path_raises_instead_of_retrying(tmp_path: Path) -> None: # macOS reports ENOENT for a create through a trailing slash, Linux EISDIR; neither is contention. with pytest.raises((FileNotFoundError, IsADirectoryError)): UnixFileLock(f"{tmp_path / 'missing'}{os.sep}").acquire(timeout=0) @NEEDS_FCNTL def test_release_suppresses_eio_on_close(tmp_path: Path, mocker: MockerFixture) -> None: # pragma: needs fcntl lock = UnixFileLock(tmp_path / "test.lock") lock.acquire() real_close = os.close # capture before the patch so the fd still closes for real fd_to_fail = lock._context.lock_file_fd # _release() nulls this before closing, so read it now def _close_eio(fd: int) -> None: real_close(fd) if fd == fd_to_fail: # pragma: no branch # only the lock's own descriptor closes while the patch is live raise OSError(EIO, "Input/output error") mocker.patch("filelock._unix.os.close", side_effect=_close_eio) lock.release() assert not lock.is_locked @NEEDS_FCNTL # pragma: needs fcntl def test_acquire_flock_error_clears_pending_descriptor(tmp_path: Path, mocker: MockerFixture) -> None: lock = UnixFileLock(tmp_path / "test.lock") mocker.patch( "filelock._unix.fcntl.flock", side_effect=[OSError(EIO, "Input/output error"), None, None], ) with pytest.raises(OSError, match="Input/output error"): lock.acquire(timeout=0) assert not lock.is_locked lock.acquire(timeout=0) assert lock.is_locked lock.release() assert not lock.is_locked @pytest.fixture def unsupported_flock(mocker: MockerFixture) -> MagicMock: # pragma: needs fcntl return mocker.patch("filelock._unix.fcntl.flock", side_effect=OSError(ENOSYS, "Function not implemented")) filelock-4.2.8/tests/test_util.py0000644000000000000000000007110015272561277014010 0ustar00from __future__ import annotations import os import runpy import sys import time from contextlib import suppress from errno import EACCES, EINVAL, EIO, ELOOP, ENOENT, EPERM, ESRCH from pathlib import Path from typing import TYPE_CHECKING, Final import pytest from capabilities import CAPABILITIES import filelock._util from filelock import SoftFileLock, Timeout from filelock._identity import host_name from filelock._util import break_lock_file from tests.capability_marks import NEEDS_FILE_PERMISSIONS, NEEDS_STALE_RECLAIM, NEEDS_SYMLINK, NEEDS_UNLINK_OPEN_FILE from tests.conftest import DEAD_HOLDER, STALE_BREAKERS from tests.process_helpers import call_within if TYPE_CHECKING: from collections.abc import Callable, Generator from contextlib import AbstractContextManager from pytest_mock import MockerFixture _SKIP_AS_ROOT: Final[pytest.MarkDecorator] = pytest.mark.skipif( sys.platform != "win32" and os.geteuid() == 0, reason="root can write a 0o444 file", ) _NEEDS_ACCESS_NOFOLLOW: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["access-nofollow"], reason="os.access cannot refuse to follow a symlink here" ) @pytest.fixture def stale_lock(tmp_path: Path) -> Path: path: Final[Path] = tmp_path / "test.lock" path.write_text(DEAD_HOLDER, encoding="utf-8") return path @NEEDS_STALE_RECLAIM def test_break_lock_file_unlinks_a_dead_holders_marker(stale_lock: Path) -> None: with SoftFileLock(stale_lock, timeout=1): assert list(stale_lock.parent.glob("test.lock.break.*")) == [] @pytest.mark.skipif(sys.platform == "win32", reason="Windows can reclaim a pinned marker without exchange") @pytest.mark.usefixtures("break_route") @pytest.mark.parametrize("break_route", ["no-exchange"], indirect=True) def test_stale_marker_is_retained_without_exchange(stale_lock: Path) -> None: # pragma: win32 no cover with pytest.raises(Timeout): SoftFileLock(stale_lock).acquire(blocking=False) assert (stale_lock.read_text(encoding="utf-8"), list(stale_lock.parent.glob("test.lock.break.*"))) == ( DEAD_HOLDER, [], ) @pytest.mark.skipif(sys.platform != "win32", reason="oplocks exist only on Windows") def test_break_lock_file_skips_a_marker_under_an_oplock( # pragma: win32 cover stale_lock: Path, hold_oplock: Callable[[Path], Callable[[], None]] ) -> None: held = stale_lock.stat() hold_oplock(stale_lock) finished, error = call_within(lambda: break_lock_file(str(stale_lock), held.st_mtime, held.st_ino), 5) assert (finished, type(error), stale_lock.exists()) == (True, BlockingIOError, True) def _rewrite_in_place(lock: Path) -> None: lock.write_text("live", encoding="utf-8") os.utime(lock, (time.time() + 10, time.time() + 10)) def _replace_with_same_mtime(lock: Path) -> None: # A filesystem with coarse modification times (NFS, FAT) gives a same-second recreation the old mtime, so only the # inode tells it apart. Writing the replacement while the original exists guarantees a fresh inode. stale: Final[os.stat_result] = lock.stat() (replacement := lock.with_name("recreated")).write_text("live", encoding="utf-8") os.utime(replacement, ns=(stale.st_atime_ns, stale.st_mtime_ns)) replacement.replace(lock) @pytest.mark.usefixtures("break_route") @pytest.mark.parametrize( "recreate", [pytest.param(_rewrite_in_place, id="mtime-advanced"), pytest.param(_replace_with_same_mtime, id="inode-changed")], ) def test_break_lock_file_keeps_a_marker_recreated_after_the_recheck( stale_lock: Path, mocker: MockerFixture, on_break_recheck: Callable[[Path, Callable[[], None]], None], recreate: Callable[[Path], None], ) -> None: on_break_recheck(stale_lock, lambda: recreate(stale_lock)) with pytest.raises(Timeout): SoftFileLock(stale_lock).acquire(blocking=False) mocker.stopall() assert (stale_lock.read_text(encoding="utf-8"), list(stale_lock.parent.glob("test.lock.break.*"))) == ("live", []) @pytest.mark.parametrize( "recreate", [ pytest.param(_rewrite_in_place, id="mtime-advanced"), # The peer replaces the marker while the stale read still holds it open. pytest.param(_replace_with_same_mtime, id="inode-changed", marks=NEEDS_UNLINK_OPEN_FILE), ], ) def test_break_lock_file_leaves_a_marker_recreated_since_the_stale_check( stale_lock: Path, mocker: MockerFixture, recreate: Callable[[Path], None] ) -> None: def read_then_peer_recreates(fd: int, length: int) -> bytes: data: Final = real_read(fd, length) recreate(stale_lock) return data real_read: Final = os.read mocker.patch("os.read", autospec=True, side_effect=read_then_peer_recreates) rename: Final = mocker.spy(Path, "rename") with pytest.raises(Timeout): SoftFileLock(stale_lock).acquire(blocking=False) # Moving the live marker aside would empty the path until it is linked back, long enough for a third holder. assert (stale_lock.read_text(encoding="utf-8"), rename.call_count) == ("live", 0) @pytest.mark.usefixtures("break_route") @pytest.mark.parametrize( "point", [ pytest.param("lstat", id="after-the-recheck-and-the-take"), pytest.param("open", id="after-the-placeholder"), pytest.param("unlink", id="after-each-removal"), ], ) def test_stale_break_admits_no_third_holder_beside_a_recreated_marker( stale_lock: Path, mocker: MockerFixture, on_break_recheck: Callable[[Path, Callable[[], None]], None], point: str ) -> None: # A peer broke the stale marker and the next holder acquired after the recheck. A third contender then polls at # every step of the break, including between taking the marker and checking whose it is. holder: Final = SoftFileLock(stale_lock) overlaps: list[bool] = [] polling: list[bool] = [] real: Final = getattr(os, point) def third_contender_polls(path: str | os.PathLike[str], *args: int, **kwargs: int | None) -> object: result: Final = real(path, *args, **kwargs) if holder.is_locked and not polling and os.fspath(path).startswith(os.fspath(stale_lock)): polling.append(True) third: Final = SoftFileLock(stale_lock) with suppress(Timeout): third.acquire(blocking=False) overlaps.append(third.is_locked) third.release() polling.clear() return result def peer_breaks_and_holder_acquires() -> None: stale_lock.unlink() holder.acquire() mocker.patch.object(os, point, new=third_contender_polls) on_break_recheck(stale_lock, peer_breaks_and_holder_acquires) with pytest.raises(Timeout): SoftFileLock(stale_lock).acquire(blocking=False) mocker.stopall() assert (any(overlaps), holder.is_lock_held_by_us) == (False, True) holder.release() @pytest.mark.skipif(sys.platform != "win32", reason="only Windows renames a marker aside") def test_break_lock_file_put_back_never_replaces_a_marker_at_the_path( # pragma: win32 cover stale_lock: Path, mocker: MockerFixture ) -> None: assert sys.platform == "win32" rename: Final = Path.rename pin: Final = filelock._util._pinned_for_rename def peer_recreates_then_pin(path: str) -> AbstractContextManager[object]: _rewrite_in_place(Path(path)) # Windows refuses an in-place rewrite once the pin holds the marker return pin(path) def rename_then_third_creates(source: Path, target: str) -> Path: result: Final[Path] = rename(source, target) source.write_text("third", encoding="utf-8") return result mocker.patch("filelock._util._pinned_for_rename", side_effect=peer_recreates_then_pin) mocker.patch.object(Path, "rename", autospec=True, side_effect=rename_then_third_creates) with pytest.raises(Timeout): SoftFileLock(stale_lock).acquire(blocking=False) # link never replaces, so the third marker keeps the path and the live one waits under the break name. assert ( stale_lock.read_text(encoding="utf-8"), [path.read_text(encoding="utf-8") for path in stale_lock.parent.glob("test.lock.break.*")], ) == ("third", ["live"]) @NEEDS_SYMLINK @pytest.mark.usefixtures("break_route") def test_break_lock_file_never_links_a_symlink_swapped_in_before_the_take( # pragma: needs symlink stale_lock: Path, mocker: MockerFixture, on_break_recheck: Callable[[Path, Callable[[], None]], None] ) -> None: (target := stale_lock.with_name("target")).write_text("victim", encoding="utf-8") def peer_swaps_in_a_symlink() -> None: stale_lock.unlink() stale_lock.symlink_to(target) on_break_recheck(stale_lock, peer_swaps_in_a_symlink) with pytest.raises(Timeout): SoftFileLock(stale_lock).acquire(blocking=False) mocker.stopall() # Linking a symlink back with link's default would publish the target's inode at the lock path. assert (target.stat().st_nlink, target.read_text(encoding="utf-8")) == (1, "victim") @NEEDS_SYMLINK def test_break_lock_file_never_links_a_symlink_swapped_in_under_the_break_name( # pragma: needs symlink stale_lock: Path, mocker: MockerFixture ) -> None: (target := stale_lock.with_name("target")).write_text("private", encoding="utf-8") lstat: Final = os.lstat def peer_recreates_then_swaps_in_a_symlink(path: str, *, dir_fd: int | None = None) -> os.stat_result: if ".break." not in os.fspath(path): return lstat(path, dir_fd=dir_fd) _rewrite_in_place(Path(path)) found: Final = lstat(path, dir_fd=dir_fd) Path(path).unlink() Path(path).symlink_to(target) return found mocker.patch("os.lstat", side_effect=peer_recreates_then_swaps_in_a_symlink) with pytest.raises(Timeout): SoftFileLock(stale_lock).acquire(blocking=False) mocker.stopall() assert target.stat().st_nlink == 1 @pytest.mark.skipif(sys.platform == "win32", reason="the liveness probe of a Windows PID does not call os.kill") @STALE_BREAKERS def test_stale_break_spares_a_successor_on_the_freed_inode_number( # pragma: win32 no cover tmp_path: Path, mocker: MockerFixture, reuse_inode: Callable[[Path, os.stat_result], None], lock_type: type[SoftFileLock], record: str, ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") stale: Final = lock.lstat() def owner_gone_and_successor_acquired(pid: int, signal: int) -> None: # Between the stale read and the break the marker goes and the next holder's marker takes the freed inode # number, created in the same timestamp tick. lock.unlink() lock.write_text("live", encoding="utf-8") os.utime(lock, ns=(stale.st_atime_ns, stale.st_mtime_ns)) reuse_inode(lock, stale) raise ProcessLookupError(ESRCH, f"no process {pid} for signal {signal}") mocker.patch("filelock._identity.os.kill", side_effect=owner_gone_and_successor_acquired) with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) mocker.stopall() assert lock.read_text(encoding="utf-8") == "live" @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot rename a file held open, so it pins nothing") @STALE_BREAKERS def test_stale_break_skips_a_marker_swapped_in_after_the_pin( # pragma: win32 no cover tmp_path: Path, mocker: MockerFixture, lock_type: type[SoftFileLock], record: str ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") fstat: Final = os.fstat swapped: list[bool] = [] def pin_then_peer_swaps_the_marker(fd: int) -> os.stat_result: found: Final = fstat(fd) if not swapped: swapped.append(True) (replacement := lock.with_name("replacement")).write_text(record, encoding="utf-8") replacement.replace(lock) return found mocker.patch("os.fstat", side_effect=pin_then_peer_swaps_the_marker) rename: Final = mocker.spy(Path, "rename") with pytest.raises(Timeout): lock_type(lock).acquire(blocking=False) mocker.stopall() # Nothing keeps the swapped-in node's inode allocated, so its number proves nothing by the time of a break. assert (lock.read_text(encoding="utf-8"), rename.call_count) == (record, 0) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot rename a file held open, so it pins nothing") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_reclaims_a_marker_it_cannot_pin( # pragma: win32 no cover tmp_path: Path, mocker: MockerFixture, lock_type: type[SoftFileLock], record: str ) -> None: # Without O_PATH a symlink or a socket cannot be opened, and the check falls back to the number it read. (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") open_: Final = os.open pinned: list[bool] = [] def pin_refused(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: # The pin is the first open of the existing marker; the read after it opens the file for real. if os.fspath(path) == os.fspath(lock) and not flags & os.O_CREAT and not pinned: pinned.append(True) raise OSError(ELOOP, "Too many levels of symbolic links", os.fspath(path)) return open_(path, flags, mode, dir_fd=dir_fd) mocker.patch("os.open", side_effect=pin_refused) with lock_type(lock, timeout=1) as held: assert held.is_locked @pytest.mark.skipif(sys.platform == "win32", reason="Windows does not open the marker while pinning") @STALE_BREAKERS def test_stale_break_lets_acquire_retry_after_the_marker_vanishes_before_the_pin( # pragma: win32 no cover tmp_path: Path, mocker: MockerFixture, lock_type: type[SoftFileLock], record: str ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") open_: Final = os.open released: list[bool] = [] def holder_releases_then_open(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: if os.fspath(path) == os.fspath(lock) and not flags & os.O_CREAT and not released: released.append(True) lock.unlink() return open_(path, flags, mode, dir_fd=dir_fd) mocker.patch("os.open", side_effect=holder_releases_then_open) with lock_type(lock, timeout=1) as held: assert held.is_locked @pytest.mark.usefixtures("break_route") def test_break_lock_file_lets_acquire_retry_after_the_marker_vanishes( stale_lock: Path, on_break_recheck: Callable[[Path, Callable[[], None]], None] ) -> None: on_break_recheck(stale_lock, stale_lock.unlink) with SoftFileLock(stale_lock, timeout=1): assert stale_lock.read_text(encoding="utf-8").splitlines()[0] == str(os.getpid()) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @pytest.mark.filterwarnings("ignore::filelock.SoftFileLockLifetimeWarning") @pytest.mark.parametrize( "lifetime", [pytest.param(None, id="stale-take-over"), pytest.param(0.1, id="expiry-placeholder")], ) @NEEDS_STALE_RECLAIM def test_stale_take_over_keeps_the_lock_when_a_rename_replaces_its_marker( # pragma: win32 no cover stale_lock: Path, mocker: MockerFixture, lifetime: float | None ) -> None: os.utime(stale_lock, (0, 0)) lstat: Final = os.lstat replaced: list[bool] = [] def peer_replaces_the_swapped_in_file(path: str | os.PathLike[str], *, dir_fd: int | None = None) -> os.stat_result: found: Final = lstat(path, dir_fd=dir_fd) if ".break." in os.fspath(path) and not replaced: replaced.append(True) (other := stale_lock.with_name("other")).write_text("other", encoding="utf-8") other.replace(stale_lock) return found mocker.patch.object(os, "lstat", new=peer_replaces_the_swapped_in_file) lock: Final = SoftFileLock(stale_lock, lifetime=lifetime) lock.acquire(blocking=False) mocker.stopall() held: Final = lock.is_locked lock.release() assert (held, stale_lock.read_text(encoding="utf-8"), list(stale_lock.parent.glob("*.break.*"))) == ( True, "other", [], ) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @NEEDS_STALE_RECLAIM def test_remove_own_file_placeholder_names_this_process( tmp_path: Path, mocker: MockerFixture ) -> None: # pragma: win32 no cover mocker.patch("filelock._soft.os.fchmod", side_effect=OSError(EIO, "Input/output error")) exchange: Final = filelock._util.exchange_names placeholders: Final[list[bytes]] = [] def record_the_placeholder(source: str, target: str) -> bool: placeholders.append(Path(source).read_bytes()) return exchange(source, target) mocker.patch("filelock._util.exchange_names", side_effect=record_the_placeholder) with pytest.raises(OSError, match="Input/output error"): SoftFileLock(tmp_path / "test.lock", mode=0o600).acquire() assert placeholders == [f"filelock/break\n{os.getpid()}\n".encode()] @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @STALE_BREAKERS @NEEDS_STALE_RECLAIM def test_stale_break_takes_the_lock_over_in_one_poll( # pragma: win32 no cover tmp_path: Path, lock_type: type[SoftFileLock], record: str ) -> None: (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") contender: Final = lock_type(lock) contender.acquire(blocking=False) assert (contender.is_lock_held_by_us, list(tmp_path.glob("test.lock.break.*"))) == (True, []) contender.release() @pytest.mark.skipif(sys.platform == "win32", reason="Windows pins nothing") def test_stale_break_spares_a_successor_on_the_freed_inode_number_across_the_exchange( # pragma: win32 no cover stale_lock: Path, mocker: MockerFixture, reuse_inode: Callable[[Path, os.stat_result], None], on_break_recheck: Callable[[Path, Callable[[], None]], None], ) -> None: stale: Final = stale_lock.lstat() def successor_takes_the_freed_number() -> None: # A peer broke the stale marker after the recheck and the next holder's marker got its inode number, created in # the same timestamp tick, so only the pinned inode tells the two apart once the exchange has taken it. stale_lock.unlink() stale_lock.write_text("live", encoding="utf-8") os.utime(stale_lock, ns=(stale.st_atime_ns, stale.st_mtime_ns)) reuse_inode(stale_lock, stale) on_break_recheck(stale_lock, successor_takes_the_freed_number) with pytest.raises(Timeout): SoftFileLock(stale_lock).acquire(blocking=False) mocker.stopall() assert (stale_lock.read_text(encoding="utf-8"), list(stale_lock.parent.glob("test.lock.break.*"))) == ("live", []) @pytest.mark.skipif(sys.platform != "win32", reason="only Windows renames a marker aside") def test_break_lock_file_leaves_a_recreated_marker_aside_where_links_are_refused( # pragma: win32 cover stale_lock: Path, mocker: MockerFixture, on_break_recheck: Callable[[Path, Callable[[], None]], None] ) -> None: mocker.patch("os.link", side_effect=PermissionError(1, "Operation not permitted")) on_break_recheck(stale_lock, lambda: _rewrite_in_place(stale_lock)) with pytest.raises(Timeout): SoftFileLock(stale_lock).acquire(blocking=False) mocker.stopall() assert [path.read_text(encoding="utf-8") for path in stale_lock.parent.glob("test.lock.break.*")] == ["live"] @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @pytest.mark.parametrize( "error", [ pytest.param(EINVAL, id="filesystem-without-exchange"), pytest.param(EACCES, id="exchange-denied"), ], ) def test_break_lock_file_leaves_the_marker_where_the_exchange_fails( # pragma: win32 no cover stale_lock: Path, mocker: MockerFixture, error: int ) -> None: # NFS and SMB refuse the exchange; renaming the marker aside instead would empty the path beside a live holder. renamer: Final = mocker.MagicMock(return_value=error) mocker.patch("filelock._util._rename_call", return_value=(renamer, 2)) with pytest.raises(Timeout): SoftFileLock(stale_lock).acquire(blocking=False) assert ( stale_lock.read_text(encoding="utf-8"), list(stale_lock.parent.glob("test.lock.break.*")), renamer.called, ) == ( DEAD_HOLDER, [], True, ) @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") @NEEDS_STALE_RECLAIM def test_stale_take_over_interrupted_before_the_check_puts_the_marker_back( # pragma: win32 no cover stale_lock: Path, mocker: MockerFixture ) -> None: # Between the exchange and the check the lock path names the contender's own marker; an interrupt there must not # leave it, naming a live PID that holds nothing, nor keep the marker it took. lstat: Final = os.lstat interrupted: list[bool] = [] def interrupt_after_the_exchange(path: str | os.PathLike[str], *, dir_fd: int | None = None) -> os.stat_result: if ".break." in os.fspath(path) and not interrupted: interrupted.append(True) raise KeyboardInterrupt return lstat(path, dir_fd=dir_fd) mocker.patch.object(os, "lstat", new=interrupt_after_the_exchange) lock: Final = SoftFileLock(stale_lock) with pytest.raises(KeyboardInterrupt): lock.acquire(blocking=False) mocker.stopall() assert ( stale_lock.read_text(encoding="utf-8"), list(stale_lock.parent.glob("test.lock.break.*")), lock.is_locked, ) == ( DEAD_HOLDER, [], False, ) @NEEDS_STALE_RECLAIM def test_break_lock_file_break_name_is_unguessable(stale_lock: Path, mocker: MockerFixture) -> None: # A second breaker in this process could compute .break. and rename a recreated live lock onto it # between our lstat and unlink. Were that our break name, our unlink would delete the live lock. guessable: Final[Path] = stale_lock.with_name(f"test.lock.break.{os.getpid()}") lstat: Final = os.lstat def peer_moves_a_live_lock_onto_the_guessable_name(path: str | os.PathLike[str]) -> os.stat_result: result: Final[os.stat_result] = lstat(path) if ".break." in os.fspath(path) and not guessable.exists(): stale_lock.write_text("live", encoding="utf-8") stale_lock.rename(guessable) return result mocker.patch("os.lstat", side_effect=peer_moves_a_live_lock_onto_the_guessable_name) with SoftFileLock(stale_lock): pass assert guessable.read_text(encoding="utf-8") == "live" @pytest.mark.skipif(sys.platform == "win32", reason="symlink-to-dir raises IsADirectoryError only on Unix") def test_writability_check_does_not_follow_symlink_to_dir(tmp_path: Path) -> None: # pragma: win32 no cover (target := tmp_path / "targetdir").mkdir() (link := tmp_path / "my.lock").symlink_to(target) # Following the symlink would see a directory and raise IsADirectoryError; the lock waits on the link instead. with pytest.raises(Timeout): SoftFileLock(link).acquire(blocking=False) @pytest.mark.skipif(sys.platform == "win32", reason="symlink + 0o444 semantics differ on Windows") @_SKIP_AS_ROOT # pragma: win32 no cover def test_writability_check_does_not_follow_symlink_to_readonly(tmp_path: Path) -> None: (target := tmp_path / "readonly").write_text("x", encoding="utf-8") target.chmod(0o444) (link := tmp_path / "my.lock").symlink_to(target) # Following the symlink would see a read-only file and raise PermissionError; the link itself is writable. with pytest.raises(Timeout): SoftFileLock(link).acquire(blocking=False) @pytest.mark.skipif(sys.platform == "win32", reason="a real directory raises PermissionError on Windows") @pytest.mark.parametrize( "mode", [ pytest.param(0o755, id="writable"), pytest.param(0o555, id="readonly", marks=NEEDS_FILE_PERMISSIONS), ], ) @pytest.mark.parametrize("mtime", [pytest.param(0, id="mtime-zero"), pytest.param(2_000_000_000, id="mtime-future")]) def test_writability_check_rejects_a_directory(tmp_path: Path, mode: int, mtime: int) -> None: # pragma: win32 no cover (path := tmp_path / "a_dir").mkdir() os.utime(path, (mtime, mtime)) path.chmod(mode) try: with pytest.raises(IsADirectoryError): SoftFileLock(path).acquire(blocking=False) finally: path.chmod(0o755) @_SKIP_AS_ROOT @pytest.mark.parametrize("mtime", [pytest.param(0, id="mtime-zero"), pytest.param(2_000_000_000, id="mtime-future")]) def test_writability_check_rejects_a_readonly_file(tmp_path: Path, mtime: int) -> None: (path := tmp_path / "ro.lock").write_text("x", encoding="utf-8") path.chmod(0o444) try: os.utime(path, (mtime, mtime)) with pytest.raises(PermissionError): SoftFileLock(path).acquire(blocking=False) finally: path.chmod(0o644) @pytest.mark.parametrize( "mode", [pytest.param(0o444, id="acl"), pytest.param(0o464, id="group"), pytest.param(0o446, id="other")] ) @pytest.mark.parametrize("effective_ids", [pytest.param(True, id="supported"), pytest.param(False, id="unsupported")]) def test_writability_uses_open_credentials( readonly_marker: Path, mocker: MockerFixture, mode: int, *, effective_ids: bool ) -> None: readonly_marker.chmod(mode) def access(filename: str, permission: int, *, effective_ids: bool = False, **_: bool) -> bool: assert (filename, permission) == (str(readonly_marker), os.W_OK) return effective_ids == supported supported: Final = effective_ids probe: Final = mocker.patch("os.access", autospec=True, side_effect=access) mocker.patch("os.supports_effective_ids", {probe} if effective_ids else set()) with pytest.raises(Timeout): SoftFileLock(readonly_marker).acquire(timeout=0) def test_writability_allows_creation_after_marker_disappears(readonly_marker: Path, mocker: MockerFixture) -> None: def access(filename: str, permission: int, *, effective_ids: bool = False, **_: bool) -> bool: assert (permission, effective_ids) == (os.W_OK, os.access in os.supports_effective_ids) readonly_marker.chmod(0o644) Path(filename).unlink() return False mocker.patch("os.access", autospec=True, side_effect=access) with (lock := SoftFileLock(readonly_marker)).acquire(timeout=0): assert lock.is_locked @_NEEDS_ACCESS_NOFOLLOW # pragma: needs access-nofollow def test_writability_check_ignores_symlink_swapped_in_after_lstat(readonly_marker: Path, mocker: MockerFixture) -> None: def swap_then_access(filename: str, permission: int, **kwargs: bool) -> bool: Path(filename).unlink() Path(filename).symlink_to(readonly_marker.with_name("missing")) return real_access(filename, permission, **kwargs) real_access: Final = os.access probe: Final = mocker.patch("os.access", autospec=True, side_effect=swap_then_access) mocker.patch("os.supports_follow_symlinks", {*os.supports_follow_symlinks, probe}) # Following the symlink would find no target and raise PermissionError; the link itself is writable. with pytest.raises(Timeout): SoftFileLock(readonly_marker).acquire(timeout=0) def test_writability_rejects_denied_marker(readonly_marker: Path, mocker: MockerFixture) -> None: mocker.patch("os.access", autospec=True, return_value=False) with pytest.raises(PermissionError): SoftFileLock(readonly_marker).acquire(timeout=0) @pytest.fixture def readonly_marker(tmp_path: Path) -> Generator[Path]: path: Final = tmp_path / "test.lock" path.write_text(f"424242\n{host_name()}-other\n", encoding="utf-8") path.chmod(0o444) try: yield path finally: with suppress(FileNotFoundError): path.chmod(0o644) @pytest.mark.parametrize( ("error", "expected"), [ pytest.param(None, False, id="privileged"), pytest.param(PermissionError(EACCES, "denied"), True, id="access-denied"), pytest.param(PermissionError(EPERM, "denied"), True, id="operation-denied"), ], ) @pytest.mark.usefixtures("_read_failure") def test_file_permissions_capability(*, expected: bool) -> None: assert runpy.run_module("capabilities")["CAPABILITIES"]["file-permissions"] is expected @pytest.mark.parametrize( "error", [ pytest.param(OSError(EIO, "probe failed"), id="io-error"), pytest.param(OSError(ENOENT, "probe failed"), id="missing-file"), ], ) @pytest.mark.usefixtures("_read_failure") def test_file_permissions_probe_propagates_io_errors(error: OSError) -> None: with pytest.raises(OSError, match="probe failed") as raised: runpy.run_module("capabilities") assert raised.value is error @pytest.fixture def _read_failure(mocker: MockerFixture, error: OSError | None) -> None: mocker.patch.object(Path, "read_bytes", autospec=True, side_effect=error, return_value=b"") filelock-4.2.8/tests/test_virtualenv.py0000644000000000000000000000072315272561277015235 0ustar00from __future__ import annotations import sys from typing import TYPE_CHECKING import pytest from virtualenv import cli_run if TYPE_CHECKING: from pathlib import Path @pytest.mark.skipif( hasattr(sys, "pypy_version_info"), reason="PyPy segfaults in SQLite Cursor.__del__._reset when GC runs during virtualenv imports", ) def test_virtualenv(tmp_path: Path) -> None: cli_run([str(tmp_path), "--no-pip", "--no-setuptools", "--no-periodic-update"]) filelock-4.2.8/tests/soft_rw/conftest.py0000644000000000000000000000065615272561277015314 0ustar00from __future__ import annotations from typing import TYPE_CHECKING import pytest if TYPE_CHECKING: from unittest.mock import MagicMock from pytest_mock import MockerFixture @pytest.fixture def flushes(mocker: MockerFixture) -> MagicMock: # These tests commit by the hundred, and one flush can take a Windows runner most of a second. return mocker.patch("filelock._soft_rw._storage.os.fsync", autospec=True) filelock-4.2.8/tests/soft_rw/test_soft_rw_async.py0000644000000000000000000007507615272561277017416 0ustar00from __future__ import annotations import asyncio import errno import functools import os import re import threading from concurrent.futures import ThreadPoolExecutor from contextlib import AsyncExitStack from pathlib import Path from typing import TYPE_CHECKING, Final, Literal import pytest from filelock import Timeout from filelock._soft_rw import AsyncSoftReadWriteLock, SoftReadWriteLock from filelock._soft_rw._protocol import GenerationLog, Snapshot from filelock._soft_rw._storage import OsFiles from tests.capability_marks import XFAIL_WITHOUT_COROUTINE_CANCELLATION if TYPE_CHECKING: from collections.abc import Callable, Generator from concurrent.futures import Executor from pytest_mock import MockerFixture from filelock._lease import LeaseCompromise pytestmark = pytest.mark.requires_hard_links # Bounds every wait on another task, so a lock that never admits it fails the test instead of hanging the suite. _TASK_WAIT: Final[float] = 10 @pytest.fixture(autouse=True) def _clear_singletons() -> Generator[None]: SoftReadWriteLock._instances.clear() yield for lock in filter(None, (ref() for ref in list(SoftReadWriteLock._instances.valuerefs()))): lock.close() SoftReadWriteLock._instances.clear() def _make( tmp_path: Path, name: str = "foo.lock", *, timeout: float = -1, blocking: bool = True, heartbeat_interval: float = 0.1, stale_threshold: float = 0.5, poll_interval: float = 0.02, loop: asyncio.AbstractEventLoop | None = None, executor: Executor | None = None, is_singleton: bool = False, on_compromise: Callable[[LeaseCompromise], None] | None = None, ) -> AsyncSoftReadWriteLock: return AsyncSoftReadWriteLock( str(tmp_path / name), timeout=timeout, blocking=blocking, is_singleton=is_singleton, heartbeat_interval=heartbeat_interval, stale_threshold=stale_threshold, poll_interval=poll_interval, loop=loop, executor=executor, on_compromise=on_compromise, ) class _Gate: """Pause one call inside the executor thread so a cancellation lands after the backend work started.""" def __init__(self, mocker: MockerFixture, name: str, *, fail_with: BaseException | None = None) -> None: self.started = asyncio.Event() # Set once the gated call returns, letting a test distinguish the executor finishing from the caller giving up. self.finished = threading.Event() self._resume = threading.Event() self._loop = asyncio.get_running_loop() self._real: Callable[..., object] = getattr(SoftReadWriteLock, name) self._pending = True mocker.patch.object(SoftReadWriteLock, name, autospec=True, side_effect=self._call(fail_with)) def _call(self, fail_with: BaseException | None) -> Callable[..., object]: def call(lock: SoftReadWriteLock, *args: object, **kwargs: object) -> object: if not self._pending: return self._real(lock, *args, **kwargs) self._pending = False self._loop.call_soon_threadsafe(self.started.set) assert self._resume.wait(timeout=5) try: if fail_with is not None: raise fail_with return self._real(lock, *args, **kwargs) finally: self.finished.set() return call def resume(self) -> None: self._resume.set() def _members(tmp_path: Path, name: str = "foo.lock") -> list[str]: lock_file = str(tmp_path / name) latest = GenerationLog(OsFiles(lock_file), lock_file, f"{lock_file}.rw").latest() holders = tmp_path / f"{name}.rw" / "holders" return sorted(latest.members) + (sorted(entry.name for entry in holders.iterdir()) if holders.is_dir() else []) @pytest.mark.asyncio @pytest.mark.parametrize("mode", [pytest.param("write", id="write"), pytest.param("read", id="read")]) async def test_async_lock_context_manager(tmp_path: Path, mode: Literal["read", "write"]) -> None: lock = _make(tmp_path) try: context = lock.write_lock if mode == "write" else lock.read_lock async with context(timeout=2): pass finally: await lock.close() @pytest.mark.asyncio async def test_async_exposes_configuration(tmp_path: Path) -> None: lock = _make(tmp_path, timeout=5, blocking=False) try: assert lock.lock_file.endswith("foo.lock") assert lock.timeout == 5 assert lock.blocking is False assert lock.loop is None assert lock.executor is None assert lock.generation is None assert lock.compromise is None finally: await lock.close() @pytest.mark.asyncio async def test_async_raw_acquire_release_round_trip(tmp_path: Path) -> None: lock = _make(tmp_path) try: proxy = await lock.acquire_write(timeout=2) async with proxy: assert lock.generation is not None proxy = await lock.acquire_read(timeout=2) async with proxy: pass finally: await lock.close() @pytest.mark.asyncio @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.parametrize( ("timeout", "message"), [ pytest.param(-2, "timeout must be a non-negative number or -1, not -2.0", id="integer"), pytest.param(-0.5, "timeout must be a non-negative number or -1, not -0.5", id="fraction"), pytest.param(float("-inf"), "timeout must be a non-negative number or -1, not -inf", id="negative-infinite"), pytest.param(float("nan"), "timeout must be a number of seconds, not nan", id="nan"), ], ) @pytest.mark.parametrize("configured", [pytest.param(True, id="instance"), pytest.param(False, id="call")]) @pytest.mark.parametrize("contended", [pytest.param(True, id="contended"), pytest.param(False, id="free")]) async def test_async_acquire_rejects_invalid_timeout( tmp_path: Path, mode: Literal["read", "write"], timeout: float, message: str, *, configured: bool, contended: bool, ) -> None: lock: Final = _make(tmp_path, timeout=timeout if configured else -1) acquire: Final = lock.acquire_read if mode == "read" else lock.acquire_write try: async with AsyncExitStack() as stack: if contended: await stack.enter_async_context(lock.write_lock(timeout=0)) with pytest.raises(ValueError, match=f"^{re.escape(message)}$"): await asyncio.create_task(acquire() if configured else acquire(timeout=timeout)) finally: await lock.close() @pytest.mark.asyncio @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.parametrize( "timeout", [pytest.param(-2, id="integer"), pytest.param(float("inf"), id="infinite"), pytest.param(float("nan"), id="nan")], ) @pytest.mark.parametrize("configured", [pytest.param(True, id="instance"), pytest.param(False, id="call")]) async def test_async_nonblocking_ignores_invalid_timeout( tmp_path: Path, mode: Literal["read", "write"], timeout: float, *, configured: bool ) -> None: lock: Final = _make(tmp_path, timeout=timeout, blocking=not configured) acquire: Final = lock.acquire_read if mode == "read" else lock.acquire_write try: async with await (acquire() if configured else acquire(timeout=timeout, blocking=False)): with pytest.raises(Timeout): await asyncio.create_task(lock.acquire_write(timeout=timeout, blocking=False)) finally: await lock.close() @pytest.mark.asyncio @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) async def test_async_acquire_accepts_infinite_timeout(tmp_path: Path, mode: Literal["read", "write"]) -> None: lock: Final = _make(tmp_path) acquire: Final = lock.acquire_read if mode == "read" else lock.acquire_write try: async with await acquire(timeout=float("inf")): with pytest.raises(Timeout): await asyncio.create_task(lock.acquire_write(blocking=False)) finally: await lock.close() @pytest.mark.asyncio async def test_async_custom_loop_and_executor(tmp_path: Path) -> None: with ThreadPoolExecutor(max_workers=1) as executor: loop = asyncio.get_running_loop() lock = _make(tmp_path, loop=loop, executor=executor) try: assert lock.loop is loop assert lock.executor is executor async with lock.write_lock(timeout=2): pass finally: await lock.close() @pytest.mark.asyncio async def test_async_write_release_lands_on_another_executor_thread(tmp_path: Path) -> None: with ThreadPoolExecutor(max_workers=2) as executor: lock = _make(tmp_path, executor=executor) await lock.acquire_write(timeout=2) started, gate = threading.Event(), threading.Event() def park() -> None: started.set() gate.wait(5) # The pool's only thread ran the acquire; parking it there forces the release onto a second worker. parked = asyncio.get_running_loop().run_in_executor(executor, park) await asyncio.to_thread(started.wait, 5) try: await lock.release() finally: gate.set() await parked async with lock.write_lock(timeout=2): pass await lock.close() @pytest.mark.asyncio async def test_async_release_force(tmp_path: Path) -> None: lock = _make(tmp_path) try: await lock.acquire_read(timeout=2) await lock.release(force=True) finally: await lock.close() @pytest.mark.asyncio async def test_async_release_and_close_skip_when_pid_differs(tmp_path: Path, mocker: MockerFixture) -> None: # A pid that no longer matches the creator's must leave the parent's still-held lock untouched. lock = _make(tmp_path) await lock.acquire_write(timeout=2) try: mocker.patch("filelock._soft_rw._async.os.getpid", return_value=os.getpid() + 1) await lock.release(force=True) await lock.close() mocker.stopall() await lock.release() finally: mocker.stopall() await lock.close() @pytest.mark.asyncio async def test_async_leaked_singleton_is_closed_on_teardown(tmp_path: Path) -> None: # A live heartbeat thread keeps the singleton reachable, so the autouse teardown finds and closes it. path = tmp_path / "singleton.lock" lock = AsyncSoftReadWriteLock(str(path), heartbeat_interval=0.5, stale_threshold=1.5, poll_interval=0.02) await lock.acquire_write(timeout=2) assert len(_members(tmp_path, "singleton.lock")) == 2 @pytest.mark.timeout(0) # The per-handoff wait bounds stalls; batch runtime is not a lock guarantee. @pytest.mark.asyncio async def test_async_tasks_sharing_a_lock_take_the_write_lock_one_at_a_time(tmp_path: Path) -> None: lock: Final = _make(tmp_path) progress: Final[asyncio.Queue[None]] = asyncio.Queue() inside = peak = 0 async def write() -> None: nonlocal inside, peak async with lock.write_lock(): inside += 1 peak = max(peak, inside) await asyncio.sleep(0.001) inside -= 1 progress.put_nowait(None) writers: Final = [asyncio.create_task(write()) for _ in range(10)] # Each handoff waits on fsync and link, which stall for seconds on Windows CI disks; ten queued writers then # overrun one shared deadline without any writer being stuck. for _ in writers: await asyncio.wait_for(progress.get(), 10) await asyncio.gather(*writers) await lock.close() assert peak == 1 @pytest.mark.asyncio async def test_async_tasks_sharing_a_lock_hold_the_read_lock_together(tmp_path: Path) -> None: lock = _make(tmp_path) readers = 0 both_inside = asyncio.Event() async def read() -> None: nonlocal readers async with lock.read_lock(timeout=10): readers += 1 if readers == 2: both_inside.set() await asyncio.wait_for(both_inside.wait(), 10) await asyncio.gather(read(), read()) await lock.close() assert readers == 2 @pytest.mark.asyncio async def test_async_release_from_a_task_that_does_not_hold_raises(tmp_path: Path) -> None: lock = _make(tmp_path) await lock.acquire_write(timeout=5) with pytest.raises(RuntimeError, match="not held by this task"): await asyncio.create_task(lock.release()) await lock.release() await lock.close() @pytest.mark.asyncio async def test_async_writer_times_out_behind_reader(tmp_path: Path) -> None: reader = _make(tmp_path) await reader.acquire_read(timeout=2) try: writer = _make(tmp_path) try: with pytest.raises(Timeout): await writer.acquire_write(timeout=0.3) finally: await writer.close() finally: await reader.release() await reader.close() @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_async_acquire_cancellation_hands_the_claim_back( tmp_path: Path, mocker: MockerFixture, mode: Literal["read", "write"] ) -> None: gate = _Gate(mocker, "_acquire_level_of") lock = _make(tmp_path) acquire = lock.acquire_read if mode == "read" else lock.acquire_write task = asyncio.create_task(acquire(timeout=5)) await gate.started.wait() task.cancel("cancel acquire") gate.resume() with pytest.raises(asyncio.CancelledError): await task assert gate.finished.wait(timeout=5), "the executor never finished the acquire it was already running" assert lock._lock._hold is None assert _members(tmp_path) == [] contender = SoftReadWriteLock(str(tmp_path / "foo.lock"), is_singleton=False, poll_interval=0.02) try: with contender.write_lock(timeout=5): pass finally: contender.close() await lock.close() @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_async_acquire_cancellation_surfaces_a_failed_acquire(tmp_path: Path, mocker: MockerFixture) -> None: # A Timeout here means the acquire gave up on the cancellation, so a real failure has to be another error. acquire_error = OSError("acquire failed") gate = _Gate(mocker, "_acquire_level_of", fail_with=acquire_error) lock = _make(tmp_path) task = asyncio.create_task(lock.acquire_write(timeout=5)) await gate.started.wait() task.cancel("cancel acquire") gate.resume() with pytest.raises(OSError, match="acquire failed") as info: await task assert info.value is acquire_error assert isinstance(acquire_error.__context__, asyncio.CancelledError) assert lock._lock._hold is None assert _members(tmp_path) == [] await lock.close() @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_async_acquire_cancellation_surfaces_a_failed_release(tmp_path: Path, mocker: MockerFixture) -> None: gate = _Gate(mocker, "_acquire_level_of") release_error = RuntimeError("release failed") mocker.patch.object(SoftReadWriteLock, "_release_level_of", side_effect=release_error) lock = _make(tmp_path) task = asyncio.create_task(lock.acquire_write(timeout=5)) await gate.started.wait() task.cancel("cancel acquire") gate.resume() with pytest.raises(RuntimeError, match="release failed") as info: await task assert info.value is release_error assert isinstance(release_error.__context__, asyncio.CancelledError) mocker.stopall() await lock.close() @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_async_release_cancellation_drains_the_release(tmp_path: Path, mocker: MockerFixture) -> None: lock = _make(tmp_path) gate = _Gate(mocker, "_release_level_of") task = asyncio.create_task(_acquire_then_release(lock, "write")) await gate.started.wait() task.cancel("cancel release") # Resume only after the cancellation has had a chance to propagate, so the cancellation reaching the caller # before the release finished is a failure rather than a coin flip. asyncio.get_running_loop().call_later(0.05, gate.resume) with pytest.raises(asyncio.CancelledError): await task assert gate.finished.is_set(), "the cancellation surfaced while the release was still running" assert lock._lock._hold is None assert _members(tmp_path) == [] await lock.close() @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_async_release_cancellation_hands_the_lock_to_the_next_task( tmp_path: Path, mocker: MockerFixture, mode: Literal["read", "write"] ) -> None: lock = _make(tmp_path) gate = _Gate(mocker, "_release_level_of") task = asyncio.create_task(_acquire_then_release(lock, mode)) await gate.started.wait() task.cancel("cancel release") gate.resume() with pytest.raises(asyncio.CancelledError): await task await lock.acquire_write(timeout=2) peer = SoftReadWriteLock(str(tmp_path / "foo.lock"), is_singleton=False, poll_interval=0.02) try: with pytest.raises(Timeout): peer.acquire_read(blocking=False) finally: peer.close() await lock.release() await lock.close() @pytest.mark.parametrize("fail", [pytest.param(False, id="closed"), pytest.param(True, id="failed")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_async_close_cancellation_drains_the_close(tmp_path: Path, mocker: MockerFixture, *, fail: bool) -> None: lock = _make(tmp_path) await lock.acquire_write(timeout=5) close_error = RuntimeError("close failed") gate = _Gate(mocker, "close", fail_with=close_error if fail else None) task = asyncio.create_task(lock.close()) await gate.started.wait() task.cancel("cancel close") gate.resume() with pytest.raises(RuntimeError if fail else asyncio.CancelledError) as info: await task assert gate.finished.is_set() if fail: assert info.value is close_error assert isinstance(close_error.__context__, asyncio.CancelledError) mocker.stopall() await lock.close() assert _members(tmp_path) == [] @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_async_release_cancellation_surfaces_a_failed_release(tmp_path: Path, mocker: MockerFixture) -> None: lock = _make(tmp_path) release_error = RuntimeError("release failed") gate = _Gate(mocker, "_release_level_of", fail_with=release_error) task = asyncio.create_task(_acquire_then_release(lock, "write")) await gate.started.wait() task.cancel("cancel release") gate.resume() with pytest.raises(RuntimeError, match="release failed") as info: await task assert info.value is release_error assert isinstance(release_error.__context__, asyncio.CancelledError) mocker.stopall() await lock.close() async def _acquire_then_release(lock: AsyncSoftReadWriteLock, mode: Literal["read", "write"]) -> None: # A hold belongs to the task that took it, so the task whose release gets canceled has to acquire first. await (lock.acquire_read if mode == "read" else lock.acquire_write)(timeout=5) await lock.release() @pytest.mark.asyncio async def test_async_soft_read_after_a_sync_close_of_the_shared_singleton_raises(tmp_path: Path) -> None: lock: Final = _make(tmp_path, is_singleton=True) await lock.acquire_read(timeout=_TASK_WAIT) await asyncio.to_thread(SoftReadWriteLock(str(tmp_path / "foo.lock")).close) with pytest.raises(RuntimeError, match="has been closed"): await asyncio.create_task(lock.acquire_read(timeout=_TASK_WAIT)) with pytest.raises(RuntimeError, match="not held by this task"): await lock.release() await lock.close() @pytest.mark.parametrize("sync_end", [pytest.param("release", id="release"), pytest.param("close", id="close")]) @pytest.mark.asyncio async def test_async_soft_close_after_a_sync_end_of_the_shared_singleton_returns(tmp_path: Path, sync_end: str) -> None: lock: Final = _make(tmp_path, is_singleton=True) await lock.acquire_read(timeout=_TASK_WAIT) sync: Final = SoftReadWriteLock(str(tmp_path / "foo.lock")) await asyncio.to_thread(sync.close if sync_end == "close" else functools.partial(sync.release, force=True)) await lock.close() assert _members(tmp_path, "foo.lock") == [] @pytest.mark.asyncio async def test_async_soft_release_after_a_sync_forced_release_raises_not_held_by_this_task(tmp_path: Path) -> None: lock: Final = _make(tmp_path, is_singleton=True) await lock.acquire_read(timeout=_TASK_WAIT) await asyncio.to_thread(SoftReadWriteLock(str(tmp_path / "foo.lock")).release, force=True) with pytest.raises(RuntimeError, match="not held by this task"): await lock.release() await lock.close() @pytest.mark.parametrize("is_singleton", [pytest.param(True, id="singleton"), pytest.param(False, id="owned")]) @pytest.mark.asyncio async def test_async_soft_read_arriving_during_close_is_refused(tmp_path: Path, is_singleton: bool) -> None: lock: Final = _make(tmp_path, is_singleton=is_singleton) await lock.acquire_read(timeout=_TASK_WAIT) arriving = asyncio.create_task(_read_and_probe(lock)) # first runs while close() waits for its backend call await lock.close() with pytest.raises(RuntimeError, match="has been closed"): await arriving @pytest.mark.asyncio async def test_async_soft_read_through_another_wrapper_during_close_holds_the_lock(tmp_path: Path) -> None: closing, other = _make(tmp_path, is_singleton=True), _make(tmp_path, is_singleton=True) await closing.acquire_read(timeout=_TASK_WAIT) arriving = asyncio.create_task(_read_and_probe(other)) await closing.close() assert not await arriving await other.close() @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio async def test_async_soft_close_keeps_the_hold_taken_through_another_wrapper( tmp_path: Path, mode: Literal["read", "write"] ) -> None: holder, closing = _make(tmp_path, is_singleton=True), _make(tmp_path, is_singleton=True) await (holder.acquire_read if mode == "read" else holder.acquire_write)(timeout=_TASK_WAIT) await asyncio.create_task(closing.close()) assert not await asyncio.to_thread(_writer_gets_in, tmp_path) await holder.release() assert await asyncio.to_thread(_writer_gets_in, tmp_path) await holder.close() @pytest.mark.asyncio async def test_async_soft_close_of_the_entering_wrapper_keeps_a_level_taken_through_another(tmp_path: Path) -> None: entering, nested = _make(tmp_path, is_singleton=True), _make(tmp_path, is_singleton=True) await entering.acquire_read(timeout=_TASK_WAIT) await nested.acquire_read(timeout=_TASK_WAIT) await entering.close() assert not await asyncio.to_thread(_writer_gets_in, tmp_path) await nested.release() assert await asyncio.to_thread(_writer_gets_in, tmp_path) await nested.close() @pytest.mark.asyncio async def test_async_soft_close_of_the_nested_wrapper_ends_only_its_own_level(tmp_path: Path) -> None: entering, nested = _make(tmp_path, is_singleton=True), _make(tmp_path, is_singleton=True) await entering.acquire_read(timeout=_TASK_WAIT) await nested.acquire_read(timeout=_TASK_WAIT) await nested.close() await entering.release() assert await asyncio.to_thread(_writer_gets_in, tmp_path) await entering.close() @pytest.mark.asyncio async def test_async_soft_release_through_each_wrapper_ends_the_hold_after_the_last(tmp_path: Path) -> None: entering, nested = _make(tmp_path, is_singleton=True), _make(tmp_path, is_singleton=True) await entering.acquire_read(timeout=_TASK_WAIT) await nested.acquire_read(timeout=_TASK_WAIT) await nested.release() assert not await asyncio.to_thread(_writer_gets_in, tmp_path) await entering.release() assert await asyncio.to_thread(_writer_gets_in, tmp_path) await entering.close() await nested.close() @pytest.mark.asyncio async def test_async_soft_forced_release_through_a_wrapper_without_a_level_releases_nothing(tmp_path: Path) -> None: holder, other = _make(tmp_path, is_singleton=True), _make(tmp_path, is_singleton=True) await holder.acquire_read(timeout=_TASK_WAIT) await other.release(force=True) assert not await asyncio.to_thread(_writer_gets_in, tmp_path) await holder.release() assert await asyncio.to_thread(_writer_gets_in, tmp_path) await holder.close() await other.close() @pytest.mark.asyncio async def test_async_soft_release_through_a_wrapper_without_a_level_raises(tmp_path: Path) -> None: holder, other = _make(tmp_path, is_singleton=True), _make(tmp_path, is_singleton=True) await holder.acquire_read(timeout=_TASK_WAIT) with pytest.raises(RuntimeError, match="through this wrapper"): await other.release() assert not await asyncio.to_thread(_writer_gets_in, tmp_path) await holder.release() await holder.close() await other.close() @pytest.mark.asyncio async def test_async_soft_close_of_a_singleton_wrapper_ends_the_holds_taken_through_it(tmp_path: Path) -> None: lock: Final = _make(tmp_path, is_singleton=True) await lock.acquire_write(timeout=_TASK_WAIT) await lock.close() await lock.close() assert await asyncio.to_thread(_writer_gets_in, tmp_path) with pytest.raises(RuntimeError, match="has been closed"): await lock.acquire_read(timeout=_TASK_WAIT) def _writer_gets_in(tmp_path: Path) -> bool: contender: Final = _make_sync(tmp_path) try: contender.acquire_write(blocking=False) except Timeout: return False finally: contender.close() return True def _make_sync(tmp_path: Path) -> SoftReadWriteLock: return SoftReadWriteLock( str(tmp_path / "foo.lock"), is_singleton=False, heartbeat_interval=0.1, stale_threshold=0.5 ) async def _read_and_probe(lock: AsyncSoftReadWriteLock) -> bool: async with lock.read_lock(timeout=_TASK_WAIT): return await asyncio.to_thread(_writer_gets_in, Path(lock.lock_file).parent) @pytest.mark.usefixtures("failing_release") @pytest.mark.asyncio async def test_async_soft_read_after_a_failed_release_waits_for_another_writer(tmp_path: Path) -> None: lock: Final = _make(tmp_path) await lock.acquire_read(timeout=5) with pytest.raises(OSError, match="No space left"): await lock.release() writer: Final = _make_sync(tmp_path) await asyncio.to_thread(writer.acquire_write, 5) with pytest.raises(Timeout): await asyncio.create_task(lock.acquire_read(blocking=False)) writer.close() await lock.close() @pytest.mark.usefixtures("failing_release") @pytest.mark.asyncio async def test_async_soft_release_retried_after_the_hold_was_dropped_raises(tmp_path: Path) -> None: lock: Final = _make(tmp_path) await lock.acquire_write(timeout=5) with pytest.raises(OSError, match="No space left"): await lock.release() with pytest.raises(RuntimeError, match="not held by this task"): await lock.release() await lock.close() @pytest.fixture def failing_release(mocker: MockerFixture) -> None: # The sync lock drops its hold before the filesystem step that fails, as a full disk does. real_release = SoftReadWriteLock._release_level_of def release(lock: SoftReadWriteLock, holder: int) -> None: real_release(lock, holder) raise OSError(errno.ENOSPC, "No space left on device") mocker.patch.object(SoftReadWriteLock, "_release_level_of", autospec=True, side_effect=release) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_async_soft_close_cancellation_refuses_later_acquires(tmp_path: Path) -> None: started, resume = threading.Event(), threading.Event() def park() -> None: started.set() resume.wait(5) with ThreadPoolExecutor(max_workers=1) as worker: lock: Final = _make(tmp_path, executor=worker) await lock.acquire_read(timeout=5) # Parking the only worker keeps close() waiting for its backend call when the cancellation lands. parked = asyncio.get_running_loop().run_in_executor(worker, park) await asyncio.to_thread(started.wait, 5) closing = asyncio.create_task(lock.close()) await asyncio.sleep(0) closing.cancel() resume.set() await parked with pytest.raises(asyncio.CancelledError): await closing with pytest.raises(RuntimeError, match="has been closed"): await asyncio.create_task(lock.acquire_read(timeout=5)) @pytest.mark.asyncio async def test_async_soft_release_leaves_a_sync_reader_of_the_same_singleton_holding(tmp_path: Path) -> None: async_lock: Final = AsyncSoftReadWriteLock(str(tmp_path / "foo.lock")) with SoftReadWriteLock(str(tmp_path / "foo.lock")).read_lock(): async with async_lock.read_lock(timeout=5): pass assert not await asyncio.to_thread(_writer_gets_in, tmp_path) await async_lock.close() @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_async_soft_canceled_acquire_leaves_a_sync_reader_of_the_same_singleton_holding( tmp_path: Path, mocker: MockerFixture ) -> None: async_lock: Final = AsyncSoftReadWriteLock(str(tmp_path / "foo.lock")) with SoftReadWriteLock(str(tmp_path / "foo.lock")).read_lock(): gate = _Gate(mocker, "_acquire_level_of") acquiring = asyncio.create_task(async_lock.acquire_read(timeout=5)) await gate.started.wait() acquiring.cancel() gate.resume() with pytest.raises(asyncio.CancelledError): await acquiring assert not await asyncio.to_thread(_writer_gets_in, tmp_path) await async_lock.close() @pytest.mark.asyncio async def test_async_soft_read_joining_a_lost_hold_raises(tmp_path: Path) -> None: lost = threading.Event() lock: Final = _make( tmp_path, heartbeat_interval=0.05, stale_threshold=0.2, on_compromise=lambda _compromise: lost.set() ) await lock.acquire_read(timeout=2) _evict_every_holder(tmp_path) assert await asyncio.to_thread(lost.wait, 10) with pytest.raises(RuntimeError, match="hold was lost"): await asyncio.create_task(lock.acquire_read(timeout=1)) await lock.release() await lock.close() def _evict_every_holder(tmp_path: Path) -> None: lock_file = str(tmp_path / "foo.lock") log = GenerationLog(OsFiles(lock_file), lock_file, f"{lock_file}.rw") assert log.commit(Snapshot(generation=log.latest().generation + 1, writer=None, readers=frozenset())) filelock-4.2.8/tests/soft_rw/test_soft_rw_protocol.py0000644000000000000000000016015115272561277020127 0ustar00""" The generation-log protocol: record formats, the ledger, and ``GenerationLog``/``Participant`` behavior, plus a model check of the whole protocol under an adversarial scheduler. The direct tests below exercise ``Snapshot``, ``Ledger``, ``GenerationLog``, and ``Participant`` against a real filesystem. The model check that follows runs every participant on its own thread over an in-memory filesystem, and every filesystem call hands the turn back to a seeded scheduler that picks which participant runs next, advances a fake clock, and sometimes kills the participant it picked. That interleaves the protocol at the granularity of single filesystem operations, including inside a commit, and a crash at any of those points leaves exactly what a dead process would leave on disk. """ from __future__ import annotations import errno import os import random import shutil import threading from dataclasses import dataclass, field from errno import EIO from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, Final, Literal import pytest from capabilities import CAPABILITIES from filelock import SoftFileLockProtocolError from filelock._soft_rw._protocol import ( GenerationLog, Ledger, Participant, Snapshot, TreeReplacedError, encode_holder, holder_lease, parse_snapshot, ) from filelock._soft_rw._storage import OsFiles if TYPE_CHECKING: from collections.abc import Callable from unittest.mock import MagicMock from pytest_mock import MockerFixture _FIRST = "0123456789abcdef0123456789abcdef" _SECOND = "fedcba9876543210fedcba9876543210" pytestmark = pytest.mark.usefixtures("flushes") def _participant( files: OsFiles, lock_file: str, mode: Literal["read", "write"], *, stale_threshold: float = 100, clock: Callable[[], float] = lambda: 0.0, ) -> Participant: # Each participant gets its own ledger and log, the way separate processes do. root = f"{lock_file}.rw" return Participant( files, root, mode, stale_threshold=stale_threshold, clock=clock, ledger=Ledger(clock), log=GenerationLog(files, lock_file, root), ) @pytest.fixture def lock_file(tmp_path: Path) -> str: return str(tmp_path / "x.lock") @pytest.fixture def files(lock_file: str) -> OsFiles: return OsFiles(lock_file) @pytest.fixture def root(lock_file: str) -> str: return f"{lock_file}.rw" @pytest.mark.parametrize( "snapshot", [ pytest.param(Snapshot(generation=0, writer=None, readers=frozenset()), id="empty"), pytest.param(Snapshot(generation=7, writer=_FIRST, readers=frozenset()), id="writer"), pytest.param(Snapshot(generation=12, writer=None, readers=frozenset({_FIRST, _SECOND})), id="readers"), ], ) def test_snapshot_round_trips(snapshot: Snapshot) -> None: assert parse_snapshot(snapshot.encode()) == snapshot @pytest.mark.parametrize( "data", [ pytest.param(b"", id="empty"), pytest.param(b"x" * ((1 << 20) + 1), id="oversized"), pytest.param("filelock-rw/1\ngeneration=1\nreader=é\n".encode(), id="non-ascii"), pytest.param(b"filelock-rw/2\ngeneration=1\n", id="unknown-protocol"), pytest.param(b"filelock-rw/1\ngeneration=1", id="no-trailing-newline"), pytest.param(b"filelock-rw/1\n", id="no-generation"), pytest.param(b"filelock-rw/1\ngeneration=01\n", id="leading-zero"), pytest.param(b"filelock-rw/1\ngeneration=1\ngeneration=2\n", id="two-generations"), pytest.param(f"filelock-rw/1\ngeneration=1\nwriter={_FIRST}\nwriter={_SECOND}\n".encode(), id="two-writers"), pytest.param(f"filelock-rw/1\ngeneration=1\nreader={_FIRST}\nreader={_FIRST}\n".encode(), id="reader-twice"), pytest.param(b"filelock-rw/1\ngeneration=1\nwriter=short\n", id="bad-token"), pytest.param(b"filelock-rw/1\ngeneration=1\ncolor=blue\n", id="unknown-key"), pytest.param(b"filelock-rw/1\ngeneration=1\nno separator\n", id="no-separator"), ], ) def test_malformed_snapshots_parse_to_none(data: bytes) -> None: assert parse_snapshot(data) is None def test_holder_record_keeps_its_length_across_nonces() -> None: first = encode_holder(_FIRST, _SECOND, 90.0) second = encode_holder(_FIRST, _FIRST, 90.0) assert len(first) == len(second) assert first != second assert f"pid={os.getpid()}".encode() in first @pytest.mark.parametrize( "lease", [pytest.param(90.0, id="default"), pytest.param(1e-05, id="exponent"), pytest.param(10, id="integer")] ) def test_holder_record_carries_its_lease(lease: float) -> None: assert holder_lease(encode_holder(_FIRST, _SECOND, lease)) == lease @pytest.mark.parametrize( "record", [ pytest.param(None, id="missing"), pytest.param(b"filelock-rw/1\nnonce=x\n", id="older-release"), pytest.param(b"\xff", id="non-ascii"), pytest.param(b"lease=abc\n", id="not-a-number"), pytest.param(b"lease=1_000.0\n", id="underscore"), pytest.param(b"lease= 1.0\n", id="padded"), pytest.param(b"lease=inf\n", id="infinite"), pytest.param(b"lease=0.0\n", id="zero"), pytest.param(b"lease=-1.0\n", id="negative"), ], ) def test_holder_lease_ignores_what_it_cannot_trust(record: bytes | None) -> None: assert holder_lease(record) is None def test_ledger_measures_how_long_a_value_stays_unchanged() -> None: clock = [10.0] ledger = Ledger(lambda: clock[0]) assert ledger.observe("a", b"1") == pytest.approx(0.0) clock[0] = 12.5 assert ledger.observe("a", b"1") == pytest.approx(2.5) assert ledger.observe("a", b"2") == pytest.approx(0.0) clock[0] = 13.0 assert ledger.observe("a", None) == pytest.approx(0.0) clock[0] = 14.0 assert ledger.observe("a", None) == pytest.approx(1.0) ledger.forget("a") assert ledger.observe("a", None) == pytest.approx(0.0) def test_ledger_counts_no_time_past_the_moment_the_observer_asked() -> None: clock = [10.0] ledger = Ledger(lambda: clock[0]) assert ledger.observe("a", b"1", asked=4.0) == pytest.approx(0.0) clock[0] = 50.0 assert ledger.observe("a", b"1", asked=12.5) == pytest.approx(2.5) def test_ledger_retain_forgets_everything_else() -> None: clock = [0.0] ledger = Ledger(lambda: clock[0]) ledger.observe("kept", b"1") ledger.observe("gone", b"1") clock[0] = 5.0 ledger.retain({"kept"}) assert ledger.observe("kept", b"1") == pytest.approx(5.0) assert ledger.observe("gone", b"1") == pytest.approx(0.0) def test_log_starts_empty(lock_file: str, files: OsFiles, root: str) -> None: log = GenerationLog(files, lock_file, root) assert log.latest() == Snapshot(generation=0, writer=None, readers=frozenset()) def test_a_commit_into_a_log_without_its_directory_is_lost(lock_file: str, files: OsFiles, root: str) -> None: # The log was removed and nobody has prepared it again; the caller re-reads instead of failing on the missing gen/. assert not GenerationLog(files, lock_file, root).commit(Snapshot(generation=1, writer=_FIRST, readers=frozenset())) @pytest.mark.requires_hard_links def test_an_epoch_a_peer_created_first_is_adopted( lock_file: str, files: OsFiles, root: str, mocker: MockerFixture ) -> None: # Between this client's read and its exclusive create, a peer can create the epoch; the peer's token then wins. files.prepare(root) epoch = Path(root, "gen", "epoch") epoch.write_bytes(b"peer\n") real_read = OsFiles.read missed = [] def miss_the_epoch_once(self: OsFiles, path: str) -> bytes | None: if path == str(epoch) and not missed: missed.append(path) return None return real_read(self, path) mocker.patch.object(OsFiles, "read", autospec=True, side_effect=miss_the_epoch_once) log = GenerationLog(files, lock_file, root) assert log.latest() == Snapshot(generation=0, writer=None, readers=frozenset()) assert missed assert epoch.read_bytes() == b"peer\n" assert log.commit(Snapshot(generation=1, writer=_FIRST, readers=frozenset())) def test_an_empty_log_reads_as_empty_again(lock_file: str, files: OsFiles, root: str) -> None: # A commit that did not land leaves the log empty; remembering the empty snapshot must not turn into a demand to # read a gen/0 that never exists. files.prepare(root) log = GenerationLog(files, lock_file, root) assert log.latest() == log.latest() == Snapshot(generation=0, writer=None, readers=frozenset()) @pytest.mark.requires_hard_links def test_commit_refuses_a_generation_that_exists(lock_file: str, files: OsFiles, root: str) -> None: files.prepare(root) first = GenerationLog(files, lock_file, root) second = GenerationLog(files, lock_file, root) assert first.latest().generation == 0 assert second.latest().generation == 0 assert first.commit(Snapshot(generation=1, writer=_FIRST, readers=frozenset())) assert not second.commit(Snapshot(generation=1, writer=_SECOND, readers=frozenset())) assert second.latest().writer == _FIRST assert sorted(entry.name for entry in Path(root, "gen").iterdir() if entry.name.isdigit()) == [f"{1:020d}"] @pytest.mark.requires_hard_links def test_rescan_skips_a_generation_removed_after_listing( lock_file: str, files: OsFiles, root: str, mocker: MockerFixture ) -> None: # A listing can name a generation a peer compacts before it is read; the next older one still stands in. _committed(files, lock_file, root, [None, _FIRST]) # A log an older release wrote has no HEAD to stand in either. Path(root, "gen", "HEAD").unlink() real_read = OsFiles.read latest_path = str(Path(root, "gen", f"{2:020d}")) mocker.patch.object(OsFiles, "read", lambda self, path: None if path == latest_path else real_read(self, path)) fresh = GenerationLog(files, lock_file, root) assert fresh.latest().generation == 1 def test_malformed_generation_raises(lock_file: str, files: OsFiles, root: str) -> None: files.prepare(root) Path(root, "gen", f"{1:020d}").write_bytes(b"filelock-rw/1\ngeneration=5\n") log = GenerationLog(files, lock_file, root) with pytest.raises(SoftFileLockProtocolError, match="malformed generation record") as caught: log.latest() assert caught.value.claim_name == f"{1:020d}" def test_non_ascii_digit_generation_name_is_ignored(lock_file: str, files: OsFiles, root: str) -> None: files.prepare(root) Path(root, "gen", f"{1:020d}").write_bytes(Snapshot(generation=1, writer=_FIRST, readers=frozenset()).encode()) Path(root, "gen", "²" * 20).write_bytes(b"not a generation") assert GenerationLog(files, lock_file, root).latest() == Snapshot(generation=1, writer=_FIRST, readers=frozenset()) def test_leave_without_entering_only_drops_the_record(lock_file: str, files: OsFiles) -> None: participant = _participant(files, lock_file, "read", stale_threshold=1) participant.publish() assert Path(f"{lock_file}.rw", "holders", participant.token).exists() participant.leave() assert not Path(f"{lock_file}.rw", "holders", participant.token).exists() assert participant.generation is None @pytest.mark.requires_hard_links def test_leave_retries_a_commit_a_peer_won(lock_file: str, files: OsFiles, root: str, mocker: MockerFixture) -> None: # A peer can publish the next generation between leave() reading the latest one and linking its successor; the # holder then re-reads and commits itself out of whatever the peer published. reader = _participant(files, lock_file, "read", stale_threshold=100) reader.publish() assert reader.advance() real_commit = GenerationLog.commit lost = [] def commit_after_a_peer(log: GenerationLog, successor: Snapshot) -> bool: if not lost: lost.append(successor) (peer := GenerationLog(files, lock_file, root)).latest() still_reading = successor.readers | {reader.token} assert real_commit(peer, Snapshot(generation=successor.generation, writer=None, readers=still_reading)) return real_commit(log, successor) mocker.patch.object(GenerationLog, "commit", autospec=True, side_effect=commit_after_a_peer) reader.leave() latest = GenerationLog(files, lock_file, root).latest() assert latest.members == frozenset() assert latest.generation == lost[0].generation + 1 @pytest.mark.requires_hard_links def test_waiting_contender_republishes_a_swept_record(lock_file: str, files: OsFiles, root: str) -> None: # A record removed out from under a contender (a sweeper that mistook it, an operator) comes back on its next poll, # so peers always have a record to watch by the time the contender is admitted. writer = _participant(files, lock_file, "write", stale_threshold=100) writer.publish() assert writer.advance() reader = _participant(files, lock_file, "read", stale_threshold=100) reader.publish() record = Path(root, "holders", reader.token) record.unlink() assert not reader.advance() assert record.exists() @pytest.mark.requires_hard_links def test_waiting_contender_keeps_an_undeletable_record( lock_file: str, files: OsFiles, root: str, mocker: MockerFixture ) -> None: # Windows can leave the record in place while refusing the rewrite; the contender carries on. reader = _participant(files, lock_file, "read", stale_threshold=100) reader.publish() mocker.patch.object(OsFiles, "overwrite", return_value=False) assert reader.advance() assert Path(root, "holders", reader.token).exists() @pytest.mark.requires_hard_links def test_blocked_contender_still_evicts_stale_readers(lock_file: str, files: OsFiles, root: str) -> None: # A live writer blocks entry, but a dead reader named beside it is evicted anyway so the writer can drain. clock = [0.0] dead = _participant(files, lock_file, "read", stale_threshold=1, clock=lambda: clock[0]) dead.publish() assert dead.advance() writer = _participant(files, lock_file, "write", stale_threshold=1, clock=lambda: clock[0]) writer.publish() assert not writer.advance() contender = _participant(files, lock_file, "read", stale_threshold=1, clock=lambda: clock[0]) contender.publish() assert not contender.advance() clock[0] = 5.0 assert writer.heartbeat() == ("ok", None) # keeps its own nonce fresh, so only the dead reader goes stale assert not contender.advance() latest = GenerationLog(files, lock_file, root).latest() assert latest.writer == writer.token assert latest.readers == frozenset() def test_advance_hands_back_the_poll_after_losing_every_race( lock_file: str, files: OsFiles, mocker: MockerFixture ) -> None: # Peers winning every commit is contention, not a fault: the caller sleeps its poll interval and tries again. writer = _participant(files, lock_file, "write") writer.publish() commit = mocker.patch.object(GenerationLog, "commit", autospec=True, return_value=False) assert not writer.advance() assert commit.call_count == 4 assert writer.generation is None @pytest.mark.requires_hard_links def test_writer_admission_after_the_last_reader_leaves_is_a_commit(lock_file: str, files: OsFiles, root: str) -> None: # The final grant must publish a generation of its own: a grant read off a snapshot a peer is about to supersede # would let an evictor and the resumed writer both hold. reader = _participant(files, lock_file, "read") reader.publish() assert reader.advance() writer = _participant(files, lock_file, "write") writer.publish() assert not writer.advance() reader.leave() left_at = GenerationLog(files, lock_file, root).latest().generation assert writer.advance() assert writer.generation == left_at + 1 @pytest.mark.requires_hard_links def test_a_commit_reported_lost_that_landed_is_recognized( lock_file: str, files: OsFiles, root: str, mocker: MockerFixture ) -> None: # A link that landed but whose identity check failed must not leave the writer blocking on its own token. writer = _participant(files, lock_file, "write") writer.publish() real_link = OsFiles.link denied = [] def link_then_deny(self: OsFiles, source: str, target: str) -> bool: landed = real_link(self, source, target) if landed and not denied and Path(target).name.isdigit(): denied.append(target) return False return landed mocker.patch.object(OsFiles, "link", autospec=True, side_effect=link_then_deny) assert writer.advance() # The denied commit entered it at generation 1; the admission it then recognized is a commit of its own. assert writer.generation == 2 assert len(denied) == 1 assert GenerationLog(files, lock_file, root).latest().writer == writer.token @pytest.mark.requires_hard_links def test_latest_probes_across_a_compaction_hole(lock_file: str, files: OsFiles, root: str) -> None: files.prepare(root) log = GenerationLog(files, lock_file, root) for generation in range(1, 6): assert log.commit(Snapshot(generation=generation, writer=None, readers=frozenset())) Path(root, "gen", f"{3:020d}").unlink() behind = GenerationLog(files, lock_file, root) assert behind.latest().generation == 5 def test_a_listing_naming_only_compacted_generations_fails_closed( lock_file: str, files: OsFiles, root: str, mocker: MockerFixture ) -> None: # A client that can see names but read none of them is behind the log by more than the retained window; treating # that as an empty log would restart the sequence and fork it. files.prepare(root) mocker.patch.object(OsFiles, "listdir", return_value=[f"{generation:020d}" for generation in (4, 5)]) with pytest.raises(SoftFileLockProtocolError, match="names no readable snapshot"): GenerationLog(files, lock_file, root).latest() @pytest.mark.requires_hard_links def test_a_listing_compacted_from_under_the_reader_is_taken_again( lock_file: str, files: OsFiles, root: str, mocker: MockerFixture ) -> None: # Peers can compact every generation a listing named before this client reads one; a second listing then finds # the head rather than failing closed on the first. files.prepare(root) log = GenerationLog(files, lock_file, root) assert log.commit(Snapshot(generation=1, writer=_FIRST, readers=frozenset())) real_listdir = OsFiles.listdir listings = iter([[f"{generation:020d}" for generation in (4, 5)]]) mocker.patch.object(OsFiles, "listdir", lambda self, path: next(listings, None) or real_listdir(self, path)) assert GenerationLog(files, lock_file, root).latest().writer == _FIRST def _committed(files: OsFiles, lock_file: str, root: str, writers: list[str | None]) -> GenerationLog: # One log that read the head before each commit, as a participant does, and so remembers the last one. files.prepare(root) log = GenerationLog(files, lock_file, root) for writer in writers: assert log.commit(Snapshot(generation=log.latest().generation + 1, writer=writer, readers=frozenset())) return log @pytest.mark.requires_hard_links @pytest.mark.parametrize( ("writers", "expected"), [ pytest.param([], Snapshot(generation=0, writer=None, readers=frozenset()), id="empty"), pytest.param([_SECOND], Snapshot(generation=1, writer=_SECOND, readers=frozenset()), id="restarted"), ], ) def test_a_wiped_log_is_read_from_disk_not_memory( lock_file: str, files: OsFiles, root: str, writers: list[str | None], expected: Snapshot ) -> None: # An instance that remembers generation 2 of a log someone removed must see the new log's head, not continue its # own numbering into it beside the new holder. old = _committed(files, lock_file, root, [None, _FIRST]) shutil.rmtree(root) _committed(OsFiles(lock_file), lock_file, root, writers) assert old.latest() == expected @pytest.mark.requires_hard_links def test_a_remembered_head_gone_from_the_same_log_fails_closed(lock_file: str, files: OsFiles, root: str) -> None: # The epoch still names the log this client remembers, so an empty view is this client failing to see the head. old = _committed(files, lock_file, root, [None, _FIRST]) for name in (f"{1:020d}", f"{2:020d}", "HEAD"): Path(root, "gen", name).unlink() with pytest.raises(SoftFileLockProtocolError, match="names no readable snapshot"): old.latest() @pytest.mark.requires_hard_links @pytest.mark.parametrize("remembered", [pytest.param(True, id="memory"), pytest.param(False, id="head")]) def test_an_empty_listing_past_compaction_still_finds_the_head( lock_file: str, files: OsFiles, root: str, head: Path, mocker: MockerFixture, *, remembered: bool ) -> None: # Compaction leaves nothing within the probe window of generation 0, so only the head this client remembers or # gen/HEAD names, read by name, finds the log; a cached empty listing would make it look new and fork it at gen/1. log = _committed(files, lock_file, root, [None] * 140 + [_FIRST]) if remembered: head.unlink() mocker.patch.object(OsFiles, "listdir", autospec=True, return_value=[]) reader = log if remembered else GenerationLog(files, lock_file, root) assert reader.latest() == Snapshot(generation=141, writer=_FIRST, readers=frozenset()) @pytest.mark.requires_hard_links def test_a_listing_naming_a_compacted_generation_probes_past_it( lock_file: str, files: OsFiles, root: str, mocker: MockerFixture ) -> None: _committed(files, lock_file, root, [None, None, _FIRST]) mocker.patch.object(OsFiles, "listdir", autospec=True, return_value=[f"{0:020d}"]) assert GenerationLog(files, lock_file, root).latest().writer == _FIRST @pytest.mark.requires_hard_links def test_commit_into_a_log_replaced_since_the_read_is_refused(lock_file: str, files: OsFiles, root: str) -> None: old = _committed(files, lock_file, root, [None, _FIRST]) successor = Snapshot(generation=old.latest().generation + 1, writer=None, readers=frozenset()) shutil.rmtree(root) _committed(OsFiles(lock_file), lock_file, root, [_SECOND]) assert not old.commit(successor) assert not Path(root, "gen", f"{successor.generation:020d}").exists() @pytest.mark.requires_hard_links @pytest.mark.parametrize( ("writers", "record"), [ # Two committers' renames can land out of order; HEAD then names an older generation, which is only a floor. pytest.param( [None, None, _FIRST], Snapshot(generation=1, writer=None, readers=frozenset()).encode(), id="left-behind" ), # HEAD is not flushed, so a crash can leave it unreadable. pytest.param([None, _FIRST], b"garbage\n", id="torn"), ], ) def test_a_head_record_only_says_where_to_look( lock_file: str, files: OsFiles, root: str, head: Path, writers: list[str | None], record: bytes ) -> None: _committed(files, lock_file, root, writers) head.write_bytes(Path(root, "gen", "epoch").read_bytes() + record) assert GenerationLog(files, lock_file, root).latest() == Snapshot( generation=len(writers), writer=_FIRST, readers=frozenset() ) @pytest.mark.requires_hard_links def test_a_head_left_on_a_compacted_generation_fails_closed( lock_file: str, files: OsFiles, root: str, head: Path, mocker: MockerFixture ) -> None: # A committer stalled before its HEAD rename puts HEAD on long-compacted generation 2; a cached listing must not # resume there. _committed(files, lock_file, root, [None] * 200) head.write_bytes( Path(root, "gen", "epoch").read_bytes() + Snapshot(generation=2, writer=None, readers=frozenset()).encode() ) mocker.patch.object(OsFiles, "listdir", autospec=True, return_value=[f"{1:020d}", f"{2:020d}"]) with pytest.raises(SoftFileLockProtocolError, match="names no readable snapshot"): GenerationLog(files, lock_file, root).latest() @pytest.mark.requires_hard_links @pytest.mark.parametrize("generation", [pytest.param(1, id="first"), pytest.param(3, id="compacted")]) def test_commit_into_a_slot_the_log_moved_past_is_refused( lock_file: str, files: OsFiles, root: str, generation: int ) -> None: log = _committed(files, lock_file, root, [None] * 70) assert ( log.commit(Snapshot(generation=generation, writer=_FIRST, readers=frozenset())), Path(root, "gen", f"{generation:020d}").exists(), ) == (False, False) @pytest.mark.requires_hard_links def test_a_head_from_a_wiped_log_names_nothing_in_the_new_one( lock_file: str, files: OsFiles, root: str, head: Path ) -> None: _committed(files, lock_file, root, [None, None, _FIRST]) stray = head.read_bytes() shutil.rmtree(root) _committed(OsFiles(lock_file), lock_file, root, [_SECOND]) head.write_bytes(stray) assert GenerationLog(files, lock_file, root).latest() == Snapshot(generation=1, writer=_SECOND, readers=frozenset()) @pytest.mark.requires_hard_links def test_a_commit_overtaken_by_a_wipe_leaves_the_new_heads_alone( lock_file: str, files: OsFiles, root: str, head: Path, mocker: MockerFixture ) -> None: old = _committed(files, lock_file, root, [None, _FIRST]) real_link = files.link def link_then_wipe(source: str, target: str) -> bool: landed = real_link(source, target) shutil.rmtree(root) _committed(OsFiles(lock_file), lock_file, root, [_SECOND, None]) return landed mocker.patch.object(files, "link", autospec=True, side_effect=link_then_wipe) assert (old.commit(Snapshot(generation=3, writer=None, readers=frozenset())), _published(head)) == ( True, Snapshot(generation=2, writer=None, readers=frozenset()), ) @pytest.mark.requires_hard_links def test_a_commit_that_lands_in_a_compacted_slot_counts_as_lost( lock_file: str, files: OsFiles, root: str, mocker: MockerFixture ) -> None: # The committer passed the predecessor check and stalled; peers then compacted past its slot before the link. log = _committed(files, lock_file, root, [None] * 3) real_link = files.link def peers_compact_then_link(source: str, target: str) -> bool: _committed(OsFiles(lock_file), lock_file, root, [None] * 70) return real_link(source, target) mocker.patch.object(files, "link", autospec=True, side_effect=peers_compact_then_link) assert ( log.commit(Snapshot(generation=4, writer=_FIRST, readers=frozenset())), Path(root, "gen", f"{4:020d}").exists(), ) == (False, False) @pytest.mark.requires_hard_links @pytest.mark.parametrize("witness", ["listing", "head"]) def test_a_commit_beside_another_late_record_counts_as_lost( lock_file: str, files: OsFiles, root: str, head: Path, mocker: MockerFixture, witness: str ) -> None: # Two committers stalled before their links to adjacent slots. The one below has put its record back into gen/3 and # not yet removed it, so the predecessor check passes; either the listing or HEAD alone must still refuse gen/4. log = _committed(files, lock_file, root, [None] * 3) stale_head, stale_listing, real_link = head.read_bytes(), files.listdir(str(head.parent)), files.link def peers_compact_then_both_link(source: str, target: str) -> bool: _committed(OsFiles(lock_file), lock_file, root, [None] * 70) Path(root, "gen", f"{3:020d}").write_bytes(Snapshot(generation=3, writer=None, readers=frozenset()).encode()) if witness == "listing": head.write_bytes(stale_head) else: mocker.patch.object(OsFiles, "listdir", autospec=True, return_value=stale_listing) return real_link(source, target) mocker.patch.object(files, "link", autospec=True, side_effect=peers_compact_then_both_link) assert ( log.commit(Snapshot(generation=4, writer=_FIRST, readers=frozenset())), Path(root, "gen", f"{4:020d}").exists(), ) == (False, False) @pytest.mark.requires_hard_links @pytest.mark.parametrize("generation", [pytest.param(1, id="first"), pytest.param(3, id="later")]) def test_a_record_refused_inside_the_window_keeps_its_slot( lock_file: str, files: OsFiles, root: str, mocker: MockerFixture, generation: int ) -> None: # Removing it would free a slot the log is not a window past, for a committer stalled before its link to take. log = _committed(files, lock_file, root, [None] * (generation - 1)) log.latest() real_link = files.link def link_then_peers_commit(source: str, target: str) -> bool: landed = real_link(source, target) _committed(OsFiles(lock_file), lock_file, root, [None]) Path(root, "gen", f"{generation - 1:020d}").unlink(missing_ok=True) return landed mocker.patch.object(files, "link", autospec=True, side_effect=link_then_peers_commit) assert ( log.commit(Snapshot(generation=generation, writer=_FIRST, readers=frozenset())), Path(root, "gen", f"{generation:020d}").exists(), ) == (False, True) @pytest.mark.requires_hard_links def test_a_record_refused_with_nothing_built_on_it_is_removed( lock_file: str, files: OsFiles, root: str, mocker: MockerFixture ) -> None: # A peer replaced the log after the epoch check: left behind, gen/6 would pass for the head of the new log. log = _committed(files, lock_file, root, [None] * 5) real_create = files.create def wipe_then_create(path: str, data: bytes, *, durable: bool = False) -> tuple[int, int]: shutil.rmtree(root) _committed(OsFiles(lock_file), lock_file, root, [_SECOND]) return real_create(path, data, durable=durable) mocker.patch.object(files, "create", autospec=True, side_effect=wipe_then_create) assert ( log.commit(Snapshot(generation=6, writer=_FIRST, readers=frozenset())), Path(root, "gen", f"{6:020d}").exists(), ) == (False, False) @pytest.mark.requires_hard_links def test_a_late_head_rename_never_moves_head_back( lock_file: str, files: OsFiles, root: str, head: Path, mocker: MockerFixture ) -> None: log = _committed(files, lock_file, root, [None]) real_link = files.link def link_then_peers_commit(source: str, target: str) -> bool: landed = real_link(source, target) _committed(OsFiles(lock_file), lock_file, root, [None] * 3) return landed mocker.patch.object(files, "link", autospec=True, side_effect=link_then_peers_commit) assert (log.commit(Snapshot(generation=2, writer=_FIRST, readers=frozenset())), _published(head)) == ( True, Snapshot(generation=5, writer=None, readers=frozenset()), ) @pytest.mark.requires_hard_links def test_a_failed_head_rename_keeps_the_commit( lock_file: str, files: OsFiles, root: str, mocker: MockerFixture ) -> None: log = _committed(files, lock_file, root, [None]) mocker.patch.object(files, "replace", autospec=True, side_effect=OSError(EIO, "rename failed")) assert ( log.commit(Snapshot(generation=2, writer=_FIRST, readers=frozenset())), GenerationLog(OsFiles(lock_file), lock_file, root).latest().writer, ) == (True, _FIRST) @pytest.mark.requires_hard_links def test_a_log_whose_epoch_was_never_written_runs_without_head( lock_file: str, files: OsFiles, root: str, head: Path ) -> None: # The epoch's creator died between creating the file and writing its token. files.prepare(root) Path(root, "gen", "epoch").write_bytes(b"") log = _committed(files, lock_file, root, [None, _FIRST]) assert (head.exists(), log.latest().writer) == (False, _FIRST) @pytest.mark.requires_hard_links def test_compaction_removes_a_stalled_survivor_before_the_window_edge( lock_file: str, files: OsFiles, root: str ) -> None: # A committer that stalled before its unlink left gen/5 behind; removing gen/6 alone would leave a hole above it. log = _committed(files, lock_file, root, [None] * 70) Path(root, "gen", f"{5:020d}").write_bytes(Snapshot(generation=5, writer=None, readers=frozenset()).encode()) assert ( log.commit(Snapshot(generation=71, writer=None, readers=frozenset())), min(int(entry.name) for entry in Path(root, "gen").iterdir() if entry.name.isdigit()), ) == (True, 7) @pytest.mark.requires_hard_links def test_a_commit_flushes_only_its_generation_record( lock_file: str, files: OsFiles, root: str, flushes: MagicMock ) -> None: log = _committed(files, lock_file, root, [None]) flushes.reset_mock() assert (log.commit(Snapshot(generation=2, writer=_FIRST, readers=frozenset())), flushes.call_count) == (True, 1) @pytest.mark.requires_hard_links def test_a_torn_head_still_refuses_a_first_generation(lock_file: str, files: OsFiles, root: str, head: Path) -> None: # Its epoch line still says this log moved past its first generation, whatever follows it. log = _committed(files, lock_file, root, [None, None, _FIRST]) for generation in (1, 2, 3): Path(root, "gen", f"{generation:020d}").unlink() head.write_bytes(Path(root, "gen", "epoch").read_bytes() + b"garbage\n") assert not log.commit(Snapshot(generation=1, writer=_SECOND, readers=frozenset())) @pytest.fixture def head(root: str) -> Path: return Path(root, "gen", "HEAD") def _published(head: Path) -> Snapshot | None: return parse_snapshot(head.read_bytes().partition(b"\n")[2]) @pytest.mark.requires_hard_links def test_a_tighter_peer_waits_out_the_holders_own_lease(lock_file: str, files: OsFiles) -> None: # The holder heartbeats on its own slower schedule; a peer configured for a shorter threshold must not take a gap # between those beats for death. clock = [0.0] holder = _participant(files, lock_file, "write", stale_threshold=10, clock=lambda: clock[0]) holder.publish() assert holder.advance() peer = _participant(files, lock_file, "write", stale_threshold=1, clock=lambda: clock[0]) peer.publish() assert not peer.advance() clock[0] = 5.0 assert not peer.advance() clock[0] = 11.0 assert peer.advance() @pytest.mark.requires_hard_links def test_a_contender_suspended_after_its_read_does_not_evict_a_live_holder( lock_file: str, files: OsFiles, root: str, mocker: MockerFixture ) -> None: # The stop lands between the read of the holder's record and the clock sample that follows it; the holder keeps # heartbeating throughout, so the bytes the contender resumes with are stale, not the holder. clock = [0.0] holder = _participant(files, lock_file, "write", stale_threshold=10, clock=lambda: clock[0]) holder.publish() assert holder.advance() peer = _participant(files, lock_file, "write", stale_threshold=10, clock=lambda: clock[0]) peer.publish() assert not peer.advance() record, real_read = str(Path(root, "holders", holder.token)), files.read def read_then_sleep_through_a_heartbeat(path: str) -> bytes | None: data = real_read(path) if path == record: clock[0] += 11.0 assert holder.heartbeat() == ("ok", None) return data mocker.patch.object(files, "read", autospec=True, side_effect=read_then_sleep_through_a_heartbeat) assert not peer.advance() assert holder.heartbeat() == ("ok", None) @pytest.mark.requires_hard_links def test_the_sweep_forgets_peers_that_left(lock_file: str, files: OsFiles, root: str) -> None: clock = [0.0] ledger = Ledger(lambda: clock[0]) def waiter() -> Participant: log = GenerationLog(files, lock_file, root) return Participant(files, root, "read", stale_threshold=10, clock=lambda: clock[0], ledger=ledger, log=log) writer = _participant(files, lock_file, "write", stale_threshold=10, clock=lambda: clock[0]) writer.publish() assert writer.advance() record = Path(root, "holders", writer.token).read_bytes() (first := waiter()).publish() assert not first.advance() first.leave() writer.leave() clock[0] = 6.0 (second := waiter()).publish() assert second.advance() second.leave() # Had the writer's entry survived the sweep, the same bytes six seconds later would read as six seconds unchanged. assert ledger.observe(writer.token, record) == pytest.approx(0.0) @pytest.mark.requires_hard_links def test_leave_drops_the_record_when_the_log_cannot_be_read( lock_file: str, files: OsFiles, root: str, mocker: MockerFixture ) -> None: reader = _participant(files, lock_file, "read") reader.publish() mocker.patch.object(GenerationLog, "latest", autospec=True, side_effect=OSError(EIO, "Input/output error")) with pytest.raises(OSError, match="Input/output error"): reader.leave() assert not Path(root, "holders", reader.token).exists() @pytest.mark.requires_hard_links def test_early_commits_unlink_no_negative_generation(lock_file: str, files: OsFiles, mocker: MockerFixture) -> None: unlink = mocker.spy(OsFiles, "unlink") writer = _participant(files, lock_file, "write") writer.publish() assert writer.advance() writer.leave() assert [call for call in unlink.call_args_list if Path(call.args[-1]).name.startswith("-")] == [] @pytest.mark.requires_hard_links def test_latest_on_a_current_head_reads_it_then_probes_one_name( lock_file: str, files: OsFiles, root: str, mocker: MockerFixture ) -> None: log = _committed(files, lock_file, root, [None, _FIRST]) read = mocker.spy(OsFiles, "read") assert log.latest().generation == 2 assert [name for call in read.call_args_list if (name := Path(call.args[-1]).name).isdigit()] == [ f"{2:020d}", f"{3:020d}", ] @pytest.mark.requires_hard_links def test_heartbeat_ignores_a_broken_successor_record(lock_file: str, files: OsFiles, root: str) -> None: # A record nobody can parse breaks every acquire, and that is its own error; it says nothing about whether this # holder is alive, so the heartbeat must keep the nonce fresh rather than stop and hand the lock to a peer. writer = _participant(files, lock_file, "write") writer.publish() assert writer.advance() Path(root, "gen", f"{2:020d}").write_bytes(b"garbage\n") assert writer.heartbeat() == ("ok", None) @pytest.mark.requires_hard_links @pytest.mark.parametrize( ("error", "expected"), [ pytest.param(OSError(EIO, "Input/output error"), "transient", id="filesystem-error"), pytest.param(TreeReplacedError("tree replaced"), "lost", id="replaced-tree"), ], ) def test_heartbeat_reports_the_refresh_error( lock_file: str, files: OsFiles, mocker: MockerFixture, error: OSError | TreeReplacedError, expected: Literal["transient", "lost"], ) -> None: writer: Final[Participant] = _participant(files, lock_file, "write") writer.publish() assert writer.advance() mocker.patch.object(OsFiles, "overwrite", autospec=True, side_effect=error) assert writer.heartbeat() == (expected, error if isinstance(error, OSError) else None) def test_heartbeat_without_a_published_record_is_lost(lock_file: str, files: OsFiles) -> None: assert _participant(files, lock_file, "write").heartbeat() == ("lost", None) @pytest.mark.skipif( not (CAPABILITIES["symlink"] and CAPABILITIES["link-dir-fd"]), reason="a swapped tree is refused only where records are reached through descriptors", ) @pytest.mark.requires_hard_links def test_heartbeat_with_the_tree_swapped_for_a_symlink_is_lost( # pragma: needs link-dir-fd lock_file: str, files: OsFiles, root: str, tmp_path: Path ) -> None: writer = _participant(files, lock_file, "write") writer.publish() assert writer.advance() Path(root).rename(tmp_path / "aside") Path(root).symlink_to(target := tmp_path / "target", target_is_directory=True) target.mkdir() assert (writer.heartbeat(), list(target.iterdir())) == (("lost", None), []) @pytest.mark.skipif( not (CAPABILITIES["symlink"] and CAPABILITIES["link-dir-fd"]), reason="a swapped tree is refused only where records are reached through descriptors", ) @pytest.mark.requires_hard_links @pytest.mark.parametrize("component", ["root", "holders"]) @pytest.mark.parametrize("refusal", ["EFTYPE", "EMLINK"]) def test_heartbeat_with_a_symlink_refused_by_platform_errno_is_lost( # pragma: needs link-dir-fd lock_file: str, files: OsFiles, root: str, tmp_path: Path, mocker: MockerFixture, component: str, refusal: str ) -> None: writer = _participant(files, lock_file, "write") writer.publish() assert writer.advance() directory = Path(root) if component == "root" else Path(root, component) directory.rename(tmp_path / "aside") directory.symlink_to(target := tmp_path / "target", target_is_directory=True) target.mkdir() eftype = getattr(errno, "EFTYPE", 79) mocker.patch.object(errno, "EFTYPE", eftype, create=True) refusal_errno = getattr(errno, refusal) real_open = os.open refusals: list[tuple[int, int | None]] = [] def platform_open(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: try: return real_open(path, flags, mode, dir_fd=dir_fd) except OSError as error: # NetBSD uses EFTYPE and FreeBSD uses EMLINK; translate only the real symlink refusal. refusals.append((flags, error.errno)) raise OSError(refusal_errno, os.strerror(refusal_errno), path) from error mocker.patch.object(os, "open", side_effect=platform_open) assert (writer.heartbeat(), list(target.iterdir())) == (("lost", None), []) assert len(refusals) == 1 flags, native_errno = refusals[0] assert flags & getattr(os, "O_NOFOLLOW", 0) assert native_errno in {errno.ELOOP, errno.ENOTDIR, errno.EMLINK, eftype} @pytest.mark.requires_hard_links def test_heartbeat_leaves_a_file_linked_over_the_holder_record_alone(lock_file: str, files: OsFiles, root: str) -> None: # A process that can write holders/ can rename a hard link to another of the holder's files over its record. writer = _participant(files, lock_file, "write") writer.publish() (private := Path(f"{lock_file}.private")).write_bytes(b"private") os.link(private, linked := Path(root, "holders", "linked")) linked.replace(Path(root, "holders", writer.token)) assert writer.advance() assert (writer.heartbeat(), private.read_bytes()) == (("lost", None), b"private") _STALE_THRESHOLD: Final[float] = 1.0 _HEARTBEAT: Final[float] = 0.3 _POLL: Final[float] = 0.1 #: What one filesystem operation costs. Small against the heartbeat so a live participant's refreshes stay well inside #: the stale threshold however the scheduler orders its operations, as they do on a real filesystem. _STEP: Final[float] = 0.001 #: A runnable participant is scheduled again before this much fake time passes, so only a crashed one goes stale. _FAIRNESS: Final[float] = 8 * _STEP _ACQUIRE_BUDGET: Final[float] = 120.0 _ROOT: Final[str] = "/lock.rw" class _Crashed(BaseException): """Raised inside a participant the scheduler killed; it exits at once and never touches the filesystem again.""" @dataclass class _Scheduler: """ Discrete-event scheduler: a parked participant is runnable once its wake time has passed, the clock only jumps when nothing is runnable, and every scheduled operation costs one step. A runnable participant waits at most ``_FAIRNESS`` before it is picked, so only a crashed participant can ever look stale. """ seed: int crash_probability: float max_crashes: int clock: float = 0.0 crashes: int = 0 _random: random.Random = field(init=False) _condition: threading.Condition = field(default_factory=threading.Condition) _expected: int = 0 _live: set[int] = field(default_factory=set) _parked: dict[int, float] = field(default_factory=dict) _turn: int | None = None _crashed: set[int] = field(default_factory=set) def __post_init__(self) -> None: self._random = random.Random(self.seed) # ruff: ignore[suspicious-non-cryptographic-random-usage] # a seeded schedule is the point, not entropy def expect(self) -> None: # Counted before the thread starts, so the first step cannot conclude that nobody is left to run. with self._condition: self._expected += 1 def register(self) -> None: with self._condition: self._expected -= 1 self._live.add(threading.get_ident()) self._condition.notify_all() def yield_turn(self, wake_at: float | None = None) -> None: me = threading.get_ident() with self._condition: self._parked[me] = self.clock if wake_at is None else wake_at self._condition.notify_all() while self._turn != me: self._condition.wait() self._turn = None crashed = me in self._crashed if crashed: raise _Crashed def sleep(self, seconds: float) -> None: self.yield_turn(wake_at=self.clock + seconds) def finish(self) -> None: with self._condition: self._live.discard(threading.get_ident()) self._parked.pop(threading.get_ident(), None) self._condition.notify_all() def step(self) -> bool: """Run one participant for one filesystem operation; ``False`` once every participant has finished.""" with self._condition: while self._expected or self._turn is not None or set(self._parked) != self._live: self._condition.wait() if not self._live: return False if not (runnable := [ident for ident, wake_at in self._parked.items() if wake_at <= self.clock]): self.clock = min(self._parked.values()) runnable = [ident for ident, wake_at in self._parked.items() if wake_at <= self.clock] starving = [ident for ident in runnable if self.clock - self._parked[ident] >= _FAIRNESS] chosen = self._random.choice(sorted(starving or runnable)) del self._parked[chosen] self.clock += _STEP if self.crashes < self.max_crashes and self._random.random() < self.crash_probability: self._crashed.add(chosen) self.crashes += 1 self._turn = chosen self._condition.notify_all() return True class _MemoryFiles: """An in-memory :class:`~filelock._soft_rw._protocol.Files` whose every call is one scheduler step.""" def __init__(self, scheduler: _Scheduler, *, stale_listings: float = 0.0) -> None: self._scheduler = scheduler self.files: dict[str, bytes] = {} # An NFS client can serve a directory listing that predates a peer's commit; with this probability a listing # comes from some earlier point in the tree's history rather than from now. self._stale_listings = stale_listings self._random = random.Random(scheduler.seed + 1) # ruff: ignore[suspicious-non-cryptographic-random-usage] # a seeded schedule, not entropy self._history: list[list[str]] = [[]] def _record(self) -> None: self._history.append(sorted(self.files)) del self._history[:-128] def read(self, path: str) -> bytes | None: self._scheduler.yield_turn() return self.files.get(_key(path)) def create(self, path: str, data: bytes, *, durable: bool = False) -> tuple[int, int]: # ruff:ignore[unused-method-argument] # memory has no storage to flush self._scheduler.yield_turn() if _key(path) in self.files: # pragma: no cover # every name created here is a fresh token raise FileExistsError(path) self.files[_key(path)] = data self._record() return 0, 0 def link(self, source: str, target: str) -> bool: self._scheduler.yield_turn() if _key(target) in self.files or _key(source) not in self.files: return False self.files[_key(target)] = self.files[_key(source)] self._record() return True def replace(self, source: str, target: str) -> bool: self._scheduler.yield_turn() self.files[_key(target)] = self.files.pop(_key(source)) self._record() return True def overwrite(self, path: str, data: bytes, identity: tuple[int, int]) -> bool: # ruff:ignore[unused-method-argument] # nothing here swaps one file for another self._scheduler.yield_turn() if ( _key(path) not in self.files ): # pragma: no cover # only a crashed participant loses its record, and it never writes again return False self.files[_key(path)] = data self._record() return True def unlink(self, path: str) -> None: self._scheduler.yield_turn() self.files.pop(_key(path), None) self._record() def listdir(self, path: str) -> list[str]: self._scheduler.yield_turn() names = ( self._random.choice(self._history) if self._random.random() < self._stale_listings else sorted(self.files) ) return [PurePosixPath(name).name for name in names if str(PurePosixPath(name).parent) == _key(path)] def prepare(self, root: str, *, force: bool = False) -> None: # ruff:ignore[unused-method-argument] # nothing to create in memory self._scheduler.yield_turn() def _key(path: str) -> str: # The protocol joins paths with the host separator; the in-memory tree keys them the POSIX way on every platform. return path.replace("\\", "/") @dataclass class _Outcome: mode: Literal["read", "write"] granted_at: int | None = None crashed: bool = False lost: bool = False stuck: bool = False class _Model: def __init__(self, scheduler: _Scheduler, *, stale_listings: float = 0.0) -> None: self.scheduler = scheduler self.files = _MemoryFiles(scheduler, stale_listings=stale_listings) self.holding: dict[str, Literal["read", "write"]] = {} self.last_granted = 0 self.last_writer = 0 self.outcomes: list[_Outcome] = [] self.threads: list[threading.Thread] = [] self.violations: list[str] = [] def add(self, mode: Literal["read", "write"], hold_beats: int) -> None: outcome = _Outcome(mode) self.outcomes.append(outcome) self.scheduler.expect() self.threads.append(threading.Thread(target=self._run, args=(outcome, hold_beats), daemon=True)) def _run(self, outcome: _Outcome, hold_beats: int) -> None: self.scheduler.register() participant = Participant( self.files, _ROOT, outcome.mode, stale_threshold=_STALE_THRESHOLD, clock=lambda: self.scheduler.clock, ledger=Ledger(lambda: self.scheduler.clock), log=GenerationLog(self.files, "/lock", _ROOT), ) try: participant.publish() deadline = self.scheduler.clock + _ACQUIRE_BUDGET while not participant.advance(): if ( self.scheduler.clock >= deadline ): # pragma: no cover # a liveness regression, reported by the assertion outcome.stuck = True return self.scheduler.sleep(_POLL) outcome.granted_at = participant.generation self._enter(participant.token, outcome.mode, outcome.granted_at) for _ in range(hold_beats): self.scheduler.sleep(_HEARTBEAT) if ( participant.heartbeat()[0] != "ok" ): # pragma: no cover # a live holder evicted: a regression the test reports outcome.lost = True break self.holding.pop(participant.token, None) participant.leave() except _Crashed: outcome.crashed = True self.holding.pop(participant.token, None) finally: self.scheduler.finish() def _enter(self, token: str, mode: Literal["read", "write"], granted_at: int | None) -> None: # Recorded from the participant's thread while it holds the turn, so the check sees a consistent set. writers = [held for held in self.holding.values() if held == "write"] # Fencing orders writers against every grant; concurrent readers may enter out of commit order. floor = self.last_granted if mode == "write" else self.last_writer if granted_at is None or granted_at <= floor: # pragma: no cover # a forked log, reported by the assertion self.violations.append(f"{mode} granted at generation {granted_at} after generation {floor}") else: self.last_granted = max(self.last_granted, granted_at) if mode == "write": self.last_writer = granted_at if mode == "write" and self.holding: # pragma: no cover # an exclusion regression, reported by the assertion self.violations.append( f"writer granted at {self.scheduler.clock:.2f} while {sorted(self.holding.values())}" ) elif mode == "read" and writers: # pragma: no cover # an exclusion regression, reported by the assertion self.violations.append(f"reader granted at {self.scheduler.clock:.2f} while a writer holds") self.holding[token] = mode def run(self) -> None: for thread in self.threads: thread.start() while self.scheduler.step(): pass for thread in self.threads: thread.join(timeout=30) assert not thread.is_alive() @pytest.mark.parametrize( ("seed", "crash_probability", "stale_listings"), [ pytest.param( seed, probability, stale, id=f"seed{seed}-{'crashes' if probability else 'clean'}-{'stale-listings' if stale else 'fresh-listings'}", ) for seed in range(24) for probability in (0.0 if seed % 3 == 0 else 0.02,) for stale in (0.3 if seed % 2 else 0.0,) ], ) @pytest.mark.timeout(120) def test_model_never_overlaps_and_always_progresses(seed: int, crash_probability: float, stale_listings: float) -> None: scheduler = _Scheduler(seed=seed, crash_probability=crash_probability, max_crashes=3) model = _Model(scheduler, stale_listings=stale_listings) rng = random.Random(seed) # ruff: ignore[suspicious-non-cryptographic-random-usage] # a seeded mix of hold lengths, not entropy for index in range(8): model.add("write" if index % 3 == 0 else "read", hold_beats=rng.randint(1, 4)) model.run() assert model.violations == [] survivors = [outcome for outcome in model.outcomes if not outcome.crashed] assert [outcome for outcome in survivors if outcome.stuck] == [] assert [outcome for outcome in survivors if outcome.lost] == [] assert all(outcome.granted_at is not None for outcome in survivors) assert scheduler.crashes == sum(outcome.crashed for outcome in model.outcomes) @pytest.mark.parametrize("seed", range(6)) @pytest.mark.timeout(120) def test_model_janitor_clears_every_crashed_holder(seed: int) -> None: # After the crashes, one more writer must be able to enter by evicting whatever the dead left behind, and leave the # log naming nobody and the holders directory holding nobody. scheduler = _Scheduler(seed=seed, crash_probability=0.05, max_crashes=4) model = _Model(scheduler) for index in range(6): model.add("write" if index % 2 else "read", hold_beats=2) model.run() assert model.violations == [] janitor = _Model(_Scheduler(seed=seed + 100, crash_probability=0.0, max_crashes=0)) janitor.files.files = model.files.files # Held long enough for the sweep to see an orphaned record unchanged for a full threshold and collect it. janitor.add("write", hold_beats=8) janitor.run() assert janitor.violations == [] assert janitor.outcomes[0].granted_at is not None remaining = sorted(model.files.files) generations = [name for name in remaining if name.startswith(f"{_ROOT}/gen/") and name.rpartition("/")[2].isdigit()] assert generations, "the log must keep its latest generation" assert not [name for name in remaining if name.startswith(f"{_ROOT}/holders/")] latest = model.files.files[generations[-1]] assert b"writer=" not in latest assert b"reader=" not in latest @pytest.mark.timeout(120) def test_fencing_generations_are_strictly_increasing_across_grants() -> None: scheduler = _Scheduler(seed=7, crash_probability=0.0, max_crashes=0) model = _Model(scheduler) for index in range(9): model.add("write" if index % 3 == 0 else "read", hold_beats=1) model.run() assert model.violations == [] writers = sorted(outcome.granted_at or 0 for outcome in model.outcomes if outcome.mode == "write") assert len(set(writers)) == len(writers) for outcome in model.outcomes: assert outcome.granted_at is not None if outcome.mode == "read": assert outcome.granted_at not in writers filelock-4.2.8/tests/soft_rw/test_soft_rw_storage.py0000644000000000000000000002640315272561277017733 0ustar00"""The :class:`~filelock._soft_rw._storage.OsFiles` implementation of the protocol's filesystem operations.""" from __future__ import annotations import contextlib import os import shutil import sys import time from errno import EIO from pathlib import Path from typing import TYPE_CHECKING import pytest from capabilities import CAPABILITIES from filelock import SoftFileLockProtocolError from filelock._soft_rw import _storage as storage_mod from filelock._soft_rw._storage import OsFiles from tests.capability_marks import NEEDS_FOREIGN_STICKY_TMP if TYPE_CHECKING: from collections.abc import Callable from pytest_mock import MockerFixture pytestmark = pytest.mark.requires_hard_links @pytest.mark.parametrize( ("call", "expected"), [ pytest.param(lambda files, path: files.read(path), None, id="read"), pytest.param(lambda files, path: files.overwrite(path, b"data", (0, 0)), False, id="overwrite"), pytest.param(lambda files, path: files.listdir(path), [], id="listdir"), ], ) def test_missing_path_returns_default(tmp_path: Path, call: Callable[[OsFiles, str], object], expected: object) -> None: assert call(OsFiles(str(tmp_path / "x.lock")), str(tmp_path / "absent")) == expected @pytest.mark.parametrize( "call", [ pytest.param(lambda files, path: files.read(path), id="read"), pytest.param(lambda files, path: files.overwrite(path, b"data", (0, 0)), id="overwrite"), ], ) def test_open_other_errors_propagate( tmp_path: Path, mocker: MockerFixture, call: Callable[[OsFiles, str], object] ) -> None: mocker.patch.object(storage_mod.os, "open", side_effect=OSError(EIO, "Input/output error")) with pytest.raises(OSError, match="Input/output error"): call(OsFiles(str(tmp_path / "x.lock")), str(tmp_path / "absent")) def test_read_non_regular_file_is_empty(tmp_path: Path) -> None: # The null device is a character device on every platform, so it stands in for anything planted at a record path # that is not a regular file. assert OsFiles(str(tmp_path / "x.lock")).read(os.devnull) == b"" def test_create_rolls_back_a_failed_write(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch.object(storage_mod, "write_all", side_effect=OSError("write boom")) with pytest.raises(OSError, match="write boom"): OsFiles(str(tmp_path / "x.lock")).create(str(tmp_path / "record"), b"data") assert not (tmp_path / "record").exists() @pytest.mark.parametrize("durable", [pytest.param(True, id="durable"), pytest.param(False, id="holder")]) def test_create_flushes_only_a_durable_record(tmp_path: Path, mocker: MockerFixture, *, durable: bool) -> None: # A generation linked into place outlives its writer's host; unflushed, a crash would leave it empty for everyone. fsync = mocker.spy(storage_mod.os, "fsync") OsFiles(str(tmp_path / "x.lock")).create(str(tmp_path / "record"), b"data", durable=durable) assert fsync.call_count == durable assert (tmp_path / "record").read_bytes() == b"data" def test_link_trusts_identity_over_an_error(tmp_path: Path, mocker: MockerFixture) -> None: # An NFS retransmit can report a link that landed as failed; the target naming the source's file is what counts. real_link = storage_mod._link_no_follow def link_then_fail(source: str, target: str, **dir_fds: int | None) -> None: real_link(source, target, **dir_fds) raise OSError(EIO, "Input/output error") mocker.patch.object(storage_mod, "_link_no_follow", side_effect=link_then_fail) source = tmp_path / "source" source.write_bytes(b"data") assert OsFiles(str(tmp_path / "x.lock")).link(str(source), str(tmp_path / "target")) def test_link_reports_an_unsupported_filesystem(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch.object(storage_mod, "_link_no_follow", side_effect=NotImplementedError("no os.link")) source = tmp_path / "source" source.write_bytes(b"data") with pytest.raises(SoftFileLockProtocolError, match="hard-link"): OsFiles(str(tmp_path / "x.lock")).link(str(source), str(tmp_path / "target")) def test_link_from_a_missing_source_raises(tmp_path: Path) -> None: with pytest.raises(FileNotFoundError): OsFiles(str(tmp_path / "x.lock")).link(str(tmp_path / "absent"), str(tmp_path / "target")) def test_link_raises_a_fault_that_did_not_land(tmp_path: Path, mocker: MockerFixture) -> None: # A persistent EPERM or EIO is not a peer winning the race; reporting it as one would spin forever. mocker.patch.object(storage_mod, "_link_no_follow", side_effect=OSError(EIO, "Input/output error")) source = tmp_path / "source" source.write_bytes(b"data") with pytest.raises(OSError, match="Input/output error"): OsFiles(str(tmp_path / "x.lock")).link(str(source), str(tmp_path / "target")) @pytest.mark.parametrize("refused", [pytest.param(False, id="renamed"), pytest.param(True, id="refused")]) def test_replace_renames_unless_refused(tmp_path: Path, mocker: MockerFixture, *, refused: bool) -> None: # Windows refuses to rename over a file a reader holds open; the caller keeps the older target. (source := tmp_path / "source").write_bytes(b"new") (target := tmp_path / "target").write_bytes(b"old") if refused: mocker.patch.object(storage_mod.os, "replace", autospec=True, side_effect=PermissionError("in use")) replaced = OsFiles(str(tmp_path / "x.lock")).replace(str(source), str(target)) assert (replaced, target.read_bytes(), source.exists()) == (not refused, b"old" if refused else b"new", refused) def test_overwrite_rewrites_the_record_it_created(tmp_path: Path) -> None: files = OsFiles(str(tmp_path / "x.lock")) identity = files.create(str(record := tmp_path / "record"), b"old") assert (files.overwrite(str(record), b"new", identity), record.read_bytes()) == (True, b"new") @pytest.mark.parametrize("swap", [pytest.param("link", id="hard-link"), pytest.param("rename", id="rename")]) def test_overwrite_spares_a_file_swapped_in_for_the_record(tmp_path: Path, swap: str) -> None: # A process that can write the directory can put another of this account's files under the record's name, and # that file opens for writing like the record would. files = OsFiles(str(tmp_path / "x.lock")) identity = files.create(str(record := tmp_path / "record"), b"old") (private := tmp_path / "private").write_bytes(b"private") record.unlink() getattr(os, swap)(private, record) assert (files.overwrite(str(record), b"new", identity), record.read_bytes()) == (False, b"private") def test_overwrite_spares_a_record_with_a_second_name(tmp_path: Path) -> None: files = OsFiles(str(tmp_path / "x.lock")) identity = files.create(str(record := tmp_path / "record"), b"old") os.link(record, tmp_path / "private") assert (files.overwrite(str(record), b"new", identity), record.read_bytes()) == (False, b"old") @pytest.mark.skipif(not CAPABILITIES["fifo"], reason="needs os.mkfifo") @pytest.mark.parametrize("reader", [pytest.param(False, id="no-reader"), pytest.param(True, id="reader")]) def test_overwrite_does_not_block_on_a_fifo(tmp_path: Path, *, reader: bool) -> None: # pragma: needs fifo files = OsFiles(str(tmp_path / "x.lock")) identity = files.create(str(record := tmp_path / "record"), b"old") record.unlink() assert sys.platform != "win32" # skipped there; narrows os.mkfifo and os.O_NONBLOCK for the type checker os.mkfifo(record) with contextlib.ExitStack() as stack: if reader: stack.callback(os.close, os.open(record, os.O_RDONLY | os.O_NONBLOCK)) assert files.overwrite(str(record), b"new", identity) is False def test_unlink_tolerates_a_refused_removal(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch.object(storage_mod.os, "unlink", side_effect=PermissionError("held open")) OsFiles(str(tmp_path / "x.lock")).unlink(str(tmp_path / "record")) def test_unlink_other_errors_propagate(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch.object(storage_mod.os, "unlink", side_effect=OSError(EIO, "Input/output error")) with pytest.raises(OSError, match="Input/output error"): OsFiles(str(tmp_path / "x.lock")).unlink(str(tmp_path / "record")) @pytest.mark.skipif(sys.platform == "win32", reason="Windows retries a refused open as a sharing race") def test_read_refused_on_posix_raises(tmp_path: Path, mocker: MockerFixture) -> None: # pragma: win32 no cover # Reading a peer's record as missing when it is unreadable would age it as a constant and evict a holder. mocker.patch.object(storage_mod.os, "open", side_effect=PermissionError("denied")) with pytest.raises(PermissionError): OsFiles(str(tmp_path / "x.lock")).read(str(tmp_path / "record")) @pytest.mark.skipif(sys.platform != "win32", reason="only Windows retries a refused open as a sharing race") def test_read_refused_on_windows_is_missing_after_the_grace( tmp_path: Path, mocker: MockerFixture ) -> None: # pragma: win32 cover # A file still refusing after the grace has been delete-pending longer than any deletion takes: on its way out. mocker.patch.object(storage_mod.os, "open", side_effect=PermissionError("sharing violation")) started = time.monotonic() assert OsFiles(str(tmp_path / "x.lock")).read(str(tmp_path / "record")) is None assert time.monotonic() - started >= storage_mod._WINDOWS_OPEN_GRACE - 0.05 def test_prepare_checks_the_directories_once_unless_forced(tmp_path: Path, mocker: MockerFixture) -> None: files = OsFiles(str(tmp_path / "x.lock")) ensure = mocker.spy(storage_mod, "ensure_directory_exists") files.prepare(root := str(tmp_path / "x.lock.rw")) files.prepare(root) assert ensure.call_count == 1 files.prepare(root, force=True) assert ensure.call_count == 2 def test_prepare_creates_missing_parents(tmp_path: Path) -> None: root = str(tmp_path / "nested" / "deeper" / "x.lock.rw") OsFiles(str(tmp_path / "nested" / "deeper" / "x.lock")).prepare(root) assert Path(root, "gen").is_dir() @NEEDS_FOREIGN_STICKY_TMP @pytest.mark.parametrize( "call", [ pytest.param(lambda files, path: files.create(path, b"data"), id="create"), pytest.param(lambda files, path: files.read(path), id="read"), pytest.param(lambda files, path: files.listdir(str(Path(path).parent)), id="listdir"), pytest.param(lambda files, path: files.unlink(path), id="unlink"), ], ) def test_record_operations_recheck_the_owner_of_a_replaced_directory( # pragma: needs foreign-sticky-tmp lock_path_in_shared_tmp: Path, mocker: MockerFixture, call: Callable[[OsFiles, str], object] ) -> None: files = OsFiles(str(lock_path_in_shared_tmp)) files.prepare(root := f"{lock_path_in_shared_tmp}.rw") Path(root).rename(aside := f"{root}.aside") try: for name in ("gen", "holders"): Path(root, name).mkdir(parents=True) assert sys.platform != "win32" # the capability is absent there; narrows os.geteuid for the type checker mocker.patch("filelock._util.os.geteuid", autospec=True, return_value=os.geteuid() + 1) with pytest.raises(RuntimeError, match="not to this process's account"): call(files, str(Path(root, "holders", "record"))) assert list(Path(root, "holders").iterdir()) == [] finally: shutil.rmtree(aside, ignore_errors=True) filelock-4.2.8/tests/soft_rw/test_soft_rw_sync.py0000644000000000000000000021643215272561277017246 0ustar00from __future__ import annotations import multiprocessing as mp import os import shutil import signal import stat import sys import threading import time from concurrent.futures import ThreadPoolExecutor from contextlib import closing, suppress from errno import EIO from functools import partial from multiprocessing import Event, Process from pathlib import Path from types import SimpleNamespace from typing import TYPE_CHECKING, Any, Final, Literal import pytest from capabilities import CAPABILITIES from filelock import AsyncSoftReadWriteLock, SoftFileLockProtocolError, Timeout from filelock._lease import LeaseCompromise from filelock._soft_rw import SoftReadWriteLock from filelock._soft_rw import _storage as storage_mod from filelock._soft_rw import _sync as sync_mod from filelock._soft_rw._protocol import GenerationLog, Participant, Snapshot, encode_holder, new_token from filelock._soft_rw._storage import OsFiles from tests.capability_marks import ( NEEDS_FILE_MODE, NEEDS_FOREIGN_STICKY_TMP, NEEDS_FORK, NEEDS_POSIX_SIGNALS, NEEDS_SYMLINK, SKIP_ON_UNRELIABLE_PROCESS_SYNC, ) from tests.process_helpers import cleanup_processes if TYPE_CHECKING: from collections.abc import Callable, Generator from multiprocessing.synchronize import Event as EventType from pytest_mock import MockerFixture pytestmark = pytest.mark.requires_hard_links # Bounds how long a spawned process or thread may take to reach the lock, not how fast it must be: an interpreter # that starts slowly under a loaded suite is not a locking failure. The short negative waits below are deliberate, # since those assert a contender stays blocked and have to stay brief. _PROCESS_DEADLINE: Final[int] = 30 @pytest.fixture(autouse=True) def _clear_singletons() -> Generator[None]: SoftReadWriteLock._instances.clear() yield for lock in filter(None, (ref() for ref in list(SoftReadWriteLock._instances.valuerefs()))): lock.close() SoftReadWriteLock._instances.clear() @pytest.fixture def lock_file(tmp_path: Path) -> str: return str(tmp_path / "test.lock") @pytest.fixture def lock(lock_file: str) -> Generator[SoftReadWriteLock]: instance = _make_lock(lock_file) try: yield instance finally: instance.close() def _state(lock_file: str) -> Snapshot: return GenerationLog(OsFiles(lock_file), lock_file, f"{lock_file}.rw").latest() def _remove_log(lock_file: str) -> None: # The documented recovery: a rename takes the whole log away at once, so no participant reads it half deleted. Path(f"{lock_file}.rw").rename(removed := Path(f"{lock_file}.rw.removed")) shutil.rmtree(removed) def _holders(lock_file: str) -> list[str]: holders = Path(f"{lock_file}.rw", "holders") return sorted(entry.name for entry in holders.iterdir()) if holders.is_dir() else [] def _generations(lock_file: str) -> list[str]: return sorted(entry.name for entry in Path(f"{lock_file}.rw", "gen").iterdir() if entry.name.isdigit()) def _plant_holder(lock_file: str, *, mode: Literal["read", "write"], host: str = "terminated-pod") -> str: # What a process on another host leaves behind when it dies holding the lock: its record and the snapshot naming it. files = OsFiles(lock_file) root = f"{lock_file}.rw" files.prepare(root) token = new_token() record = encode_holder(token, new_token(), 0.5).replace(b"host=", f"host={host}?".encode("ascii"), 1) files.create(str(Path(root, "holders", token)), record) log = GenerationLog(files, lock_file, root) latest = log.latest() writer, readers = (token, latest.readers) if mode == "write" else (latest.writer, latest.readers | {token}) assert log.commit(Snapshot(generation=latest.generation + 1, writer=writer, readers=readers)) return token def test_release_interrupted_while_joining_the_heartbeat_still_leaves(lock_file: str, mocker: MockerFixture) -> None: lock = _make_lock(lock_file) lock.acquire_write(timeout=2) mocker.patch.object(threading.Thread, "join", autospec=True, side_effect=KeyboardInterrupt) with pytest.raises(KeyboardInterrupt): lock.release() mocker.stopall() peer = _make_lock(lock_file) with peer.write_lock(blocking=False): assert peer.generation is not None lock.close() peer.close() @pytest.mark.parametrize( ("name", "value", "error", "problem"), [ # The heartbeat thread and the acquire loop wait this long; past TIMEOUT_MAX they raise OverflowError mid-hold. pytest.param( "heartbeat_interval", threading.TIMEOUT_MAX * 2, ValueError, "not exceed threading.TIMEOUT_MAX", id="heartbeat-max", ), pytest.param( "poll_interval", threading.TIMEOUT_MAX * 2, ValueError, "not exceed threading.TIMEOUT_MAX", id="poll-max" ), pytest.param("heartbeat_interval", "1", TypeError, "be a number of seconds, not str", id="heartbeat-str"), pytest.param("heartbeat_interval", True, TypeError, "be a number of seconds, not bool", id="heartbeat-bool"), pytest.param("stale_threshold", True, TypeError, "be a number of seconds, not bool", id="stale-bool"), pytest.param("poll_interval", True, TypeError, "be a number of seconds, not bool", id="poll-bool"), ], ) def test_rejects_an_interval_it_cannot_use( tmp_path: Path, name: str, value: float, error: type[Exception], problem: str ) -> None: settings: Final = {name: value} with pytest.raises(error, match=f"{name} must {problem}"): SoftReadWriteLock( tmp_path / "a.lock", is_singleton=False, heartbeat_interval=settings.get("heartbeat_interval", 30.0), stale_threshold=settings.get("stale_threshold", 90.0), poll_interval=settings.get("poll_interval", 0.25), ) @pytest.mark.parametrize( "lock_type", [pytest.param(SoftReadWriteLock, id="sync"), pytest.param(AsyncSoftReadWriteLock, id="async")], ) @pytest.mark.parametrize("cached", [pytest.param(False, id="new"), pytest.param(True, id="cached")]) @pytest.mark.parametrize( ("settings", "message"), [ pytest.param({name: value}, name, id=f"{name}-{label}") for name in ("heartbeat_interval", "stale_threshold", "poll_interval") for label, value in (("nan", float("nan")), ("infinity", float("inf")), ("negative-infinity", float("-inf"))) ], ) def test_rejects_invalid_intervals( tmp_path: Path, lock_type: type[SoftReadWriteLock | AsyncSoftReadWriteLock], cached: bool, settings: dict[str, float], message: str, ) -> None: path: Final = tmp_path / "timing.lock" # Keep the weakly cached instance alive through the second construction. existing: Final = SoftReadWriteLock(path) if cached else None try: with pytest.raises(ValueError, match=rf"{message} must .*finite"): lock_type( path, heartbeat_interval=settings.get("heartbeat_interval", 30), stale_threshold=settings.get("stale_threshold"), poll_interval=settings.get("poll_interval", 0.25), ) finally: if existing is not None: existing.close() @pytest.mark.parametrize( ("heartbeat_interval", "stale_threshold", "poll_interval", "match"), [ pytest.param(0, None, 0.25, "heartbeat_interval must be positive", id="heartbeat-non-positive"), pytest.param(10, 5, 0.25, "stale_threshold must exceed", id="stale-not-greater"), pytest.param(30, None, 0, "poll_interval must be positive", id="poll-non-positive"), pytest.param(1, 3, 3, "poll_interval must be below", id="poll-not-below-stale"), ], ) def test_rejects_invalid_interval_relationship( lock_file: str, heartbeat_interval: float, stale_threshold: float | None, poll_interval: float, match: str ) -> None: with pytest.raises(ValueError, match=match): SoftReadWriteLock( lock_file, is_singleton=False, heartbeat_interval=heartbeat_interval, stale_threshold=stale_threshold, poll_interval=poll_interval, ) def test_public_attributes(lock_file: str) -> None: lock = SoftReadWriteLock( lock_file, timeout=5, blocking=False, heartbeat_interval=10, stale_threshold=45, poll_interval=0.5, is_singleton=False, ) try: assert lock.lock_file == lock_file assert lock.timeout == 5 assert lock.blocking is False assert lock.heartbeat_interval == 10 assert lock.stale_threshold == 45 assert lock.poll_interval == pytest.approx(0.5) finally: lock.close() def test_default_stale_threshold_is_triple_heartbeat(lock_file: str) -> None: lock = SoftReadWriteLock(lock_file, heartbeat_interval=12, is_singleton=False) try: assert lock.stale_threshold == 36 finally: lock.close() def test_singleton_returns_same_instance(lock_file: str) -> None: first = SoftReadWriteLock(lock_file) second = SoftReadWriteLock(lock_file) try: assert first is second finally: first.close() @pytest.mark.parametrize("order", [(0, 1, 2), (1, 2, 0), (2, 0, 1)]) def test_singletons_are_distinct_per_subclass(lock_file: str, order: tuple[int, int, int]) -> None: class FirstLock(SoftReadWriteLock): pass class SecondLock(SoftReadWriteLock): pass lock_types = (SoftReadWriteLock, FirstLock, SecondLock) instances: list[SoftReadWriteLock] = [] try: for index in order: lock_type = lock_types[index] instance = lock_type(lock_file) instances.append(instance) assert type(instance) is lock_type assert lock_type.get_lock(lock_file) is instance assert len({id(instance) for instance in instances}) == len(lock_types) with instances[0].write_lock(): for contender in instances[1:]: with pytest.raises(Timeout): contender.acquire_write(blocking=False) finally: for instance in instances: instance.close() @pytest.mark.parametrize("relation", ["base", "sibling", "child"]) def test_singleton_construction_can_overlap_across_classes(lock_file: str, relation: str) -> None: entered, release = threading.Event(), threading.Event() class SlowLock(SoftReadWriteLock): def __init__(self, *args: Any, **kwargs: Any) -> None: # ruff:ignore[any-type] # forwards the public constructor unchanged if type(self) is SlowLock: entered.set() assert release.wait(timeout=5) super().__init__(*args, **kwargs) class SiblingLock(SoftReadWriteLock): pass class ChildLock(SlowLock): pass other_type = {"base": SoftReadWriteLock, "sibling": SiblingLock, "child": ChildLock}[relation] with ThreadPoolExecutor(max_workers=2) as pool: first = pool.submit(SlowLock, lock_file) try: assert entered.wait(timeout=5) # The second construction must finish while the first is still inside __init__. with closing(pool.submit(other_type, lock_file).result(timeout=5)) as other: assert type(other) is other_type assert other_type.get_lock(lock_file) is other finally: release.set() with closing(first.result(timeout=5)) as instance: assert type(instance) is SlowLock assert SlowLock.get_lock(lock_file) is instance @pytest.mark.parametrize("is_singleton", [True, False]) def test_subclass_forwards_cooperative_class_keywords(lock_file: str, is_singleton: bool) -> None: class LabelMixin: label: str def __init_subclass__(cls, *, label: str, **kwargs: object) -> None: super().__init_subclass__(**kwargs) cls.label = label class LabeledLock(SoftReadWriteLock, LabelMixin, label="invoice"): pass class ChildLock(LabeledLock, label="child"): pass with ( closing(LabeledLock(lock_file, is_singleton=is_singleton)) as parent, closing(ChildLock(lock_file, is_singleton=is_singleton)) as child, ): assert type(parent) is LabeledLock assert type(child) is ChildLock assert (parent.label, child.label) == ("invoice", "child") def test_subclass_rejects_recursive_singleton_construction_and_can_retry(lock_file: str) -> None: recurse = True class RecursiveLock(SoftReadWriteLock): def __init__(self, *args: Any, **kwargs: Any) -> None: # ruff:ignore[any-type] # forwards the public constructor unchanged if recurse: RecursiveLock(lock_file) super().__init__(*args, **kwargs) with pytest.raises(RuntimeError, match="Singleton lock construction is already active"): RecursiveLock(lock_file) recurse = False with closing(RecursiveLock(lock_file)) as instance: assert RecursiveLock(lock_file) is instance def test_subclass_concurrent_constructors_share_one_singleton(lock_file: str) -> None: callers = threading.Barrier(8) init_calls = 0 class CustomLock(SoftReadWriteLock): def __init__(self, *args: Any, **kwargs: Any) -> None: # ruff:ignore[any-type] # forwards the public constructor unchanged nonlocal init_calls init_calls += 1 super().__init__(*args, **kwargs) def construct() -> SoftReadWriteLock: callers.wait(timeout=5) return CustomLock(lock_file) with ThreadPoolExecutor(max_workers=8) as pool: instances = list(pool.map(lambda _: construct(), range(8))) try: assert init_calls == 1 assert all(instance is instances[0] for instance in instances) finally: for instance in instances: instance.close() def test_non_singleton_returns_distinct_instances(lock_file: str) -> None: first = SoftReadWriteLock(lock_file, is_singleton=False) second = SoftReadWriteLock(lock_file, is_singleton=False) try: assert first is not second finally: first.close() second.close() def test_singleton_mismatch_raises(lock_file: str) -> None: first = SoftReadWriteLock(lock_file, timeout=5) try: with pytest.raises(ValueError, match="cannot be changed"): SoftReadWriteLock(lock_file, timeout=10) finally: first.close() def test_get_lock_returns_singleton(lock_file: str) -> None: first = SoftReadWriteLock.get_lock(lock_file) second = SoftReadWriteLock.get_lock(lock_file) try: assert first is second finally: first.close() def test_leaked_acquired_singleton_is_closed_on_teardown(lock_file: str) -> None: # A live heartbeat thread keeps the singleton reachable, so the autouse teardown finds and closes it. lock = SoftReadWriteLock(lock_file, heartbeat_interval=0.5) lock.acquire_write(timeout=2) assert _state(lock_file).writer is not None def test_reentrant_read_holds_and_releases(lock: SoftReadWriteLock) -> None: with lock.read_lock(timeout=2), lock.read_lock(timeout=2): pass with lock.read_lock(timeout=2): pass def test_reentrant_write_holds_and_releases(lock_file: str, lock: SoftReadWriteLock) -> None: with lock.write_lock(timeout=2), lock.write_lock(timeout=2): assert _state(lock_file).writer is not None assert _state(lock_file).writer is None @pytest.mark.parametrize( ("first_mode", "second_mode", "direction"), [ pytest.param("read", "write", "upgrade", id="upgrade"), pytest.param("write", "read", "downgrade", id="downgrade"), ], ) def test_change_of_mode_while_held_raises( lock: SoftReadWriteLock, first_mode: Literal["read", "write"], second_mode: Literal["read", "write"], direction: str ) -> None: first_lock = lock.read_lock if first_mode == "read" else lock.write_lock second_acquire = lock.acquire_read if second_mode == "read" else lock.acquire_write with first_lock(timeout=2), pytest.raises(RuntimeError, match=f"{direction} not allowed"): second_acquire(timeout=1) def test_write_lock_is_thread_pinned(lock: SoftReadWriteLock) -> None: errors: list[BaseException] = [] lock.acquire_write(timeout=2) def other() -> None: try: lock.acquire_write(timeout=1, blocking=False) except BaseException as exc: errors.append(exc) thread = threading.Thread(target=other) thread.start() thread.join() lock.release() assert len(errors) == 1 assert isinstance(errors[0], (RuntimeError, Timeout)) def test_write_lock_release_from_different_thread_prohibited(lock_file: str, lock: SoftReadWriteLock) -> None: with closing(_make_lock(lock_file)) as contender, ThreadPoolExecutor(max_workers=1) as other_thread: lock.acquire_write(timeout=2) with pytest.raises(RuntimeError, match="while it is held by thread"): other_thread.submit(lock.release).result() with pytest.raises(Timeout): contender.acquire_write(blocking=False) @pytest.mark.parametrize( "drop", [ pytest.param(SoftReadWriteLock.close, id="close"), pytest.param(partial(SoftReadWriteLock.release, force=True), id="force-release"), ], ) def test_write_lock_drop_from_different_thread_needs_an_explicit_override( lock_file: str, lock: SoftReadWriteLock, drop: Callable[[SoftReadWriteLock], None] ) -> None: with closing(_make_lock(lock_file)) as contender, ThreadPoolExecutor(max_workers=1) as other_thread: lock.acquire_write(timeout=2) other_thread.submit(drop, lock).result() with contender.write_lock(blocking=False): pass def test_blocking_acquire_without_timeout_waits_for_release(lock_file: str) -> None: # Without a deadline the poll loop sleeps a full interval each round rather than clamping to a budget. holder = _make_lock(lock_file) holder.acquire_write(timeout=2) acquired = threading.Event() def waiter() -> None: contender = _make_lock(lock_file) try: contender.acquire_read(timeout=-1) acquired.set() contender.release() finally: contender.close() thread = threading.Thread(target=waiter) thread.start() try: assert not acquired.wait(timeout=0.2) holder.release() assert acquired.wait(timeout=_PROCESS_DEADLINE) finally: thread.join(timeout=_PROCESS_DEADLINE) holder.close() def test_release_without_hold_raises(lock_file: str) -> None: lock = SoftReadWriteLock(lock_file) try: with pytest.raises(RuntimeError, match="not held"): lock.release() finally: lock.close() def test_release_force_without_hold_is_noop(lock_file: str) -> None: lock = SoftReadWriteLock(lock_file) try: lock.release(force=True) finally: lock.close() def test_release_force_on_reentrant_lock_drops_all(lock: SoftReadWriteLock) -> None: lock.acquire_read(timeout=2) lock.acquire_read(timeout=2) lock.release(force=True) with lock.write_lock(timeout=2): pass def test_close_is_idempotent(lock_file: str) -> None: lock = SoftReadWriteLock(lock_file) lock.close() lock.close() def test_acquire_on_closed_raises(lock_file: str) -> None: lock = SoftReadWriteLock(lock_file) lock.close() with pytest.raises(RuntimeError, match="has been closed"): lock.acquire_read(timeout=1) with pytest.raises(RuntimeError, match="has been closed"): lock.acquire_write(timeout=1) @pytest.mark.parametrize("blocked", [pytest.param(True, id="while-blocked"), pytest.param(False, id="as-granted")]) def test_close_during_acquire_leaves_no_hold(lock_file: str, mocker: MockerFixture, *, blocked: bool) -> None: # A close() that lands while an acquire polls, or between the grant and publishing the hold, must not leave a # heartbeat holding the slot on an instance nobody can release through any more. blocker = _make_lock(lock_file) if blocked: blocker.acquire_write(timeout=2) victim = _make_lock(lock_file) real_advance = Participant.advance def close_during(participant: Participant) -> bool: granted = real_advance(participant) victim.close() return granted mocker.patch.object(Participant, "advance", autospec=True, side_effect=close_during) try: with pytest.raises(RuntimeError, match="has been closed"): victim.acquire_write(timeout=2) finally: blocker.close() assert victim.generation is None assert _state(lock_file).members == frozenset() assert _holders(lock_file) == [] @SKIP_ON_UNRELIABLE_PROCESS_SYNC @pytest.mark.timeout(_PROCESS_DEADLINE * 5) def test_multiple_readers_can_hold_simultaneously(lock_file: str) -> None: r1, r2, release = Event(), Event(), Event() p1 = Process(target=_worker, args=(lock_file, "read", r1, release)) p2 = Process(target=_worker, args=(lock_file, "read", r2, release)) with cleanup_processes([p1, p2]): p1.start() p2.start() assert r1.wait(timeout=_PROCESS_DEADLINE) assert r2.wait(timeout=_PROCESS_DEADLINE) release.set() p1.join(timeout=_PROCESS_DEADLINE) p2.join(timeout=_PROCESS_DEADLINE) @SKIP_ON_UNRELIABLE_PROCESS_SYNC @pytest.mark.timeout(_PROCESS_DEADLINE * 4) @pytest.mark.parametrize("contender_mode", [pytest.param("write", id="writer"), pytest.param("read", id="reader")]) def test_write_lock_excludes_contender(lock_file: str, contender_mode: Literal["read", "write"]) -> None: held, release = Event(), Event() contender_acquired = Event() writer = Process(target=_worker, args=(lock_file, "write", held, release)) contender = Process(target=_worker, args=(lock_file, contender_mode, contender_acquired, None, 0.3, True)) with cleanup_processes([writer, contender]): writer.start() assert held.wait(timeout=_PROCESS_DEADLINE) contender.start() assert not contender_acquired.wait(timeout=0.5) release.set() writer.join(timeout=_PROCESS_DEADLINE) contender.join(timeout=_PROCESS_DEADLINE) @SKIP_ON_UNRELIABLE_PROCESS_SYNC @pytest.mark.timeout(_PROCESS_DEADLINE * 5) def test_writer_drains_existing_readers(lock_file: str) -> None: r_held, r_release = Event(), Event() w_held = Event() reader = Process(target=_worker, args=(lock_file, "read", r_held, r_release)) writer = Process(target=_worker, args=(lock_file, "write", w_held)) with cleanup_processes([reader, writer]): reader.start() assert r_held.wait(timeout=_PROCESS_DEADLINE) writer.start() assert not w_held.wait(timeout=0.5) r_release.set() reader.join(timeout=_PROCESS_DEADLINE) assert w_held.wait(timeout=_PROCESS_DEADLINE) writer.join(timeout=_PROCESS_DEADLINE) @SKIP_ON_UNRELIABLE_PROCESS_SYNC @pytest.mark.timeout(_PROCESS_DEADLINE * 7) def test_writer_preference_blocks_new_readers(lock_file: str) -> None: r1_held, r1_release = Event(), Event() w_held, w_release = Event(), Event() r2_held = Event() reader1 = Process(target=_worker, args=(lock_file, "read", r1_held, r1_release)) writer = Process(target=_worker, args=(lock_file, "write", w_held, w_release)) reader2 = Process(target=_worker, args=(lock_file, "read", r2_held, None, 10, True)) with cleanup_processes([reader1, writer, reader2]): reader1.start() assert r1_held.wait(timeout=_PROCESS_DEADLINE) writer.start() # Start reader2 only once the writer is named in the latest snapshot: a fixed sleep undershoots on a Windows # runner still spawning the writer's interpreter, and reader2 then slips in ahead of the writer's intent. deadline = time.monotonic() + _PROCESS_DEADLINE while _state(lock_file).writer is None: assert time.monotonic() < deadline time.sleep(0.01) reader2.start() assert not r2_held.wait(timeout=0.5) r1_release.set() assert w_held.wait(timeout=_PROCESS_DEADLINE) assert not r2_held.wait(timeout=0.3) w_release.set() assert r2_held.wait(timeout=_PROCESS_DEADLINE) reader1.join(timeout=_PROCESS_DEADLINE) writer.join(timeout=_PROCESS_DEADLINE) reader2.join(timeout=_PROCESS_DEADLINE) @pytest.mark.timeout(_PROCESS_DEADLINE * 4) def test_transaction_lock_timeout_across_threads(lock_file: str) -> None: # Two threads share one lock instance. Thread A holds the transaction lock while spinning on a peer # writer; thread B times out on the transaction lock, exercising the in-process Timeout path rather # than cross-process contention. # Staleness is not under test: a peer heartbeat that stalls past a short threshold on a loaded runner would let # thread A evict the peer and take the lock, turning thread B's Timeout into a same-instance ownership error. peer = SoftReadWriteLock( lock_file, is_singleton=False, heartbeat_interval=0.1, stale_threshold=_PROCESS_DEADLINE, poll_interval=0.02, ) peer.acquire_write(timeout=2) try: lock = SoftReadWriteLock( lock_file, is_singleton=False, heartbeat_interval=0.1, stale_threshold=_PROCESS_DEADLINE, poll_interval=0.02, ) try: thread_ready = threading.Event() release_thread = threading.Event() def target_a() -> None: thread_ready.set() with suppress(Timeout): lock.acquire_write(timeout=2) release_thread.wait(timeout=_PROCESS_DEADLINE) thread_a = threading.Thread(target=target_a) thread_a.start() try: thread_ready.wait(timeout=_PROCESS_DEADLINE) time.sleep(0.05) with pytest.raises(Timeout): lock.acquire_write(timeout=0.1) finally: release_thread.set() thread_a.join(timeout=_PROCESS_DEADLINE) finally: lock.close() finally: peer.release() peer.close() @pytest.mark.timeout(_PROCESS_DEADLINE * 3) def test_two_readers_in_same_process_share_slot(lock_file: str) -> None: # Many threads take a read lock on one instance; one hits the inner reentrant branch (lock level # above 0 after waiting on the transaction lock). lock = SoftReadWriteLock( lock_file, is_singleton=False, heartbeat_interval=0.1, stale_threshold=0.5, poll_interval=0.02, ) try: barrier = threading.Barrier(8) def target() -> None: barrier.wait(timeout=_PROCESS_DEADLINE) with lock.read_lock(timeout=5): time.sleep(0.05) threads = [threading.Thread(target=target) for _ in range(8)] for thread in threads: thread.start() for thread in threads: thread.join(timeout=_PROCESS_DEADLINE) finally: lock.close() @SKIP_ON_UNRELIABLE_PROCESS_SYNC @pytest.mark.timeout(_PROCESS_DEADLINE * 3) def test_timeout_raises(lock_file: str, lock: SoftReadWriteLock) -> None: held, release = Event(), Event() holder = Process(target=_worker, args=(lock_file, "write", held, release)) with cleanup_processes([holder]): holder.start() assert held.wait(timeout=_PROCESS_DEADLINE) with pytest.raises(Timeout): lock.acquire_write(timeout=0.3) release.set() holder.join(timeout=_PROCESS_DEADLINE) @SKIP_ON_UNRELIABLE_PROCESS_SYNC @pytest.mark.timeout(_PROCESS_DEADLINE * 3) def test_non_blocking_writer_contended_raises(lock_file: str, lock: SoftReadWriteLock) -> None: held, release = Event(), Event() holder = Process(target=_worker, args=(lock_file, "write", held, release)) with cleanup_processes([holder]): holder.start() assert held.wait(timeout=_PROCESS_DEADLINE) with pytest.raises(Timeout): lock.acquire_write(timeout=1, blocking=False) with pytest.raises(Timeout): lock.acquire_read(timeout=1, blocking=False) release.set() holder.join(timeout=_PROCESS_DEADLINE) @SKIP_ON_UNRELIABLE_PROCESS_SYNC @NEEDS_POSIX_SIGNALS @pytest.mark.timeout(_PROCESS_DEADLINE * 3) @pytest.mark.parametrize( ("dead_mode", "contender_mode"), [pytest.param("write", "read", id="dead-writer"), pytest.param("read", "write", id="dead-reader")], ) def test_dead_holder_evicted_by_contender( lock_file: str, lock: SoftReadWriteLock, dead_mode: Literal["read", "write"], contender_mode: Literal["read", "write"], ) -> None: # pragma: needs posix-signals held = Event() holder = Process(target=_sigkill_worker, args=(lock_file, dead_mode, held, 0.1, 0.5)) with cleanup_processes([holder]): holder.start() assert held.wait(timeout=_PROCESS_DEADLINE) pid = holder.pid assert pid is not None os.kill(pid, getattr(signal, "SIGKILL")) # ruff:ignore[get-attr-with-constant] - signal.SIGKILL is POSIX-only holder.join(timeout=_PROCESS_DEADLINE) time.sleep(0.8) contender = lock.read_lock if contender_mode == "read" else lock.write_lock with contender(timeout=5): pass if contender_mode == "read": # A write lock's success already proves no live reader or writer remained; a read lock's does not. assert _state(lock_file).writer is None def test_heartbeat_reports_eviction_and_stops(lock_file: str) -> None: # A peer that waited out the stale threshold commits a snapshot without this holder. The next heartbeat sees the # snapshot no longer naming it, records the loss, and stops refreshing a claim that no longer exists. seen: list[LeaseCompromise] = [] lock = _make_lock(lock_file, heartbeat_interval=0.05, stale_threshold=0.2, on_compromise=seen.append) lock.acquire_write(timeout=2) try: hold = lock._hold assert hold is not None log = GenerationLog(OsFiles(lock_file), lock_file, f"{lock_file}.rw") latest = log.latest() assert log.commit(Snapshot(generation=latest.generation + 1, writer=None, readers=frozenset())) assert hold.heartbeat_stop.wait(timeout=_PROCESS_DEADLINE) assert lock.compromise == LeaseCompromise(lock_file=lock_file, token=hold.participant.token, reason="evicted") assert seen == [lock.compromise] finally: lock.release(force=True) lock.close() peer = _make_lock(lock_file, heartbeat_interval=0.05, stale_threshold=0.2) try: with peer.write_lock(timeout=1): pass finally: peer.close() @pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning") def test_a_raising_on_compromise_still_stops_the_heartbeat(lock_file: str) -> None: def fail(_compromise: LeaseCompromise) -> None: msg = "callback failed" raise RuntimeError(msg) lock = _make_lock(lock_file, heartbeat_interval=0.05, stale_threshold=0.2, on_compromise=fail) lock.acquire_write(timeout=2) try: hold = lock._hold assert hold is not None log = GenerationLog(OsFiles(lock_file), lock_file, f"{lock_file}.rw") assert log.commit(Snapshot(generation=log.latest().generation + 1, writer=None, readers=frozenset())) hold.heartbeat_thread.join(timeout=_PROCESS_DEADLINE) assert not hold.heartbeat_thread.is_alive() assert hold.heartbeat_stop.is_set() finally: lock.release(force=True) lock.close() def test_heartbeat_reports_a_removed_holder_record(lock_file: str) -> None: lock = _make_lock(lock_file, heartbeat_interval=0.05, stale_threshold=0.2) lock.acquire_read(timeout=2) try: hold = lock._hold assert hold is not None Path(f"{lock_file}.rw", "holders", hold.participant.token).unlink() assert hold.heartbeat_stop.wait(timeout=_PROCESS_DEADLINE) assert lock.compromise is not None assert lock.compromise.reason == "evicted" finally: lock.release(force=True) lock.close() def _wait_for_compromise(lock: SoftReadWriteLock) -> LeaseCompromise: deadline = time.monotonic() + 2 while (compromise := lock.compromise) is None and time.monotonic() < deadline: time.sleep(0.01) assert compromise is not None return compromise @pytest.mark.skipif( not (CAPABILITIES["symlink"] and CAPABILITIES["link-dir-fd"]), reason="a swapped tree is refused only where records are reached through descriptors", ) def test_heartbeat_reports_a_symlinked_tree_as_lost( lock_file: str, tmp_path: Path ) -> None: # pragma: needs link-dir-fd seen: list[LeaseCompromise] = [] lock = _make_lock(lock_file, heartbeat_interval=0.05, stale_threshold=0.2, on_compromise=seen.append) lock.acquire_write(timeout=2) try: Path(f"{lock_file}.rw").rename(tmp_path / "aside") Path(f"{lock_file}.rw").symlink_to(tmp_path, target_is_directory=True) assert _wait_for_compromise(lock).reason == "evicted" assert len(seen) == 1 finally: lock.release(force=True) lock.close() @pytest.mark.skipif(sys.platform != "win32", reason="only Windows reaches records by name, through junctions") def test_heartbeat_reports_a_junction_tree_as_lost( # pragma: win32 cover lock_file: str, tmp_path: Path, create_junction: Callable[[Path, Path], None] ) -> None: seen: list[LeaseCompromise] = [] lock = _make_lock(lock_file, heartbeat_interval=0.05, stale_threshold=0.2, on_compromise=seen.append) lock.acquire_write(timeout=2) try: Path(f"{lock_file}.rw").rename(tmp_path / "aside") (target := tmp_path / "target").mkdir() create_junction(Path(f"{lock_file}.rw"), target) assert _wait_for_compromise(lock).reason == "evicted" assert len(seen) == 1 finally: lock.release(force=True) lock.close() def test_a_heartbeat_that_raises_reports_the_hold_lost(lock_file: str, mocker: MockerFixture) -> None: error = ValueError("broken") lock = _make_lock(lock_file, heartbeat_interval=0.05, stale_threshold=0.2) lock.acquire_write(timeout=2) try: mocker.patch.object(Participant, "heartbeat", autospec=True, side_effect=error) compromise = _wait_for_compromise(lock) assert (compromise.reason, compromise.error) == ("refresh-failed", error) finally: mocker.stopall() lock.release(force=True) lock.close() def test_release_leaves_a_peers_claim_alone(lock_file: str) -> None: # A holder paused past the stale threshold (GC pause, SIGSTOP, suspended VM) can be evicted, after which a peer # holds the writer slot. Releasing commits this holder out and leaves the peer's claim alone. lock = _make_lock(lock_file, heartbeat_interval=10, stale_threshold=40) lock.acquire_write(timeout=2) try: peer = _plant_holder(lock_file, mode="write") lock.release() assert _state(lock_file).writer == peer assert _holders(lock_file) == [peer] finally: lock.close() def test_writer_evicted_while_draining_queues_behind_its_evictor( lock_file: str, monkeypatch: pytest.MonkeyPatch ) -> None: # A writer paused past stale_threshold while waiting for readers can be evicted; a peer then names itself writer. # The resumed writer must not finish its acquire as if it still held the slot. Instead it queues again. reader = _make_lock(lock_file, heartbeat_interval=10, stale_threshold=40) reader.acquire_read(timeout=2) writer = _make_lock(lock_file, heartbeat_interval=10, stale_threshold=40) swapped = threading.Event() peer: list[str] = [] def hook(_seconds: float) -> None: if swapped.is_set(): raise Timeout(lock_file) swapped.set() # The peer evicts the draining writer and takes the slot; the reader leaves so only the slot blocks the writer. files = OsFiles(lock_file) log = GenerationLog(files, lock_file, f"{lock_file}.rw") latest = log.latest() peer.append(_plant_holder(lock_file, mode="read")) latest = log.latest() assert log.commit(Snapshot(generation=latest.generation + 1, writer=peer[0], readers=frozenset())) reader.release() monkeypatch.setattr( sync_mod, "time", SimpleNamespace(monotonic=time.monotonic, perf_counter=time.perf_counter, sleep=hook), ) try: with pytest.raises(Timeout): writer.acquire_write(timeout=30) assert swapped.is_set() assert _state(lock_file).writer == peer[0] finally: monkeypatch.setattr(sync_mod, "time", time) writer.close() reader.close() def test_heartbeat_survives_a_transient_refresh_error(lock_file: str, mocker: MockerFixture) -> None: # On the NFS-style filesystems this lock targets, a transient ESTALE/EIO on the heartbeat is routine; it must not # kill the heartbeat and drop the claim while we still believe we hold it. lock = _make_lock(lock_file, heartbeat_interval=0.02, stale_threshold=0.5) lock.acquire_write(timeout=2) try: hold = lock._hold assert hold is not None mocker.patch.object(storage_mod.OsFiles, "overwrite", side_effect=OSError(EIO, "Input/output error")) time.sleep(0.2) # ~10 ticks, every one failing the refresh assert hold.heartbeat_thread.is_alive() assert not hold.heartbeat_stop.is_set() assert lock.compromise is None finally: mocker.stopall() lock.release(force=True) lock.close() def test_heartbeat_reports_refresh_failures_that_outlast_the_margin(lock_file: str, mocker: MockerFixture) -> None: # Failures that run long enough for a peer to evict the record before the next success could land are a loss the # holder must hear about, a margin before the record ages out. seen: list[LeaseCompromise] = [] lock = _make_lock(lock_file, heartbeat_interval=0.02, stale_threshold=0.1, on_compromise=seen.append) lock.acquire_write(timeout=2) try: hold = lock._hold assert hold is not None mocker.patch.object(storage_mod.OsFiles, "overwrite", side_effect=OSError(EIO, "Input/output error")) assert hold.heartbeat_stop.wait(timeout=_PROCESS_DEADLINE) assert [compromise.reason for compromise in seen] == ["refresh-failed"] finally: mocker.stopall() lock.release(force=True) lock.close() @pytest.mark.parametrize("mode", [pytest.param("write", id="write"), pytest.param("read", id="read")]) def test_acquire_hands_back_the_slot_when_the_heartbeat_cannot_start( lock_file: str, lock: SoftReadWriteLock, mocker: MockerFixture, mode: Literal["read", "write"] ) -> None: # A heartbeat thread the OS refuses (an rlimit reached) must not leave a hold behind: a peer would evict the # unrefreshed record and acquire while this instance still believed it held the lock, and release() would raise # joining a thread that never started. mocker.patch.object(sync_mod._HeartbeatThread, "start", side_effect=RuntimeError("can't start new thread")) acquire = lock.acquire_write if mode == "write" else lock.acquire_read with pytest.raises(RuntimeError, match="can't start new thread"): acquire(timeout=2) assert lock._hold is None assert _state(lock_file).members == frozenset() assert _holders(lock_file) == [] lock.release(force=True) # a handed-back slot leaves nothing to release, so this must not raise def test_short_attempts_still_evict_a_dead_holder(lock_file: str) -> None: # How long a peer's record has stayed unchanged is knowledge the lock keeps across attempts: a caller whose every # attempt is shorter than the stale threshold (a retry loop, blocking=False) must still get past a crashed holder. _plant_holder(lock_file, mode="write") lock = _make_lock(lock_file, heartbeat_interval=0.1, stale_threshold=0.3) try: def attempt() -> bool: try: lock.acquire_read(blocking=False) except Timeout: return False return True deadline = time.monotonic() + 5 while not attempt(): assert time.monotonic() < deadline time.sleep(0.02) lock.release() finally: lock.close() @NEEDS_SYMLINK def test_a_symlinked_lock_path_keeps_its_log_beside_the_symlink(tmp_path: Path) -> None: # 4.0.12 kept the log next to the path as spelled, so a fleet that mixes releases must find it there. (alias := tmp_path / "alias.lock").symlink_to(tmp_path / "real.lock") lock = _make_lock(str(alias)) try: with lock.write_lock(timeout=2): assert sorted(entry.name for entry in tmp_path.iterdir()) == ["alias.lock", "alias.lock.rw"] finally: lock.close() @NEEDS_SYMLINK def test_a_symlink_and_its_target_are_separate_singletons(tmp_path: Path) -> None: # One singleton across two logs would let two non-singleton instances on the two spellings both hold write. (alias := tmp_path / "alias.lock").symlink_to(target := tmp_path / "real.lock") via_alias = SoftReadWriteLock(alias) via_target = SoftReadWriteLock(target) try: assert via_alias is not via_target finally: via_alias.close() via_target.close() def test_singleton_rejects_a_different_on_compromise(lock_file: str) -> None: seen: list[LeaseCompromise] = [] first = SoftReadWriteLock(lock_file, on_compromise=seen.append) try: assert SoftReadWriteLock(lock_file) is first assert SoftReadWriteLock(lock_file, on_compromise=seen.append) is first with pytest.raises(ValueError, match="different on_compromise"): SoftReadWriteLock(lock_file, on_compromise=seen.remove) finally: first.close() @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.parametrize("force", [pytest.param(True, id="force"), pytest.param(False, id="plain")]) def test_release_from_on_compromise_leaves_cleanly(lock_file: str, force: bool, mode: Literal["read", "write"]) -> None: # The callback runs on the heartbeat thread; a release there has no thread to join and must still leave. lock = SoftReadWriteLock( lock_file, is_singleton=False, heartbeat_interval=0.05, stale_threshold=0.2, poll_interval=0.02, on_compromise=lambda _compromise: lock.release(force=force), ) (lock.acquire_read if mode == "read" else lock.acquire_write)(timeout=2) hold = lock._hold assert hold is not None try: log = GenerationLog(OsFiles(lock_file), lock_file, f"{lock_file}.rw") latest = log.latest() assert log.commit(Snapshot(generation=latest.generation + 1, writer=None, readers=frozenset())) assert hold.heartbeat_stop.wait(timeout=_PROCESS_DEADLINE) hold.heartbeat_thread.join(timeout=_PROCESS_DEADLINE) assert lock._hold is None assert _holders(lock_file) == [] finally: lock.close() def test_lock_file_in_a_missing_directory_is_created(tmp_path: Path) -> None: lock = _make_lock(str(tmp_path / "nested" / "deeper" / "x.lock")) try: with lock.write_lock(timeout=1): pass finally: lock.close() def test_late_heartbeat_tick_does_not_stamp_a_later_hold(lock_file: str) -> None: # A tick that outlived its release's join reports on the hold it served, not on a hold acquired since. lock = _make_lock(lock_file, heartbeat_interval=10, stale_threshold=40) lock.acquire_write(timeout=2) old = lock._hold assert old is not None lock.release() lock.acquire_write(timeout=2) try: lock._report_compromise(old, "evicted", None) assert lock.compromise is None finally: lock.release() lock.close() @SKIP_ON_UNRELIABLE_PROCESS_SYNC @pytest.mark.timeout(_PROCESS_DEADLINE * 3) def test_live_heartbeat_keeps_lock_alive_past_stale_threshold(lock_file: str) -> None: # Generous timing here so the test stays stable on slow Windows runners where the holder's # multiprocessing.spawn startup, the heartbeat thread scheduling, and the parent's clock resolution # can all introduce sub-second jitter. heartbeat, stale = 0.3, 1.5 held, release = Event(), Event() holder = Process( target=_worker, args=(lock_file, "write", held, release, -1, True, heartbeat, stale, 0.05), ) with cleanup_processes([holder]): holder.start() assert held.wait(timeout=_PROCESS_DEADLINE) time.sleep(stale * 2) lock = _make_lock(lock_file, heartbeat_interval=heartbeat, stale_threshold=stale) try: with pytest.raises(Timeout): lock.acquire_write(timeout=stale * 2) finally: lock.close() release.set() holder.join(timeout=_PROCESS_DEADLINE) def test_generation_is_a_fencing_token(lock_file: str) -> None: first = _make_lock(lock_file) second = _make_lock(lock_file) try: assert first.generation is None with first.write_lock(timeout=2): granted = first.generation assert granted is not None with second.write_lock(timeout=2): later = second.generation assert later is not None assert later > granted assert second.generation is None finally: first.close() second.close() def test_foreign_host_holder_is_evicted_after_the_stale_threshold(lock_file: str) -> None: # The report behind #725: a pod dies on another host holding the lock, and a replacement pod under a different # hostname must still get in. Liveness is a nonce that stopped changing, so the hostname plays no part in it. _plant_holder(lock_file, mode="write") lock = _make_lock(lock_file, heartbeat_interval=0.1, stale_threshold=0.3) try: started = time.monotonic() with pytest.raises(Timeout): lock.acquire_read(timeout=0.1) # The first attempt already started watching the record, so the wait spans both attempts. with lock.read_lock(timeout=5): assert time.monotonic() - started >= 0.3 - 0.05 assert _holders(lock_file) == [] finally: lock.close() def test_release_never_blocks_on_an_abandoned_claim(lock_file: str) -> None: # The other half of #725: a writer holding the lock could not let go while a foreign host's record sat in the way. # Leaving is one commit of the holder's own token, so nothing a dead peer left can block it. lock = _make_lock(lock_file, heartbeat_interval=10, stale_threshold=40) lock.acquire_write(timeout=2) _plant_holder(lock_file, mode="read") started = time.monotonic() lock.release() assert time.monotonic() - started < 1 assert _state(lock_file).writer is None lock.close() def test_malformed_generation_record_fails_closed(lock_file: str, lock: SoftReadWriteLock) -> None: with lock.write_lock(timeout=2): pass latest = _generations(lock_file)[-1] Path(f"{lock_file}.rw", "gen", f"{int(latest) + 1:020d}").write_bytes(b"garbage\n") with pytest.raises(SoftFileLockProtocolError, match="malformed generation record"): lock.acquire_write(timeout=1) def test_removing_the_log_recovers_without_admitting_a_second_writer(lock_file: str, lock: SoftReadWriteLock) -> None: # Removing .rw is the documented recovery from a malformed record; an instance that remembers the old log must # wait behind whoever acquires the restarted one. with lock.write_lock(timeout=2): pass _remove_log(lock_file) peer = _make_lock(lock_file) try: with peer.write_lock(timeout=2): assert peer.generation == 1 with pytest.raises(Timeout): lock.acquire_write(blocking=False) with lock.write_lock(timeout=2): assert lock.generation == 3 finally: peer.close() def test_a_contender_waiting_when_the_log_is_removed_starts_over( lock_file: str, lock: SoftReadWriteLock, mocker: MockerFixture ) -> None: lock.acquire_write(timeout=2) real_advance = Participant.advance removed: list[str] = [] def remove_once_blocked(participant: Participant) -> bool: if not (granted := real_advance(participant)) and not removed: _remove_log(lock_file) removed.append(participant.token) return granted mocker.patch.object(Participant, "advance", autospec=True, side_effect=remove_once_blocked) waiter = _make_lock(lock_file) try: waiter.acquire_write(timeout=_PROCESS_DEADLINE) assert removed assert waiter.generation == 1 finally: waiter.close() def test_malformed_holder_record_is_evicted(lock_file: str) -> None: # A record that cannot be parsed still identifies nobody who refreshes it, so it ages out like any other. token = _plant_holder(lock_file, mode="write") Path(f"{lock_file}.rw", "holders", token).write_bytes(b"\x00garbage") lock = _make_lock(lock_file, heartbeat_interval=0.1, stale_threshold=0.3) try: with lock.write_lock(timeout=5): pass finally: lock.close() def test_temporary_commit_files_are_swept(lock_file: str) -> None: orphan = Path(f"{lock_file}.rw", "gen", ".commit-abandoned") lock = _make_lock(lock_file, heartbeat_interval=0.05, stale_threshold=0.2) try: with lock.write_lock(timeout=2): pass orphan.write_bytes(b"left by a crash between create and link") with lock.write_lock(timeout=2): time.sleep(0.6) assert not orphan.exists() finally: lock.close() @pytest.mark.usefixtures("flushes") def test_generations_are_compacted(lock_file: str, lock: SoftReadWriteLock) -> None: for _ in range(80): with lock.write_lock(timeout=2): pass assert len(_generations(lock_file)) <= 66 @pytest.mark.usefixtures("flushes") def test_a_participant_behind_a_compacted_generation_rescans(lock_file: str) -> None: lock = _make_lock(lock_file) peer = _make_lock(lock_file) try: with lock.write_lock(timeout=2): pass stale_log = GenerationLog(OsFiles(lock_file), lock_file, f"{lock_file}.rw") remembered = stale_log.latest() for _ in range(40): with peer.write_lock(timeout=2): pass assert not Path(f"{lock_file}.rw", "gen", f"{remembered.generation:020d}").exists() assert stale_log.latest().generation == _state(lock_file).generation finally: lock.close() peer.close() @pytest.mark.skipif(not CAPABILITIES["symlink"], reason="staging the protocol directory as a symlink") def test_symlinked_protocol_directory_is_refused( lock_file: str, tmp_path: Path, lock: SoftReadWriteLock ) -> None: # pragma: needs symlink victim_dir = tmp_path / "victim_dir" victim_dir.mkdir() Path(f"{lock_file}.rw").symlink_to(victim_dir) with pytest.raises(RuntimeError, match="not a directory or is a symlink"): lock.acquire_read(timeout=0.5) assert list(victim_dir.iterdir()) == [] @pytest.mark.skipif( not (CAPABILITIES["symlink"] and CAPABILITIES["link-dir-fd"]), reason="swapping in a symlinked directory is refused only where records are reached through descriptors", ) @pytest.mark.parametrize("swapped", ["gen", "holders"]) def test_protocol_tree_swapped_after_an_acquire_is_refused( # pragma: needs link-dir-fd lock_file: str, tmp_path: Path, lock: SoftReadWriteLock, swapped: str ) -> None: with lock.write_lock(timeout=2): pass # A process that can write the lock's directory can rename the checked tree aside and put one in its place. (root := Path(f"{lock_file}.rw")).rename(tmp_path / "aside") for name in ("gen", "holders"): (root / name).mkdir(parents=True) (root / swapped).rmdir() (root / swapped).symlink_to(target := tmp_path / "target", target_is_directory=True) target.mkdir() with pytest.raises(RuntimeError, match="is not a directory or is a symlink"): lock.acquire_write(timeout=0.5) assert list(target.iterdir()) == [] @pytest.mark.skipif(sys.platform != "win32", reason="only Windows reaches records by name, through junctions") @pytest.mark.parametrize("when", ["before-first-acquire", "after-first-acquire"]) @pytest.mark.parametrize("swapped", ["gen", "holders"]) def test_protocol_tree_with_a_junction_is_refused( # pragma: win32 cover lock_file: str, tmp_path: Path, lock: SoftReadWriteLock, create_junction: Callable[[Path, Path], None], when: str, swapped: str, ) -> None: root = Path(f"{lock_file}.rw") if when == "after-first-acquire": with lock.write_lock(timeout=2): pass root.rename(tmp_path / "aside") for name in ("gen", "holders"): if name != swapped: (root / name).mkdir(parents=True) (target := tmp_path / "target").mkdir() create_junction(root / swapped, target) with pytest.raises(RuntimeError, match="is not a directory or is a symlink"): lock.acquire_write(timeout=0.5) assert list(target.iterdir()) == [] @pytest.mark.skipif(sys.platform != "win32", reason="only Windows reaches records by name") def test_protocol_tree_stays_in_place_while_a_record_is_written( # pragma: win32 cover lock_file: str, tmp_path: Path, lock: SoftReadWriteLock, mocker: MockerFixture ) -> None: real_write: Final = storage_mod.write_all refused: list[bool] = [] def try_to_move_the_tree_aside(fd: int, data: bytes) -> None: try: Path(f"{lock_file}.rw").rename(tmp_path / "aside") except PermissionError: refused.append(True) real_write(fd, data) mocker.patch.object(storage_mod, "write_all", side_effect=try_to_move_the_tree_aside) with lock.write_lock(timeout=2): pass assert (bool(refused), (tmp_path / "aside").exists()) == (True, False) @NEEDS_FOREIGN_STICKY_TMP @pytest.mark.usefixtures("directories_of_another_account") def test_protocol_directory_of_another_account_is_refused( # pragma: needs foreign-sticky-tmp lock_path_in_shared_tmp: Path, ) -> None: (root := Path(f"{lock_path_in_shared_tmp}.rw")).mkdir() lock = SoftReadWriteLock(lock_path_in_shared_tmp, is_singleton=False) try: with pytest.raises(RuntimeError, match="not to this process's account"): lock.acquire_write(timeout=0) finally: lock.close() assert list(root.iterdir()) == [] @NEEDS_FOREIGN_STICKY_TMP def test_live_lock_refuses_a_tree_another_account_planted_after_a_recovery( # pragma: needs foreign-sticky-tmp lock_path_in_shared_tmp: Path, mocker: MockerFixture ) -> None: lock = SoftReadWriteLock(lock_path_in_shared_tmp, is_singleton=False) try: with lock.write_lock(timeout=2): pass (root := Path(f"{lock_path_in_shared_tmp}.rw")).rename(aside := Path(f"{lock_path_in_shared_tmp}.rw.aside")) try: for name in ("gen", "holders"): (root / name).mkdir(parents=True) assert sys.platform != "win32" # the capability is absent there; narrows os.geteuid for the type checker mocker.patch("filelock._util.os.geteuid", autospec=True, return_value=os.geteuid() + 1) with pytest.raises(RuntimeError, match="not to this process's account"): lock.acquire_write(timeout=0.5) assert list((root / "holders").iterdir()) == [] finally: shutil.rmtree(aside, ignore_errors=True) finally: lock.close() def test_protocol_path_as_regular_file_is_refused(lock_file: str, lock: SoftReadWriteLock) -> None: Path(f"{lock_file}.rw").write_bytes(b"x") with pytest.raises(RuntimeError, match="not a directory or is a symlink"): lock.acquire_read(timeout=0.5) @NEEDS_FILE_MODE def test_records_are_owner_only(lock_file: str, lock: SoftReadWriteLock) -> None: # pragma: needs file-mode with lock.write_lock(timeout=2): root = Path(f"{lock_file}.rw") for directory in (root, root / "gen", root / "holders"): assert stat.S_IMODE(directory.lstat().st_mode) == 0o700 generation = root / "gen" / _generations(lock_file)[-1] assert stat.S_IMODE(generation.lstat().st_mode) == 0o600 (holder,) = _holders(lock_file) assert stat.S_IMODE((root / "holders" / holder).lstat().st_mode) == 0o600 def test_stray_files_in_the_protocol_directories_are_ignored(lock_file: str, lock: SoftReadWriteLock) -> None: root = Path(f"{lock_file}.rw") (root / "gen").mkdir(parents=True) (root / "holders").mkdir() (root / "gen" / ".hidden").write_bytes(b"ignored") (root / "gen" / "not-a-generation").write_bytes(b"ignored") (root / "holders" / "not-a-token").write_bytes(b"ignored") with lock.write_lock(timeout=2): pass @SKIP_ON_UNRELIABLE_PROCESS_SYNC @NEEDS_FORK @pytest.mark.timeout(_PROCESS_DEADLINE * 2) @pytest.mark.parametrize( ("scenario", "names"), [ pytest.param("reuse-raises", ("foo.lock",), id="reuse-raises"), pytest.param("release-is-silent", ("foo.lock",), id="release-is-silent"), pytest.param("fresh-lock-after-fork", ("parent.lock", "child.lock"), id="fresh-lock-after-fork"), ], ) def test_child_fork_lock_behavior(tmp_path: Path, scenario: str, names: tuple[str, ...]) -> None: # pragma: needs fork # Resolved by name, not passed as the parametrize value: the targets are defined later in this module, and a # decorator evaluates its arguments at import time, before those definitions exist. target = { "reuse-raises": _reuse_inherited_lock, "release-is-silent": _release_inherited_lock, "fresh-lock-after-fork": _reacquire_fresh_lock_in_child, }[scenario] ctx = mp.get_context("spawn") result, failure = ctx.Event(), ctx.Event() proc = ctx.Process(target=target, args=(*(str(tmp_path / name) for name in names), result, failure)) with cleanup_processes([proc]): proc.start() proc.join(timeout=_PROCESS_DEADLINE) assert not failure.is_set() assert result.is_set() @NEEDS_FORK @pytest.mark.timeout(_PROCESS_DEADLINE * 2) # Holding the write lock keeps the heartbeat thread alive, so this fork is necessarily from a multi-threaded # process and Python 3.15 warns that it may deadlock. That is the scenario under test, and it is already safe: # register_at_fork resets inherited state in the child (any child use raises "invalidated by fork()"). Expected. @SKIP_ON_UNRELIABLE_PROCESS_SYNC @pytest.mark.filterwarnings("ignore:.*multi-threaded, use of fork.*:DeprecationWarning") def test_parent_retains_lock_across_fork(tmp_path: Path) -> None: # pragma: needs fork path = str(tmp_path / "foo.lock") lock = SoftReadWriteLock(path, heartbeat_interval=0.2, stale_threshold=1.0, poll_interval=0.02) lock.acquire_write(timeout=5) try: child = _fork_process(target=time.sleep, args=(0.05,)) child.start() child.join(timeout=_PROCESS_DEADLINE) assert _state(path).writer is not None peer = SoftReadWriteLock( path, heartbeat_interval=0.2, stale_threshold=1.0, poll_interval=0.02, is_singleton=False, ) try: with pytest.raises(Timeout): peer.acquire_write(timeout=0.3) finally: peer.close() finally: lock.release() lock.close() assert _state(path).writer is None def _make_lock( path: str, *, heartbeat_interval: float = 0.1, stale_threshold: float = 0.5, poll_interval: float = 0.02, is_singleton: bool = False, on_compromise: Callable[[LeaseCompromise], None] | None = None, ) -> SoftReadWriteLock: return SoftReadWriteLock( path, heartbeat_interval=heartbeat_interval, stale_threshold=stale_threshold, poll_interval=poll_interval, is_singleton=is_singleton, on_compromise=on_compromise, ) def _worker( lock_file: str, mode: Literal["read", "write"], acquired_event: EventType, release_event: EventType | None = None, timeout: float = -1, blocking: bool = True, heartbeat_interval: float = 0.1, stale_threshold: float = 1.0, poll_interval: float = 0.02, ) -> None: lock = SoftReadWriteLock( lock_file, timeout=timeout, blocking=blocking, is_singleton=False, heartbeat_interval=heartbeat_interval, stale_threshold=stale_threshold, poll_interval=poll_interval, ) try: with lock.read_lock() if mode == "read" else lock.write_lock(): acquired_event.set() if release_event is not None: release_event.wait(timeout=_PROCESS_DEADLINE) else: time.sleep(0.2) finally: lock.close() def _sigkill_worker( # pragma: forked child lock_file: str, mode: Literal["read", "write"], acquired_event: EventType, heartbeat_interval: float, stale_threshold: float, ) -> None: lock = SoftReadWriteLock( lock_file, is_singleton=False, heartbeat_interval=heartbeat_interval, stale_threshold=stale_threshold, poll_interval=0.05, ) if mode == "read": lock.acquire_read() else: lock.acquire_write() acquired_event.set() time.sleep(60) def _reuse_inherited_lock(lock_file: str, result: EventType, failure: EventType) -> None: # pragma: needs fork lock = SoftReadWriteLock(lock_file, heartbeat_interval=0.2, stale_threshold=1.0, poll_interval=0.02) lock.acquire_write(timeout=5) ok = _fork_event() def child_entry() -> None: try: lock.acquire_read(timeout=1) except RuntimeError as exc: if "invalidated by fork" in str(exc): # pragma: no branch # the inherited lock raises nothing else ok.set() child = _fork_process(target=child_entry) child.start() child.join(timeout=_PROCESS_DEADLINE) if ok.is_set(): result.set() else: # pragma: no cover # reached only if the child fails to report the inherited lock, i.e. a regression failure.set() lock.release() lock.close() def _release_inherited_lock(lock_file: str, result: EventType, failure: EventType) -> None: # pragma: needs fork lock = SoftReadWriteLock(lock_file, heartbeat_interval=0.2, stale_threshold=1.0, poll_interval=0.02) lock.acquire_read(timeout=5) ok = _fork_event() def child_entry() -> None: try: lock.release() except RuntimeError: # pragma: no cover # release on a fork-inherited lock is silent; guards a regression return ok.set() child = _fork_process(target=child_entry) child.start() child.join(timeout=_PROCESS_DEADLINE) if ok.is_set(): result.set() else: # pragma: no cover # reached only if the child's silent release regresses into raising failure.set() lock.release() lock.close() def _reacquire_fresh_lock_in_child( # pragma: needs fork lock_file: str, child_path: str, result: EventType, failure: EventType ) -> None: parent_lock = SoftReadWriteLock(lock_file, heartbeat_interval=0.2, stale_threshold=1.0, poll_interval=0.02) parent_lock.acquire_write(timeout=5) ok = _fork_event() def child_entry() -> None: child_lock = SoftReadWriteLock( child_path, is_singleton=False, heartbeat_interval=0.2, stale_threshold=1.0, poll_interval=0.02, ) try: with child_lock.read_lock(timeout=2): ok.set() finally: child_lock.close() child = _fork_process(target=child_entry) child.start() child.join(timeout=_PROCESS_DEADLINE) if ok.is_set(): result.set() else: # pragma: no cover # reached only if the child cannot acquire a fresh lock after the fork, i.e. a regression failure.set() parent_lock.release() parent_lock.close() def _fork_process( # pragma: needs fork target: Callable[..., object], args: tuple[object, ...] = () ) -> mp.process.BaseProcess: if sys.platform == "win32": # pragma: win32 cover msg = "fork context is POSIX only" raise RuntimeError(msg) return mp.get_context("fork").Process(target=target, args=args) def _fork_event() -> EventType: # pragma: needs fork if sys.platform == "win32": # pragma: win32 cover msg = "fork context is POSIX only" raise RuntimeError(msg) return mp.get_context("fork").Event() @SKIP_ON_UNRELIABLE_PROCESS_SYNC def test_cleanup_terminates_a_still_running_process() -> None: proc = Process(target=time.sleep, args=(30,)) proc.start() started = time.monotonic() with cleanup_processes([proc]): assert proc.is_alive() # A helper that only joined would sit here for the child's whole sleep instead of signaling it first. assert time.monotonic() - started < 10 @SKIP_ON_UNRELIABLE_PROCESS_SYNC def test_cleanup_closes_the_process() -> None: proc = Process(target=time.sleep, args=(30,)) proc.start() with cleanup_processes([proc]): assert proc.is_alive() with pytest.raises(ValueError, match="process object is closed"): proc.is_alive() def test_record_zero_write_rolls_back(lock_file: str, mocker: MockerFixture) -> None: mocker.patch("filelock._util.os.write", return_value=0) lock = SoftReadWriteLock(lock_file, is_singleton=False) with pytest.raises(OSError, match="0 bytes"): lock.acquire_write(timeout=1) assert _holders(lock_file) == [] @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.parametrize( ("timeout", "blocking"), [ pytest.param(0.02, True, id="deadline"), pytest.param(0, True, id="zero-timeout"), pytest.param(-1, False, id="nonblocking-unbounded"), pytest.param(10, False, id="nonblocking-finite"), ], ) def test_contention_obeys_acquisition_policy( lock_file: str, mocker: MockerFixture, mode: Literal["read", "write"], timeout: float, *, blocking: bool ) -> None: _plant_holder(lock_file, mode="write") real_sleep: Final = time.sleep def sleep(seconds: float) -> None: assert blocking assert 0 < seconds <= timeout real_sleep(seconds) mocker.patch( "filelock._soft_rw._sync.time", new=SimpleNamespace( monotonic=time.monotonic, perf_counter=time.perf_counter, sleep=mocker.create_autospec(time.sleep, side_effect=sleep), ), ) with closing(SoftReadWriteLock(lock_file, timeout=timeout, blocking=blocking, poll_interval=10)) as lock: acquire: Final = lock.acquire_read if mode == "read" else lock.acquire_write with pytest.raises(Timeout) as caught: acquire() assert caught.value.lock_file == lock_file assert _state(lock_file).readers == frozenset() @pytest.mark.asyncio @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) async def test_async_contention_reports_public_path(lock_file: str, mode: Literal["read", "write"]) -> None: _plant_holder(lock_file, mode="write") lock: Final = AsyncSoftReadWriteLock(lock_file, timeout=0.02) try: acquire: Final = lock.acquire_read if mode == "read" else lock.acquire_write with pytest.raises(Timeout) as caught: await acquire() assert caught.value.lock_file == lock_file finally: await lock.close() def test_writer_timeout_commits_itself_out(lock_file: str) -> None: # A writer that gives up while draining must not stay named: it would block every reader until a peer waited out # the stale threshold. Its own token is all it removes, both from the snapshot and from the holder registry, so # the reader it waited on keeps its hold. reader = _make_lock(lock_file, heartbeat_interval=10, stale_threshold=40) writer = _make_lock(lock_file, heartbeat_interval=10, stale_threshold=40, poll_interval=0.01) try: reader.acquire_read() with pytest.raises(Timeout): writer.acquire_write(timeout=0.05) state = _state(lock_file) assert state.writer is None assert len(state.readers) == 1 assert len(_holders(lock_file)) == 1 finally: writer.close() reader.close() def test_soft_read_on_a_lost_hold_raises(lost_lock: SoftReadWriteLock, lost: threading.Event) -> None: lost_lock.acquire_read(timeout=2) _evict_every_holder(lost_lock.lock_file) assert lost.wait(_PROCESS_DEADLINE) with ThreadPoolExecutor(max_workers=1) as other_thread, pytest.raises(RuntimeError, match="hold was lost"): other_thread.submit(lost_lock.acquire_read, 1).result(_PROCESS_DEADLINE) lost_lock.release() def test_soft_read_after_the_lost_hold_drains_asks_the_log(lost_lock: SoftReadWriteLock, lost: threading.Event) -> None: lost_lock.acquire_read(timeout=2) _evict_every_holder(lost_lock.lock_file) assert lost.wait(_PROCESS_DEADLINE) lost_lock.release() with closing(_make_lock(lost_lock.lock_file, heartbeat_interval=0.05, stale_threshold=0.2)) as writer: writer.acquire_write(timeout=2) with pytest.raises(Timeout): lost_lock.acquire_read(blocking=False) @pytest.fixture def lost() -> threading.Event: return threading.Event() @pytest.fixture def lost_lock(lock_file: str, lost: threading.Event) -> Generator[SoftReadWriteLock]: instance = _make_lock( lock_file, heartbeat_interval=0.05, stale_threshold=0.2, on_compromise=lambda _compromise: lost.set() ) try: yield instance finally: instance.close() def _evict_every_holder(lock_file: str) -> None: log = GenerationLog(OsFiles(lock_file), lock_file, f"{lock_file}.rw") assert log.commit(Snapshot(generation=log.latest().generation + 1, writer=None, readers=frozenset())) def test_soft_read_release_from_a_thread_without_a_read_level_raises( lock: SoftReadWriteLock, other_thread: ThreadPoolExecutor ) -> None: lock.acquire_read(timeout=2) with pytest.raises(RuntimeError, match="which does not hold it"): other_thread.submit(lock.release).result(_PROCESS_DEADLINE) assert not _writer_gets_in(lock.lock_file) lock.release() def test_soft_read_release_of_a_dropped_hold_leaves_a_newer_one_held( lock: SoftReadWriteLock, other_thread: ThreadPoolExecutor ) -> None: lock.acquire_read(timeout=2) lock.release(force=True) other_thread.submit(lock.acquire_read, 2).result(_PROCESS_DEADLINE) with pytest.raises(RuntimeError, match="which does not hold it"): lock.release() assert not _writer_gets_in(lock.lock_file) other_thread.submit(lock.release).result(_PROCESS_DEADLINE) def test_soft_read_release_gives_back_only_the_callers_levels( lock: SoftReadWriteLock, other_thread: ThreadPoolExecutor ) -> None: lock.acquire_read(timeout=2) other_thread.submit(lock.acquire_read, 2).result(_PROCESS_DEADLINE) lock.release() with pytest.raises(RuntimeError, match="which does not hold it"): lock.release() assert not _writer_gets_in(lock.lock_file) other_thread.submit(lock.release).result(_PROCESS_DEADLINE) assert _writer_gets_in(lock.lock_file) @pytest.fixture def other_thread() -> Generator[ThreadPoolExecutor]: with ThreadPoolExecutor(max_workers=1) as executor: yield executor def _writer_gets_in(lock_file: str) -> bool: with closing(_make_lock(lock_file)) as contender: try: contender.acquire_write(blocking=False) except Timeout: return False return True filelock-4.2.8/.gitignore0000644000000000000000000000031615272561277012251 0ustar00*.egg-info build dist *.egg .eggs *.py[codz] *$py.class .tox .*_cache .DS_Store .idea .vscode /pip-wheel-metadata /src/filelock/version.py venv* .python-version test.lock .coverage.* test.softlock /uv.lock filelock-4.2.8/LICENSE0000644000000000000000000000210015272561277011257 0ustar00MIT License Copyright (c) 2025 Bernát Gábor and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. filelock-4.2.8/pyproject.toml0000644000000000000000000002546715272561277013213 0ustar00[build-system] build-backend = "hatchling.build" requires = [ "hatch-fancy-pypi-readme>=25.1", "hatch-vcs>=0.5", "hatchling>=1.29", ] [project] name = "filelock" import-names = [ "filelock" ] description = "A platform independent file lock." keywords = [ "file", "lock", "synchronization", ] license = "MIT" maintainers = [ { name = "Bernát Gábor", email = "gaborjbernat@gmail.com" }, ] requires-python = ">=3.11" classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.15", "Topic :: Software Development :: Libraries", "Topic :: System", ] dynamic = [ "readme", "version", ] urls.Changelog = "https://py-filelock.readthedocs.io/en/latest/changelog.html" urls.Documentation = "https://py-filelock.readthedocs.io" urls.Homepage = "https://github.com/tox-dev/filelock" urls.Source = "https://github.com/tox-dev/filelock" urls.Tracker = "https://github.com/tox-dev/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>=10.2", "pytest>=9.0.3", "pytest-asyncio>=1.3", "pytest-cov>=7.1", "pytest-mock>=3.15.1", "pytest-randomly>=3.16", "pytest-timeout>=2.4", "virtualenv>=21.2", ] type = [ "ty>=0.0.30", { include-group = "docs" }, { include-group = "test" }, ] docs = [ "furo>=2025.12.19", "sphinx>=9.1", "sphinx-autodoc-typehints>=3.9.11", "sphinx-copybutton>=0.5.2", "sphinx-design>=0.7", "sphinx-llm>=0.4.1,!=1.1", "sphinx-sitemap>=2.9", "sphinxcontrib-mermaid>=2.0.1", "sphinxcontrib-towncrier>=0.5.0a0", "sphinxext-opengraph>=0.13", "towncrier>=24.8", ] build = [ "cython>=3", "hatch-fancy-pypi-readme>=25.1", "hatch-vcs>=0.5", "hatchling>=1.29", "setuptools>=80", ] coverage = [ "covdefaults>=2.3", "coverage[toml]>=7.13.5", "diff-cover>=10.2", ] fix = [ "pre-commit-uv>=4.2.1", ] pkg-meta = [ "check-wheel-contents>=0.6.3", "twine>=6.2", "uv>=0.11.5", ] release = [ "prek>=0.5", "release-creator>=4.4", "towncrier>=26.9", ] release-news = [ "release-creator>=4.4", "towncrier>=26.9", ] release-notes = [ "release-creator>=4.4", ] release-prepare = [ "prek>=0.5", "release-creator>=4.4", "towncrier>=26.9", ] [tool.hatch] version.source = "vcs" metadata.hooks.fancy-pypi-readme.content-type = "text/markdown" metadata.hooks.fancy-pypi-readme.fragments = [ { path = "README.md" } ] build.hooks.vcs.version-file = "src/filelock/version.py" build.targets.sdist.include = [ "/docs", "/src", "/tasks", "/tests", "/tox.toml", ] [tool.uv] dependency-groups.dev = { requires-python = ">=3.12" } dependency-groups.docs = { requires-python = ">=3.12" } dependency-groups.release = { requires-python = ">=3.15" } dependency-groups.release-news = { requires-python = ">=3.15" } dependency-groups.release-notes = { requires-python = ">=3.15" } dependency-groups.release-prepare = { requires-python = ">=3.15" } dependency-groups.type = { requires-python = ">=3.12" } [tool.ruff] line-length = 120 format.preview = true format.docstring-code-line-length = 100 format.docstring-code-format = true lint.select = [ "ALL", ] lint.ignore = [ "async-function-with-timeout", # lock acquisition timeouts support polling; cancellation would change the public API "CPY", # No copyright statements "DOC", # no support yet "escape-sequence-in-docstring", # Use `r"""` if any backslashes in a docstring "hardcoded-bind-all-interfaces", # Possible binding to all interface "incorrect-blank-line-before-class", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible "missing-blank-line-after-summary", # 1 blank line required between summary line and description "missing-trailing-comma", # Conflict with formatter "multi-line-summary-first-line", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible "non-empty-init-module", # `__init__` module should only contain docstrings and re-exports "non-imperative-mood", # First line of docstring should be in imperative mood "single-line-implicit-string-concatenation", # Conflict with formatter ] lint.per-file-ignores."tasks/**/*.py" = [ "assert", # asserts state the platform a Windows-only script already refuses to run outside of, narrowing it for ty "D", # don't care about documentation in tasks scripts "implicit-namespace-package", # no implicit namespace "print", # print allowed in scripts "subprocess-without-shell-equals-true", # `subprocess` call: check for execution of untrusted input ] lint.per-file-ignores."tests/**/*.py" = [ "assert", # asserts allowed in tests "blind-except", # blind exception catching acceptable in test helpers "D", # don't care about documentation in tests "FBT", # don't care about booleans as positional arguments in tests "implicit-namespace-package", # no implicit namespace "import-outside-top-level", # import should be at top-level "import-private-name", # private name import from external module "magic-value-comparison", # Magic value used in comparison, consider replacing with a constant variable "non-lowercase-variable-in-function", # variable in function should be lowercase "private-member-access", # accessing private members acceptable in tests "pytest-fixture-autouse", # shared setup fixtures are meant to apply to every test "subprocess-without-shell-equals-true", # `subprocess` call: check for execution of untrusted input "too-many-arguments", # too many arguments in function definition "too-many-positional-arguments", # too many positional arguments "too-many-statements-in-try-clause", # try/finally cleanup blocks in tests legitimately hold many statements ] lint.isort = { known-first-party = [ "filelock", ], required-imports = [ "from __future__ import annotations", ] } lint.preview = true [tool.codespell] builtin = "clear,usage,en-GB_to_en-US" ignore-words-list = "master" count = true quiet-level = 3 [tool.pyproject-fmt] max_supported_python = "3.15" [tool.ty] src.exclude = [] # Mirrors pytest's pythonpath so the tests' `coverage_pragmas` import resolves here too. environment.extra-paths = [ ".", "tasks", ] environment.python-version = "3.15" [tool.pytest] ini_options.testpaths = [ "tests", ] # The project root lets the tests import each other as tests.*; tasks holds coverage_pragmas, whose CAPABILITIES probes # drive both the coverage exclusions and the skipif gates, so a test can never be skipped while coverage still demands # its lines. coverage imports that plugin by name in the subprocesses it patches, so it stays a top-level module. ini_options.pythonpath = [ ".", "tasks", ] ini_options.markers = [ "requires_hard_links: exercises StrictSoftFileLock, which needs os.link; skipped where it is missing (Termux/Android)", ] ini_options.filterwarnings = [ "error", # CPython's resource tracker refuses to unregister a semaphore while it is already cleaning up, and warns instead. # multiprocessing.Event and Value expose no close, so only the collector frees them, and it picks the moment. Seen # on 3.12 alone. Filtering the tracker's own warning keeps the finalizer from raising rather than hiding the raise. "ignore:ResourceTracker called reentrantly for resource cleanup:UserWarning", ] ini_options.verbosity_assertions = 2 ini_options.asyncio_default_fixture_loop_scope = "session" ini_options.timeout = 20 [tool.coverage] # The pragma plugin configures the run; measuring it would report the tooling alongside the package under test. run.omit = [ "*/tasks/*", ] run.concurrency = [ "multiprocessing", "thread", ] run.parallel = true run.plugins = [ "covdefaults", "coverage_pragmas", ] run.patch = [ "_exit", "subprocess", ] run.sigterm = true paths.other = [ ".", "*/filelock", "*\\filelock", ] paths.source = [ "src", "*/.tox/*/lib/*/site-packages", "*/.tox/*/Lib/site-packages", "**/src", "**\\src", ] report.fail_under = 100 html.skip_covered = false [tool.towncrier] name = "filelock" directory = "docs/changelog" filename = "docs/changelog.rst" template = "release_creator.fragments:template.rst.jinja2" title_format = false issue_format = ":pr:`{issue}`" underlines = [ "*", "=", "-" ] type = [ { directory = "feature", name = "Added", showcontent = true }, { directory = "change", name = "Changes", showcontent = true }, { directory = "bugfix", name = "Fixed", showcontent = true }, { directory = "doc", name = "Documentation", showcontent = true }, { directory = "breaking", name = "Breaking changes", showcontent = true }, { directory = "security", name = "Security", showcontent = true }, { directory = "performance", name = "Performance", showcontent = true }, { directory = "deprecation", name = "Deprecated", showcontent = true }, { directory = "removal", name = "Removed", showcontent = true }, { directory = "packaging", name = "Packaging", showcontent = true }, ] [tool.flake8-lazy] standalone.line-length = 120 standalone.lazy-exclude-modules = [ # installs the fork-safety audit hook and register_at_fork callbacks at import "filelock._api", # installs its sqlite audit hook and fork handler at import, and must fail at import without sqlite3 so the package # __getattr__ resolves ReadWriteLock and AsyncReadWriteLock to None "filelock._read_write", # a marker released from a finalizer at interpreter exit unlinks through it, and a first import then fails "filelock._windows", # registers its atexit cleanup at import "filelock._soft_rw._sync", # a Python built without it must fail at import, as for filelock._read_write "sqlite3", ] [tool.nab] mode = "universal" base-group = "project-runtime" uploaded-prior-to = "P7D" decision-order = "stable" matrix.python = ">=3.15,<3.16" matrix.platforms = [ "linux_x86_64" ] # release-creator 4.4.0 needs a seven-day cutoff exception through 2026-11-09. # expires 2026-11-09 packages.release-creator.uploaded-prior-to = 2026-11-02T21:45:00Z filelock-4.2.8/PKG-INFO0000644000000000000000000000432715272561277011364 0ustar00Metadata-Version: 2.5 Name: filelock Version: 4.2.8 Import-Name: filelock Summary: A platform independent file lock. Project-URL: Changelog, https://py-filelock.readthedocs.io/en/latest/changelog.html Project-URL: Documentation, https://py-filelock.readthedocs.io Project-URL: Homepage, https://github.com/tox-dev/filelock Project-URL: Source, https://github.com/tox-dev/filelock Project-URL: Tracker, https://github.com/tox-dev/filelock/issues Maintainer-email: Bernát Gábor License-Expression: MIT License-File: LICENSE Keywords: file,lock,synchronization Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: 3.14 Classifier: Programming Language :: Python :: 3.15 Classifier: Topic :: Software Development :: Libraries Classifier: Topic :: System Requires-Python: >=3.11 Description-Content-Type: text/markdown # filelock [![PyPI](https://img.shields.io/pypi/v/filelock)](https://pypi.org/project/filelock/) [![Supported Python versions](https://img.shields.io/pypi/pyversions/filelock.svg)](https://pypi.org/project/filelock/) [![Documentation status](https://readthedocs.org/projects/py-filelock/badge/?version=latest)](https://py-filelock.readthedocs.io/en/latest/?badge=latest) [![Downloads](https://static.pepy.tech/badge/filelock/month)](https://pepy.tech/project/filelock) [![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) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/tox-dev/filelock/badge)](https://scorecard.dev/viewer/?uri=github.com/tox-dev/filelock) [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/16250/badge)](https://www.bestpractices.dev/projects/16250) For more information checkout the [official documentation](https://py-filelock.readthedocs.io/en/latest/index.html).