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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/yyds-lock.svg)](https://pypi.org/project/yyds-lock/)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
+ [![PyPI version](https://img.shields.io/pypi/v/yyds-lock.svg)](https://pypi.org/project/yyds-lock/)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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.
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,10 @@
1
+ # -*- coding:utf-8 -*-
2
+
3
+ from yyds_lock.core import force_single, release_single, single_decorator
4
+ from yyds_lock.__version__ import __version__, __title__, __author__
5
+
6
+ __all__ = [
7
+ "force_single",
8
+ "release_single",
9
+ "single_decorator",
10
+ ]
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/yyds-lock.svg)](https://pypi.org/project/yyds-lock/)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
+ yyds_lock