yyds-lock
yyds-lock is a lightweight, zero-dependency Python library for single-instance execution of cooperating scripts, processes, threads, and asyncio tasks using operating-system advisory file locks. It is designed for cron jobs, automation scripts, schedulers, and local background daemons.
Key Features
- 🛡️ Immunity to Crashes / Force Kills: Unlike PID files or stale lock files that cause permanent lockups if a process is terminated forcefully (
kill -9, crash, or power loss),yyds-lockbinds the lock to the process file descriptor. The OS automatically and instantly releases the lock as soon as the process ends. - 🪶 Zero Dependencies: 100% Python standard library with no runtime packages to install.
- 🎛️ Dual Modes: Supports both "Instant Exit" (non-blocking, terminates immediately if another instance is running) and "Queue / Wait" (blocking, waits for the existing instance to finish).
- 🧵 Thread-Safety & Isolation: Safe to use in multi-threaded programs. Different threads running under the same process are isolated and will block or raise conflicts on the same lock.
- ⚡ Async-Safe Waiting:
force_single_asyncand asynchronous decorators wait without blocking the event loop and support finite timeouts. - 🔱 Fork-Safety: Coordinates registry mutations with Unix
fork()and closes both acquired and in-flight inherited descriptors in the child without unlocking the parent. - 📁 Inaccessible Directory Fallback: If the home directory is read-only or unavailable, bare lock names fall back to a private per-user directory under the system temporary directory. An explicit
base_dirnever silently moves. - 🧹 Automatic Cleanup: Registers an
atexitcleanup hook to close file descriptors cleanly on interpreter shutdown, preventing pythonResourceWarning. - 💻 Cross-Platform: Seamlessly works on Linux, macOS (using
fcntl.flock), and Windows (usingmsvcrt.locking).
Installation
pip install -U yyds-lock
Usage
You can protect your script using any of the following approaches:
Pattern A: Direct Call (Best for straightforward scripts / entrypoints)
Place this call at the very top of your entrypoint script. If another instance of the script is already running, the new instance will immediately print an error and exit with status code 1.
import time
import yyds_lock
# Force single-instance execution.
yyds_lock.force_single(lock_name="my_automation.lock", block=False)
print("Running heavy automation task...")
time.sleep(300)
Pattern B: Decorator with Dynamic Lock Names
Decorate your functions to enforce mutual exclusion. The lock_name parameter can also be a callable (e.g. lambda function) that dynamically generates the lock name based on function arguments.
import yyds_lock
# 1. Static lock name
@yyds_lock.single_decorator(lock_name="my_task.lock", block=False)
def main():
print("Executing single instance task safely...")
# 2. Dynamic lock name based on arguments
@yyds_lock.single_decorator(lock_name=lambda job_id: f"job_{job_id}.lock", block=False)
def process_job(job_id):
print(f"Processing job {job_id} exclusively...")
if __name__ == "__main__":
main()
process_job(42)
Pattern C: Handle Lock Conflict (Exception Raising)
If you prefer to handle the locking failure programmatically (e.g., to perform custom cleanups, log warnings, or run fallback logic) instead of immediately terminating the process, set raise_on_conflict=True to raise AlreadyLockedError:
import yyds_lock
from yyds_lock import AlreadyLockedError
try:
yyds_lock.force_single(
lock_name="my_automation.lock", block=False, raise_on_conflict=True
)
except AlreadyLockedError:
print("Failed to acquire lock. Running fallback script instead...")
# Add custom fallback actions here
Operational failures such as permission errors or unsupported filesystem locking raise LockOperationError rather than being misreported as lock contention.
Pattern D: Context Manager
Use SingleInstanceLock when the lock should have an explicit lexical lifetime:
from yyds_lock import SingleInstanceLock
with SingleInstanceLock("my_task.lock", raise_on_conflict=True):
run_task()
The decorator also preserves the lock for the full lifetime of coroutine, generator, and async-generator functions. Asynchronous decorators safely support block=True without blocking the event loop.
Pattern E: Async Waiting with a Timeout
Use force_single_async directly from the Task that will eventually release the lock:
from yyds_lock import LockTimeoutError, force_single_async, release_single
async def run_job():
try:
await force_single_async("my_async_job.lock", timeout=10)
except LockTimeoutError:
return
try:
await do_work()
finally:
release_single("my_async_job.lock")
Do not acquire in an executor and release from the event-loop Task: lock ownership belongs to the Thread or Task that performed the acquisition. If an executor is required, the complete acquire/work/release scope must run in one executor call.
Configuration / Arguments
force_single and single_decorator share the following core arguments:
lock_name(str oros.PathLike): The filename/path of the lock.single_decoratoradditionally accepts a callable that returns the lock name from the decorated function's arguments.- If a simple filename is given (e.g.
"my_job.lock"), it is automatically created in a hidden directory.yyds_lockunder the user's home directory (~/.yyds_lock). - If an absolute or relative path is given (e.g.,
"/var/run/my_job.lock"), it is created at that specific path. The parent directories will be created automatically if they do not exist.
- If a simple filename is given (e.g.
block(bool):False(default): Exit immediately (or raise) if the lock cannot be acquired.True: Block and queue, waiting for the active process/thread to finish and release the lock.
raise_on_conflict(bool):False(default): Immediately log an error and callsys.exit(1)when the lock is already held.True: RaiseAlreadyLockedErrorwhen the lock is already held, allowing the caller to catch it.
base_dir(str oros.PathLike, optional): Overrides the default folder (~/.yyds_lock) for simple filenames. Failure to use an explicit directory raisesLockOperationError; it is not silently replaced.logger(optional): Pass an already initialized Logger instance (such asyyds-logger, standard librarylogging.Logger,loguru, or a custom wrapper). When a conflict or error occurs, messages are emitted directly to this logger.
force_single_async accepts lock_name, base_dir, logger, plus:
timeout(float orNone): Maximum wait in seconds.Nonewaits indefinitely; expiration raisesLockTimeoutError.poll_interval(float): Delay between non-blocking attempts, defaulting to0.05seconds.
For coroutine and async-generator decorators, timeout and poll_interval configure async waiting when block=True.
Logging
yyds-lock supports two ways of emitting conflict and warning messages:
1. Passing a Custom Logger (Recommended)
Pass an existing logger instance to force_single, SingleInstanceLock, or @single_decorator:
from yyds_lock import force_single
from yyds_logger import Logger # or loguru, standard logging.Logger
my_logger = Logger("./logs/my_spider.log", "debug").logger
# Conflict error will be written directly to my_logger upon exit
force_single(lock_name="my_spider.lock", block=False, logger=my_logger)
2. Default yyds_lock Logger
If no custom logger is passed, yyds-lock uses the standard library logger named "yyds_lock":
import logging
logger = logging.getLogger("yyds_lock")
If an effective handler is configured, conflicts are emitted once through logging. Otherwise, yyds-lock prints one message to sys.stderr (colored only when stderr is a terminal).
How It Works Under the Hood
- Linux / macOS: Uses
fcntl.flock(fd, fcntl.LOCK_EX)for exclusive advisory locking. - Windows: Uses interruptible polling around
msvcrt.LK_NBLCKto lock the first byte, givingblock=Truetrue wait-until-available behavior instead of the CRT's bounded retry window. - Thread / Task Safety: Tracks the owning live
Threador asyncioTaskobject, supports same-owner reentrancy, and serializes local path transitions before calling the OS lock. - Fork-Safety: Uses all three
os.register_at_forkphases. A fork gate snapshots registry mutations, and the child replaces inherited synchronization primitives after closing acquired and in-flight descriptors. - Clean Reclamation: Locks are released when:
- An explicit
release_singlecall is executed. - The decorated function finishes execution.
- Python exit handlers run (
atexit). - The process terminates or is killed, prompting the operating system to reclaim all file descriptors and release the locks.
- An explicit
Scope and Limitations
- Locks are advisory: every participant must cooperate by locking the same canonical file path.
- Mutual exclusion is local to processes that share the same underlying filesystem. Separate containers, hosts, or non-shared temporary directories require a distributed lock instead.
- Network filesystems may implement
flockdifferently; validate the target filesystem before relying on it for critical coordination. sys.exit(1)raisesSystemExitin the calling execution context. In libraries and worker threads,raise_on_conflict=Trueis usually the safer integration mode.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file yyds_lock-0.4.4.tar.gz.
File metadata
- Download URL: yyds_lock-0.4.4.tar.gz
- Upload date:
- Size: 31.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.1.0 CPython/3.11.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
783fc297c0183494b40b2bb6529d769459e78a2ce8f7a2bebefd43acd159d370
|
|
| MD5 |
9706b9bc4d07b6336d60211ab3c4aac3
|
|
| BLAKE2b-256 |
4ea88ebd6f2825bf72f4d567f41da63f6c396a1fa0322b32c6b8afc27458910c
|
File details
Details for the file yyds_lock-0.4.4-py3-none-any.whl.
File metadata
- Download URL: yyds_lock-0.4.4-py3-none-any.whl
- Upload date:
- Size: 15.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.1.0 CPython/3.11.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
87e4f547643028efcd18748c5fc5dddb3b4974534510eb2c2e7c3bb517b5e4e4
|
|
| MD5 |
dd135233c0754f5aaba41365991cf5db
|
|
| BLAKE2b-256 |
b8b00e35a86e7a24e46672a791ef25ccc0ed523d03d11da04b0555cbfb981573
|