yyds-lock 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yyds_lock-0.2.0/PKG-INFO +108 -0
- yyds_lock-0.2.0/README.md +79 -0
- yyds_lock-0.2.0/pyproject.toml +3 -0
- yyds_lock-0.2.0/setup.cfg +4 -0
- yyds_lock-0.2.0/setup.py +44 -0
- yyds_lock-0.2.0/tests/test_lock.py +196 -0
- yyds_lock-0.2.0/yyds_lock/__init__.py +10 -0
- yyds_lock-0.2.0/yyds_lock/__version__.py +9 -0
- yyds_lock-0.2.0/yyds_lock/core.py +149 -0
- yyds_lock-0.2.0/yyds_lock.egg-info/PKG-INFO +108 -0
- yyds_lock-0.2.0/yyds_lock.egg-info/SOURCES.txt +11 -0
- yyds_lock-0.2.0/yyds_lock.egg-info/dependency_links.txt +1 -0
- yyds_lock-0.2.0/yyds_lock.egg-info/top_level.txt +1 -0
yyds_lock-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yyds-lock
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A lightweight cross-platform single-instance process lock for Python.
|
|
5
|
+
Home-page: https://github.com/yyds-fast/yyds-lock
|
|
6
|
+
Author: wzb
|
|
7
|
+
Author-email: wzb@example.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Requires-Python: >=3.7
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Dynamic: author
|
|
21
|
+
Dynamic: author-email
|
|
22
|
+
Dynamic: classifier
|
|
23
|
+
Dynamic: description
|
|
24
|
+
Dynamic: description-content-type
|
|
25
|
+
Dynamic: home-page
|
|
26
|
+
Dynamic: license
|
|
27
|
+
Dynamic: requires-python
|
|
28
|
+
Dynamic: summary
|
|
29
|
+
|
|
30
|
+
# yyds-lock
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/yyds-lock/)
|
|
33
|
+
[](https://opensource.org/licenses/MIT)
|
|
34
|
+
|
|
35
|
+
`yyds-lock` is an ultra-lightweight, zero-dependency Python library that guarantees single-instance execution of scripts/processes using operating system level advisory file locks. It is ideal for cron jobs, automation scripts, schedulers, and background daemons.
|
|
36
|
+
|
|
37
|
+
## Key Features
|
|
38
|
+
|
|
39
|
+
- 🛡️ **Immunity to Crashes / Force Kills**: Unlike simple PID files or "lock files" that leave stale markers behind if the script crashes, is killed (`kill -9`), or suffers power loss, `yyds-lock` binds the lock to the process file descriptor. The OS automatically and instantly releases the lock as soon as the process ends.
|
|
40
|
+
- 🪶 **Zero Dependencies**: 100% pure Python standard library. The installation size is less than 5KB and does not pollute your environment.
|
|
41
|
+
- 🎛️ **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).
|
|
42
|
+
- 💻 **Cross-Platform**: Seamlessly works on Linux, macOS (using `fcntl.flock`), and Windows (using `msvcrt.locking`).
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install -U yyds-lock
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
You can protect your script using either of two simple approaches:
|
|
57
|
+
|
|
58
|
+
### Pattern A: Direct Call (Best for straightforward scripts / entrypoints)
|
|
59
|
+
|
|
60
|
+
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 to stderr and exit with status code `1`.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import time
|
|
64
|
+
import yyds_lock
|
|
65
|
+
|
|
66
|
+
# Force single-instance execution.
|
|
67
|
+
yyds_lock.force_single(lock_name="my_automation.lock", block=False)
|
|
68
|
+
|
|
69
|
+
print("Running heavy automation task...")
|
|
70
|
+
time.sleep(300)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Pattern B: Decorator (Best for structured functions/main entrypoints)
|
|
74
|
+
|
|
75
|
+
Decorate your `main` function to enforce mutual exclusion.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import yyds_lock
|
|
79
|
+
|
|
80
|
+
@yyds_lock.single_decorator(lock_name="my_task.lock", block=False)
|
|
81
|
+
def main():
|
|
82
|
+
print("Executing single instance task safely...")
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
main()
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Configuration / Arguments
|
|
91
|
+
|
|
92
|
+
Both `force_single` and `single_decorator` accept the following arguments:
|
|
93
|
+
|
|
94
|
+
- `lock_name` (str): The filename/path of the lock.
|
|
95
|
+
- If a simple filename is given (e.g. `"my_job.lock"`), it is automatically created in the user's home directory (`~`).
|
|
96
|
+
- 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.
|
|
97
|
+
- `block` (bool):
|
|
98
|
+
- `False` (default): Exit immediately if the lock cannot be acquired.
|
|
99
|
+
- `True`: Block and queue, waiting for the active process to finish and release the lock.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## How It Works Under the Hood
|
|
104
|
+
|
|
105
|
+
1. **Linux / macOS**: Uses `fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)` for exclusive advisory locking.
|
|
106
|
+
2. **Windows**: Uses `msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)` to lock the first byte of the file.
|
|
107
|
+
3. The library stores the open file handles in a global dictionary inside the Python runtime. This keeps the file descriptor open and prevents garbage collection (GC) from releasing the lock prematurely.
|
|
108
|
+
4. When the process terminates (normally, via Exception, `sys.exit`, crash, `kill -9`, or power failure), the OS closes the file descriptors, releasing the locks instantly.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# yyds-lock
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/yyds-lock/)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
`yyds-lock` is an ultra-lightweight, zero-dependency Python library that guarantees single-instance execution of scripts/processes using operating system level advisory file locks. It is ideal for cron jobs, automation scripts, schedulers, and background daemons.
|
|
7
|
+
|
|
8
|
+
## Key Features
|
|
9
|
+
|
|
10
|
+
- 🛡️ **Immunity to Crashes / Force Kills**: Unlike simple PID files or "lock files" that leave stale markers behind if the script crashes, is killed (`kill -9`), or suffers power loss, `yyds-lock` binds the lock to the process file descriptor. The OS automatically and instantly releases the lock as soon as the process ends.
|
|
11
|
+
- 🪶 **Zero Dependencies**: 100% pure Python standard library. The installation size is less than 5KB and does not pollute your environment.
|
|
12
|
+
- 🎛️ **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).
|
|
13
|
+
- 💻 **Cross-Platform**: Seamlessly works on Linux, macOS (using `fcntl.flock`), and Windows (using `msvcrt.locking`).
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install -U yyds-lock
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
You can protect your script using either of two simple approaches:
|
|
28
|
+
|
|
29
|
+
### Pattern A: Direct Call (Best for straightforward scripts / entrypoints)
|
|
30
|
+
|
|
31
|
+
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 to stderr and exit with status code `1`.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import time
|
|
35
|
+
import yyds_lock
|
|
36
|
+
|
|
37
|
+
# Force single-instance execution.
|
|
38
|
+
yyds_lock.force_single(lock_name="my_automation.lock", block=False)
|
|
39
|
+
|
|
40
|
+
print("Running heavy automation task...")
|
|
41
|
+
time.sleep(300)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Pattern B: Decorator (Best for structured functions/main entrypoints)
|
|
45
|
+
|
|
46
|
+
Decorate your `main` function to enforce mutual exclusion.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import yyds_lock
|
|
50
|
+
|
|
51
|
+
@yyds_lock.single_decorator(lock_name="my_task.lock", block=False)
|
|
52
|
+
def main():
|
|
53
|
+
print("Executing single instance task safely...")
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
main()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Configuration / Arguments
|
|
62
|
+
|
|
63
|
+
Both `force_single` and `single_decorator` accept the following arguments:
|
|
64
|
+
|
|
65
|
+
- `lock_name` (str): The filename/path of the lock.
|
|
66
|
+
- If a simple filename is given (e.g. `"my_job.lock"`), it is automatically created in the user's home directory (`~`).
|
|
67
|
+
- 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.
|
|
68
|
+
- `block` (bool):
|
|
69
|
+
- `False` (default): Exit immediately if the lock cannot be acquired.
|
|
70
|
+
- `True`: Block and queue, waiting for the active process to finish and release the lock.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## How It Works Under the Hood
|
|
75
|
+
|
|
76
|
+
1. **Linux / macOS**: Uses `fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)` for exclusive advisory locking.
|
|
77
|
+
2. **Windows**: Uses `msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)` to lock the first byte of the file.
|
|
78
|
+
3. The library stores the open file handles in a global dictionary inside the Python runtime. This keeps the file descriptor open and prevents garbage collection (GC) from releasing the lock prematurely.
|
|
79
|
+
4. When the process terminates (normally, via Exception, `sys.exit`, crash, `kill -9`, or power failure), the OS closes the file descriptors, releasing the locks instantly.
|
yyds_lock-0.2.0/setup.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding:utf-8 -*-
|
|
3
|
+
|
|
4
|
+
from setuptools import setup, find_packages
|
|
5
|
+
from codecs import open
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
about = {}
|
|
9
|
+
here = os.path.abspath(os.path.dirname(__file__))
|
|
10
|
+
with open(os.path.join(here, "yyds_lock", "__version__.py"), "r", "utf-8") as f:
|
|
11
|
+
exec(f.read(), about)
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
with open("README.md", "r", encoding="utf-8") as fh:
|
|
15
|
+
long_description = fh.read()
|
|
16
|
+
except FileNotFoundError:
|
|
17
|
+
long_description = about["__description__"]
|
|
18
|
+
|
|
19
|
+
setup(
|
|
20
|
+
name=about["__title__"],
|
|
21
|
+
version=about["__version__"],
|
|
22
|
+
author=about["__author__"],
|
|
23
|
+
author_email=about["__author_email__"],
|
|
24
|
+
description=about["__description__"],
|
|
25
|
+
long_description=long_description,
|
|
26
|
+
long_description_content_type="text/markdown",
|
|
27
|
+
url=about["__url__"],
|
|
28
|
+
license=about.get("__license__", "MIT"),
|
|
29
|
+
packages=find_packages(),
|
|
30
|
+
include_package_data=True,
|
|
31
|
+
python_requires='>=3.7',
|
|
32
|
+
classifiers=[
|
|
33
|
+
"Programming Language :: Python :: 3",
|
|
34
|
+
"Programming Language :: Python :: 3.7",
|
|
35
|
+
"Programming Language :: Python :: 3.8",
|
|
36
|
+
"Programming Language :: Python :: 3.9",
|
|
37
|
+
"Programming Language :: Python :: 3.10",
|
|
38
|
+
"Programming Language :: Python :: 3.11",
|
|
39
|
+
"Programming Language :: Python :: 3.12",
|
|
40
|
+
"Operating System :: OS Independent",
|
|
41
|
+
"License :: OSI Approved :: MIT License",
|
|
42
|
+
],
|
|
43
|
+
install_requires=[],
|
|
44
|
+
)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
# Add the parent directory to Python path to import yyds_lock
|
|
10
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
|
11
|
+
|
|
12
|
+
from yyds_lock import force_single, release_single, single_decorator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestYYDSLock(unittest.TestCase):
|
|
16
|
+
|
|
17
|
+
def setUp(self):
|
|
18
|
+
# Cleanup test locks in home directory if any exist
|
|
19
|
+
self.test_lock_name = "yyds_test_instance.lock"
|
|
20
|
+
self.lock_path = os.path.join(os.path.expanduser("~"), self.test_lock_name)
|
|
21
|
+
if os.path.exists(self.lock_path):
|
|
22
|
+
try:
|
|
23
|
+
os.remove(self.lock_path)
|
|
24
|
+
except OSError:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
def tearDown(self):
|
|
28
|
+
# Release and cleanup test lock
|
|
29
|
+
release_single(self.test_lock_name)
|
|
30
|
+
if os.path.exists(self.lock_path):
|
|
31
|
+
try:
|
|
32
|
+
os.remove(self.lock_path)
|
|
33
|
+
except OSError:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
def test_basic_acquire_and_release(self):
|
|
37
|
+
# 1. Acquire first time (should succeed)
|
|
38
|
+
try:
|
|
39
|
+
force_single(self.test_lock_name, block=False)
|
|
40
|
+
except SystemExit:
|
|
41
|
+
self.fail("force_single exited unexpectedly on first acquire")
|
|
42
|
+
|
|
43
|
+
# 2. Release lock
|
|
44
|
+
release_single(self.test_lock_name)
|
|
45
|
+
|
|
46
|
+
def test_reentrant_same_process(self):
|
|
47
|
+
# Re-acquiring the same lock in the same process should be a safe no-op
|
|
48
|
+
try:
|
|
49
|
+
force_single(self.test_lock_name, block=False)
|
|
50
|
+
force_single(self.test_lock_name, block=False)
|
|
51
|
+
except SystemExit:
|
|
52
|
+
self.fail("force_single exited on reentrant acquire in the same process")
|
|
53
|
+
finally:
|
|
54
|
+
release_single(self.test_lock_name)
|
|
55
|
+
|
|
56
|
+
def test_decorator_success(self):
|
|
57
|
+
calls = []
|
|
58
|
+
|
|
59
|
+
@single_decorator(lock_name=self.test_lock_name, block=False)
|
|
60
|
+
def my_function(x):
|
|
61
|
+
calls.append(x)
|
|
62
|
+
return x * 2
|
|
63
|
+
|
|
64
|
+
res = my_function(10)
|
|
65
|
+
self.assertEqual(res, 20)
|
|
66
|
+
self.assertEqual(calls, [10])
|
|
67
|
+
|
|
68
|
+
def test_nested_decorators_reentrancy(self):
|
|
69
|
+
# Test that nested decorators do not release the lock prematurely for the caller
|
|
70
|
+
runs = []
|
|
71
|
+
|
|
72
|
+
@single_decorator(lock_name=self.test_lock_name, block=False)
|
|
73
|
+
def inner():
|
|
74
|
+
runs.append("inner")
|
|
75
|
+
|
|
76
|
+
@single_decorator(lock_name=self.test_lock_name, block=False)
|
|
77
|
+
def outer():
|
|
78
|
+
runs.append("outer_start")
|
|
79
|
+
inner()
|
|
80
|
+
# After inner() exits, the lock should STILL be held by outer!
|
|
81
|
+
from yyds_lock.core import _lock_file_handles, _resolve_lock_path
|
|
82
|
+
lock_path = _resolve_lock_path(self.test_lock_name)
|
|
83
|
+
self.assertIn(lock_path, _lock_file_handles)
|
|
84
|
+
self.assertEqual(_lock_file_handles[lock_path]["ref_count"], 1)
|
|
85
|
+
runs.append("outer_end")
|
|
86
|
+
|
|
87
|
+
outer()
|
|
88
|
+
self.assertEqual(runs, ["outer_start", "inner", "outer_end"])
|
|
89
|
+
|
|
90
|
+
# After outer exits, the lock must be completely released
|
|
91
|
+
from yyds_lock.core import _lock_file_handles, _resolve_lock_path
|
|
92
|
+
lock_path = _resolve_lock_path(self.test_lock_name)
|
|
93
|
+
self.assertNotIn(lock_path, _lock_file_handles)
|
|
94
|
+
|
|
95
|
+
def test_subprocess_conflict_non_blocking(self):
|
|
96
|
+
# Start a background process that holds the lock
|
|
97
|
+
code_hold = (
|
|
98
|
+
"import time, sys, os\n"
|
|
99
|
+
"sys.path.insert(0, os.path.abspath('.'))\n"
|
|
100
|
+
f"from yyds_lock import force_single\n"
|
|
101
|
+
f"force_single('{self.test_lock_name}', block=False)\n"
|
|
102
|
+
"print('LOCKED', flush=True)\n"
|
|
103
|
+
"time.sleep(2)\n"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
proc_hold = subprocess.Popen(
|
|
107
|
+
[sys.executable, "-c", code_hold],
|
|
108
|
+
stdout=subprocess.PIPE,
|
|
109
|
+
stderr=subprocess.PIPE,
|
|
110
|
+
text=True
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Wait for background process to output "LOCKED"
|
|
114
|
+
output = proc_hold.stdout.readline().strip()
|
|
115
|
+
self.assertEqual(output, "LOCKED")
|
|
116
|
+
|
|
117
|
+
# Now try to acquire the same lock in a second process with block=False
|
|
118
|
+
code_try = (
|
|
119
|
+
"import sys, os\n"
|
|
120
|
+
"sys.path.insert(0, os.path.abspath('.'))\n"
|
|
121
|
+
f"from yyds_lock import force_single\n"
|
|
122
|
+
f"force_single('{self.test_lock_name}', block=False)\n"
|
|
123
|
+
"print('ACQUIRED', flush=True)\n"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
proc_try = subprocess.run(
|
|
127
|
+
[sys.executable, "-c", code_try],
|
|
128
|
+
capture_output=True,
|
|
129
|
+
text=True
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# The second process must exit with code 1 (since it is already locked)
|
|
133
|
+
self.assertEqual(proc_try.returncode, 1)
|
|
134
|
+
self.assertIn("[yyds-lock] 错误: 脚本进程已在运行中", proc_try.stderr)
|
|
135
|
+
self.assertNotIn("ACQUIRED", proc_try.stdout)
|
|
136
|
+
|
|
137
|
+
# Cleanup
|
|
138
|
+
proc_hold.stdout.close()
|
|
139
|
+
proc_hold.stderr.close()
|
|
140
|
+
proc_hold.wait()
|
|
141
|
+
|
|
142
|
+
def test_subprocess_blocking_wait(self):
|
|
143
|
+
# Start a background process that holds the lock for 1.5 seconds
|
|
144
|
+
code_hold = (
|
|
145
|
+
"import time, sys, os\n"
|
|
146
|
+
"sys.path.insert(0, os.path.abspath('.'))\n"
|
|
147
|
+
f"from yyds_lock import force_single\n"
|
|
148
|
+
f"force_single('{self.test_lock_name}', block=False)\n"
|
|
149
|
+
"print('LOCKED', flush=True)\n"
|
|
150
|
+
"time.sleep(1.5)\n"
|
|
151
|
+
"print('RELEASED', flush=True)\n"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
proc_hold = subprocess.Popen(
|
|
155
|
+
[sys.executable, "-c", code_hold],
|
|
156
|
+
stdout=subprocess.PIPE,
|
|
157
|
+
stderr=subprocess.PIPE,
|
|
158
|
+
text=True
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Wait for background process to output "LOCKED"
|
|
162
|
+
output = proc_hold.stdout.readline().strip()
|
|
163
|
+
self.assertEqual(output, "LOCKED")
|
|
164
|
+
|
|
165
|
+
# Try to acquire with block=True in a second process. It should block and then acquire.
|
|
166
|
+
code_try_block = (
|
|
167
|
+
"import sys, os, time\n"
|
|
168
|
+
"sys.path.insert(0, os.path.abspath('.'))\n"
|
|
169
|
+
f"from yyds_lock import force_single\n"
|
|
170
|
+
"start = time.time()\n"
|
|
171
|
+
f"force_single('{self.test_lock_name}', block=True)\n"
|
|
172
|
+
"print('ACQUIRED_BLOCKED', round(time.time() - start, 1), flush=True)\n"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
start_time = time.time()
|
|
176
|
+
proc_try = subprocess.run(
|
|
177
|
+
[sys.executable, "-c", code_try_block],
|
|
178
|
+
capture_output=True,
|
|
179
|
+
text=True
|
|
180
|
+
)
|
|
181
|
+
elapsed = time.time() - start_time
|
|
182
|
+
|
|
183
|
+
# The second process should succeed (exit code 0) after about 1.5 seconds
|
|
184
|
+
self.assertEqual(proc_try.returncode, 0)
|
|
185
|
+
self.assertIn("ACQUIRED_BLOCKED", proc_try.stdout)
|
|
186
|
+
# Should have waited at least 1.0 seconds
|
|
187
|
+
self.assertGreaterEqual(elapsed, 1.0)
|
|
188
|
+
|
|
189
|
+
# Cleanup
|
|
190
|
+
proc_hold.stdout.close()
|
|
191
|
+
proc_hold.stderr.close()
|
|
192
|
+
proc_hold.wait()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
unittest.main()
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
|
|
3
|
+
__title__ = "yyds-lock"
|
|
4
|
+
__description__ = "A lightweight cross-platform single-instance process lock for Python."
|
|
5
|
+
__url__ = "https://github.com/yyds-fast/yyds-lock"
|
|
6
|
+
__version__ = "0.2.0"
|
|
7
|
+
__author__ = "wzb"
|
|
8
|
+
__author_email__ = "wzb@example.com"
|
|
9
|
+
__license__ = "MIT"
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import platform
|
|
6
|
+
import threading
|
|
7
|
+
from functools import wraps
|
|
8
|
+
|
|
9
|
+
# Global registry for active locks: maps absolute lock file paths to info dicts:
|
|
10
|
+
# {
|
|
11
|
+
# "handle": file_object,
|
|
12
|
+
# "ref_count": int
|
|
13
|
+
# }
|
|
14
|
+
_lock_file_handles = {}
|
|
15
|
+
_global_lock = threading.Lock()
|
|
16
|
+
|
|
17
|
+
def _resolve_lock_path(lock_name: str) -> str:
|
|
18
|
+
"""
|
|
19
|
+
Resolves the lock name to an absolute canonical path.
|
|
20
|
+
"""
|
|
21
|
+
if os.path.isabs(lock_name) or "/" in lock_name or "\\" in lock_name:
|
|
22
|
+
return os.path.abspath(lock_name)
|
|
23
|
+
else:
|
|
24
|
+
return os.path.abspath(os.path.join(os.path.expanduser("~"), lock_name))
|
|
25
|
+
|
|
26
|
+
def force_single(lock_name: str = "yyds_instance.lock", block: bool = False):
|
|
27
|
+
"""
|
|
28
|
+
Enforces that only a single instance of the script/process runs.
|
|
29
|
+
|
|
30
|
+
:param lock_name: Name of the lock file, created in the user's home directory.
|
|
31
|
+
Can also be a relative or absolute path.
|
|
32
|
+
:param block: If True, blocks and queues until the lock is available.
|
|
33
|
+
If False, instantly prints an error message to stderr and exits with code 1.
|
|
34
|
+
"""
|
|
35
|
+
global _lock_file_handles
|
|
36
|
+
|
|
37
|
+
lock_path = _resolve_lock_path(lock_name)
|
|
38
|
+
|
|
39
|
+
with _global_lock:
|
|
40
|
+
# If the current process already holds this lock, increment reference count and return safely
|
|
41
|
+
if lock_path in _lock_file_handles:
|
|
42
|
+
_lock_file_handles[lock_path]["ref_count"] += 1
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
# Ensure parent directory exists
|
|
46
|
+
lock_dir = os.path.dirname(lock_path)
|
|
47
|
+
if lock_dir:
|
|
48
|
+
os.makedirs(lock_dir, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
# Open the lock file in append/read-write mode
|
|
51
|
+
handle = open(lock_path, "a+")
|
|
52
|
+
sys_type = platform.system().lower()
|
|
53
|
+
|
|
54
|
+
if sys_type == "windows":
|
|
55
|
+
import msvcrt
|
|
56
|
+
mode = msvcrt.LK_LOCK if block else msvcrt.LK_NBLCK
|
|
57
|
+
try:
|
|
58
|
+
handle.seek(0)
|
|
59
|
+
msvcrt.locking(handle.fileno(), mode, 1)
|
|
60
|
+
except (IOError, OSError):
|
|
61
|
+
handle.close()
|
|
62
|
+
print(f"\033[31m[yyds-lock] 错误: 脚本进程已在运行中,当前实例自动退出!\033[0m", file=sys.stderr)
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
|
|
65
|
+
else: # Linux / macOS / Unix
|
|
66
|
+
import fcntl
|
|
67
|
+
mode = fcntl.LOCK_EX
|
|
68
|
+
if not block:
|
|
69
|
+
mode |= fcntl.LOCK_NB
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
fcntl.flock(handle.fileno(), mode)
|
|
73
|
+
except (IOError, OSError):
|
|
74
|
+
handle.close()
|
|
75
|
+
print(f"\033[31m[yyds-lock] 错误: 脚本进程已在运行中,当前实例自动退出!\033[0m", file=sys.stderr)
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
|
|
78
|
+
with _global_lock:
|
|
79
|
+
# Double check to prevent rare race condition
|
|
80
|
+
if lock_path in _lock_file_handles:
|
|
81
|
+
# Another thread acquired it in the meantime (highly unlikely with _global_lock, but safe fallback)
|
|
82
|
+
_lock_file_handles[lock_path]["ref_count"] += 1
|
|
83
|
+
# Release our newly acquired OS lock as we already have one
|
|
84
|
+
try:
|
|
85
|
+
if sys_type == "windows":
|
|
86
|
+
handle.seek(0)
|
|
87
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
88
|
+
else:
|
|
89
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
90
|
+
except (IOError, OSError):
|
|
91
|
+
pass
|
|
92
|
+
handle.close()
|
|
93
|
+
else:
|
|
94
|
+
_lock_file_handles[lock_path] = {
|
|
95
|
+
"handle": handle,
|
|
96
|
+
"ref_count": 1
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def release_single(lock_name: str = "yyds_instance.lock"):
|
|
100
|
+
"""
|
|
101
|
+
Manually releases a lock if it was acquired by the current process.
|
|
102
|
+
"""
|
|
103
|
+
global _lock_file_handles
|
|
104
|
+
|
|
105
|
+
lock_path = _resolve_lock_path(lock_name)
|
|
106
|
+
handle_to_close = None
|
|
107
|
+
|
|
108
|
+
with _global_lock:
|
|
109
|
+
lock_info = _lock_file_handles.get(lock_path)
|
|
110
|
+
if not lock_info:
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
lock_info["ref_count"] -= 1
|
|
114
|
+
if lock_info["ref_count"] > 0:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
# Ref count reached 0, proceed to release lock
|
|
118
|
+
handle_to_close = lock_info["handle"]
|
|
119
|
+
_lock_file_handles.pop(lock_path, None)
|
|
120
|
+
|
|
121
|
+
if handle_to_close:
|
|
122
|
+
sys_type = platform.system().lower()
|
|
123
|
+
try:
|
|
124
|
+
if sys_type == "windows":
|
|
125
|
+
import msvcrt
|
|
126
|
+
handle_to_close.seek(0)
|
|
127
|
+
msvcrt.locking(handle_to_close.fileno(), msvcrt.LK_UNLCK, 1)
|
|
128
|
+
else:
|
|
129
|
+
import fcntl
|
|
130
|
+
fcntl.flock(handle_to_close.fileno(), fcntl.LOCK_UN)
|
|
131
|
+
except (IOError, OSError):
|
|
132
|
+
pass
|
|
133
|
+
finally:
|
|
134
|
+
handle_to_close.close()
|
|
135
|
+
|
|
136
|
+
def single_decorator(lock_name: str = "yyds_instance.lock", block: bool = False):
|
|
137
|
+
"""
|
|
138
|
+
Decorator syntax sugar for running a function as a single instance.
|
|
139
|
+
"""
|
|
140
|
+
def decorator(func):
|
|
141
|
+
@wraps(func)
|
|
142
|
+
def wrapper(*args, **kwargs):
|
|
143
|
+
force_single(lock_name, block)
|
|
144
|
+
try:
|
|
145
|
+
return func(*args, **kwargs)
|
|
146
|
+
finally:
|
|
147
|
+
release_single(lock_name)
|
|
148
|
+
return wrapper
|
|
149
|
+
return decorator
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yyds-lock
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A lightweight cross-platform single-instance process lock for Python.
|
|
5
|
+
Home-page: https://github.com/yyds-fast/yyds-lock
|
|
6
|
+
Author: wzb
|
|
7
|
+
Author-email: wzb@example.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Requires-Python: >=3.7
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Dynamic: author
|
|
21
|
+
Dynamic: author-email
|
|
22
|
+
Dynamic: classifier
|
|
23
|
+
Dynamic: description
|
|
24
|
+
Dynamic: description-content-type
|
|
25
|
+
Dynamic: home-page
|
|
26
|
+
Dynamic: license
|
|
27
|
+
Dynamic: requires-python
|
|
28
|
+
Dynamic: summary
|
|
29
|
+
|
|
30
|
+
# yyds-lock
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/yyds-lock/)
|
|
33
|
+
[](https://opensource.org/licenses/MIT)
|
|
34
|
+
|
|
35
|
+
`yyds-lock` is an ultra-lightweight, zero-dependency Python library that guarantees single-instance execution of scripts/processes using operating system level advisory file locks. It is ideal for cron jobs, automation scripts, schedulers, and background daemons.
|
|
36
|
+
|
|
37
|
+
## Key Features
|
|
38
|
+
|
|
39
|
+
- 🛡️ **Immunity to Crashes / Force Kills**: Unlike simple PID files or "lock files" that leave stale markers behind if the script crashes, is killed (`kill -9`), or suffers power loss, `yyds-lock` binds the lock to the process file descriptor. The OS automatically and instantly releases the lock as soon as the process ends.
|
|
40
|
+
- 🪶 **Zero Dependencies**: 100% pure Python standard library. The installation size is less than 5KB and does not pollute your environment.
|
|
41
|
+
- 🎛️ **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).
|
|
42
|
+
- 💻 **Cross-Platform**: Seamlessly works on Linux, macOS (using `fcntl.flock`), and Windows (using `msvcrt.locking`).
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install -U yyds-lock
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
You can protect your script using either of two simple approaches:
|
|
57
|
+
|
|
58
|
+
### Pattern A: Direct Call (Best for straightforward scripts / entrypoints)
|
|
59
|
+
|
|
60
|
+
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 to stderr and exit with status code `1`.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import time
|
|
64
|
+
import yyds_lock
|
|
65
|
+
|
|
66
|
+
# Force single-instance execution.
|
|
67
|
+
yyds_lock.force_single(lock_name="my_automation.lock", block=False)
|
|
68
|
+
|
|
69
|
+
print("Running heavy automation task...")
|
|
70
|
+
time.sleep(300)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Pattern B: Decorator (Best for structured functions/main entrypoints)
|
|
74
|
+
|
|
75
|
+
Decorate your `main` function to enforce mutual exclusion.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import yyds_lock
|
|
79
|
+
|
|
80
|
+
@yyds_lock.single_decorator(lock_name="my_task.lock", block=False)
|
|
81
|
+
def main():
|
|
82
|
+
print("Executing single instance task safely...")
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
main()
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Configuration / Arguments
|
|
91
|
+
|
|
92
|
+
Both `force_single` and `single_decorator` accept the following arguments:
|
|
93
|
+
|
|
94
|
+
- `lock_name` (str): The filename/path of the lock.
|
|
95
|
+
- If a simple filename is given (e.g. `"my_job.lock"`), it is automatically created in the user's home directory (`~`).
|
|
96
|
+
- 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.
|
|
97
|
+
- `block` (bool):
|
|
98
|
+
- `False` (default): Exit immediately if the lock cannot be acquired.
|
|
99
|
+
- `True`: Block and queue, waiting for the active process to finish and release the lock.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## How It Works Under the Hood
|
|
104
|
+
|
|
105
|
+
1. **Linux / macOS**: Uses `fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)` for exclusive advisory locking.
|
|
106
|
+
2. **Windows**: Uses `msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)` to lock the first byte of the file.
|
|
107
|
+
3. The library stores the open file handles in a global dictionary inside the Python runtime. This keeps the file descriptor open and prevents garbage collection (GC) from releasing the lock prematurely.
|
|
108
|
+
4. When the process terminates (normally, via Exception, `sys.exit`, crash, `kill -9`, or power failure), the OS closes the file descriptors, releasing the locks instantly.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
tests/test_lock.py
|
|
5
|
+
yyds_lock/__init__.py
|
|
6
|
+
yyds_lock/__version__.py
|
|
7
|
+
yyds_lock/core.py
|
|
8
|
+
yyds_lock.egg-info/PKG-INFO
|
|
9
|
+
yyds_lock.egg-info/SOURCES.txt
|
|
10
|
+
yyds_lock.egg-info/dependency_links.txt
|
|
11
|
+
yyds_lock.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
yyds_lock
|