diff --git a/.gitignore b/.gitignore index 062e64452ee2963800bd724fb9b72010275c8aa7..3acd50dec03a25f933769ea4fb99291de3e99201 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ ################# satella.sublime* - +hs_err_pid*.log *.pydevproject .project .metadata diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac80439596edf0b79c54c41f5324d059092a6e2..16a700b4bec811745937bf9c05b35184435dcc43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # v2.2.3 -* _TBA_ +* renamed `AcquirePIDLock` to `PIDFileLock` +* more unit tests for `PIDFileLock` + * it finally works # v2.2.2 diff --git a/MANIFEST.in b/MANIFEST.in index cfabbb68818ec823c5b63874a7c751fc79008540..373701c7be122e140c98812366e8d058fe46ef6e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,3 @@ include LICENSE include README.md -include requirements.txt include CHANGELOG.md diff --git a/README.md b/README.md index 91dc6379a0911cf8bc88e2132253725234fbf603..f591be911571b25a6633673c6ac676c015ef85da 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ _satella.posix_ in unavailable on non-POSIX systems, but the rest should work OK Full [documentation](http://satella.readthedocs.io/en/latest/?badge=latest) is available for the brave souls that do decide to use this library. -Satella is a Python 3.5+ library for writing server applications, especially those dealing with mundane but useful things. +Satella is a zero-requirements Python 3.5+ library for writing +server applications, especially those dealing with mundane but +useful things. It also runs on PyPy. See [LICENSE](LICENSE) for text of the license. diff --git a/docs/posix.rst b/docs/posix.rst index 7e9e789f6ff6525141eb400bd4fa941d562b42dc..e8f0dd498cec035c1b1735c1c9991e035af27448 100644 --- a/docs/posix.rst +++ b/docs/posix.rst @@ -23,3 +23,13 @@ Return if running as root .. autofunction:: satella.posix.is_running_as_root + +PIDFileLock +-------------- + +This is meant to acquire a lock on a file. + +.. autoclass:: satella.posix.PIDFileLock + :members: + +.. autoclass:: satella.posix.LockIsHeld diff --git a/requirements.txt b/requirements.txt index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..a4d92cc08db6a0d8bfedbbbd620d1fb11f84677b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1 @@ +psutil diff --git a/satella/__init__.py b/satella/__init__.py index 94657e466552e17619a3f113ad3c2f17bd78b5a1..9749a6596ac3a8d5aab0baf0f9e00b14f673e76f 100644 --- a/satella/__init__.py +++ b/satella/__init__.py @@ -1,2 +1,2 @@ # coding=UTF-8 -__version__ = '2.2.3a1' +__version__ = '2.2.3' diff --git a/satella/configuration/__init__.py b/satella/configuration/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..5d2c95b57e6b4b6cb4729cd8bec8d95f768068a3 100644 --- a/satella/configuration/__init__.py +++ b/satella/configuration/__init__.py @@ -0,0 +1,4 @@ +from . import schema +from . import sources + +__all__ = ['schema', 'sources'] diff --git a/satella/posix/__init__.py b/satella/posix/__init__.py index cf61df578f184e9049577c6eb23b49a8b4153163..b38c9099c16e2d6336e725535de369a5561c5fb0 100644 --- a/satella/posix/__init__.py +++ b/satella/posix/__init__.py @@ -5,12 +5,12 @@ POSIX things import os from .daemon import daemonize -from .pidlock import AcquirePIDLock +from .pidlock import PIDFileLock, LockIsHeld from .signals import hang_until_sig __all__ = [ 'daemonize', - 'AcquirePIDLock', + 'PIDFileLock', 'LockIsHeld', 'hang_until_sig', 'is_running_as_root', 'suicide' diff --git a/satella/posix/pidlock.py b/satella/posix/pidlock.py index 30ded11ad61c59c27fedd3dba35d5229c6a2fefe..39e1d0d3c8e4bb21dca74665d937bd1f0d12fe5a 100644 --- a/satella/posix/pidlock.py +++ b/satella/posix/pidlock.py @@ -1,109 +1,95 @@ import logging import os -logger = logging.getLogger(__name__) - +import psutil -class FailedToAcquire(Exception): - """Failed to acquire the process lock file""" +logger = logging.getLogger(__name__) -class LockIsHeld(FailedToAcquire): +class LockIsHeld(Exception): """ Lock is held by someone - Has two attributes: - pid - integer - PID of the holder - is_alive - bool - whether the holder is an alive process + pid -- PID of the holder, who is alive """ + def __init__(self, pid): + self.pid = pid + -class AcquirePIDLock: +class PIDFileLock: """ Acquire a PID lock file. + Usable also on Windows + Usage: - >>> with AcquirePIDLock('myservice.pid'): + >>> with PIDFileLock('myservice.pid'): >>> ... rest of code .. Or alternatively - >>> pid_lock = AcquirePIDLock('myservice.pid') + >>> pid_lock = PIDFileLock('myservice.pid') >>> pid_lock.acquire() >>> ... >>> pid_lock.release() The constructor doesn't throw, __enter__ or acquire() does, one of: - * AcquirePIDLock.FailedToAcquire - base class for errors. Thrown if can't read the file - * AcquirePIDLock.LockIsHeld - lock is already held. This has two attributes - pid (int), the PID of holder, + * LockIsHeld - lock is already held. This has two attributes - pid (int), the PID of holder, and is_alive (bool) - whether the holder is an alive process """ - def __init__(self, pid, is_alive): - self.pid = pid - self.is_alive = is_alive - - def __init__(self, pid_file, base_dir=u'/var/run', delete_on_dead=False): + def __init__(self, pid_file, base_dir=u'/var/run'): """ Initialize a PID lock file object :param pid_file: rest of path :param base_dir: base lock directory - :param delete_on_dead: delete the lock file if holder is dead, and retry """ - self.delete_on_dead = delete_on_dead - self.path = os.path.join(base_dir, pid_file) - - self.fileno = None + self.file_no = None def release(self): - if self.fileno is not None: - os.close(self.fileno) + """ + Free the lock + """ + if self.file_no is not None: os.unlink(self.path) + self.file_no = None def acquire(self): """ Acquire the PID lock :raises LockIsHeld: if lock if held - :raises FailedToAcquire: if for example a directory exists in that place """ try: - self.fileno = os.open(self.path, os.O_CREAT | os.O_EXCL) - except (IOError, OSError): - try: - with open(self.path, 'rb') as flock: - try: - pid = int(flock.read()) - except ValueError: - logger.warning( - 'PID file found but doesn''t have an int, skipping') - return - except IOError as e: - raise FailedToAcquire(repr(e)) - - # Is this process alive? - try: - os.kill(pid, 0) - except OSError: # dead - raise LockIsHeld(pid, False) - else: - raise LockIsHeld(pid, True) + self.file_no = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except (OSError, FileExistsError): + with open(self.path, 'r') as fin: + data = fin.read().strip() - def __enter__(self): - try: - self.acquire() - except LockIsHeld as e: - if self.delete_on_dead and (not e.is_alive): + try: + pid = int(data) + except ValueError: os.unlink(self.path) - self.acquire() + return self.acquire() + + if pid in {x.pid for x in psutil.process_iter()}: + raise LockIsHeld(pid) else: - raise + # does not exist + os.unlink(self.path) + return self.acquire() - self.success = True + fd = os.fdopen(self.file_no, 'w') + fd.write(str(os.getpid()) + '\n') + fd.close() + + def __enter__(self): + self.acquire() def __exit__(self, exc_type, exc_val, exc_tb): self.release() diff --git a/setup.py b/setup.py index 594a2324b2f220ec7e114e8a12973ab03370bd6d..620a3d4b369288c6f567c6f9ef78b3224a5ad455 100644 --- a/setup.py +++ b/setup.py @@ -7,10 +7,11 @@ setup(keywords=['ha', 'high availability', 'scalable', 'scalability', 'server'], packages=find_packages(include=['satella', 'satella.*']), version=__version__, install_requires=[ + 'psutil' ], tests_require=[ "nose", "mock", "coverage" ], test_suite='nose.collector', - python_requires='!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*' + python_requires='!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*', ) diff --git a/tests/test_configuration/test_sources/test_envvars.py b/tests/test_configuration/test_sources/test_envvars.py index 66a22444a4b021c78ec926473afbb6681c64b52b..37a40f06ec9fe8c2744378c5115481604fe2a6ce 100644 --- a/tests/test_configuration/test_sources/test_envvars.py +++ b/tests/test_configuration/test_sources/test_envvars.py @@ -1,3 +1,4 @@ +from satella.configuration import sources from satella.configuration.sources import EnvVarsSource, OptionalSource, AlternativeSource, EnvironmentSource, \ StaticSource, MergingSource from .utils import SourceTestCase, mock_env @@ -6,7 +7,7 @@ from .utils import SourceTestCase, mock_env class TestEnvVarsSource(SourceTestCase): @mock_env('satella', '{"a":2}') def test_ok(self): - self.assertSourceHas(EnvVarsSource('satella'), {u"a": 2}) + self.assertSourceHas(sources.EnvVarsSource('satella'), {u"a": 2}) def test_none(self): self.assertSourceEmpty(OptionalSource(EnvVarsSource('satella'))) diff --git a/tests/test_posix/__init__.py b/tests/test_posix/__init__.py index 6864cbaa7357da2b13bec13e6c643718243eeb51..b7f2695c358f5ac7e6e78fe2bbaea81cf9af735b 100644 --- a/tests/test_posix/__init__.py +++ b/tests/test_posix/__init__.py @@ -1,18 +1,26 @@ # coding=UTF-8 from __future__ import print_function, absolute_import, division +import multiprocessing import os import sys import unittest from mock import patch, Mock +from satella.posix import PIDFileLock, LockIsHeld + + +def acquire_lock_file_and_wait_for_signal(q, p): + with PIDFileLock('lock', '.'): + p.put(None) + q.get() + class TestPidlock(unittest.TestCase): - def test_pidlock(self): - from satella.posix.pidlock import AcquirePIDLock - with AcquirePIDLock('lock', '.', delete_on_dead=True): + def test_pidlock(self): + with PIDFileLock('lock', '.'): self.assertTrue(os.path.exists('./lock')) r = open('./lock', 'rb').read() try: @@ -23,6 +31,19 @@ class TestPidlock(unittest.TestCase): self.assertTrue(not os.path.exists('./lock')) + def test_pidlock_multiacquire(self): + q, p = multiprocessing.Queue(), multiprocessing.Queue() + process = multiprocessing.Process(target=acquire_lock_file_and_wait_for_signal, args=(q, p)) + process.start() + p.get() + n = PIDFileLock('lock', '.') + try: + self.assertRaises(LockIsHeld, lambda: n.acquire()) + finally: + q.put(None) + process.terminate() + process.join() + class TestDaemon(unittest.TestCase): @unittest.skipIf('win' in sys.platform, 'Running on Windows')