├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── highlander ├── __init__.py ├── exceptions.py └── highlander.py ├── requirements.txt ├── setup.py └── tests ├── __init__.py └── highlander_tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | .pid 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | - "3.7" 5 | - "3.8" 6 | install: 7 | - "pip install -r requirements.txt" 8 | - "pip install coverage coveralls" 9 | script: coverage run --source=highlander setup.py test 10 | after_success: 11 | coveralls 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Chris Cannon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Highlander: There can be only one... 2 | [![Build Status](https://travis-ci.org/chriscannon/highlander.svg?branch=master)](https://travis-ci.org/chriscannon/highlander) 3 | [![Coverage Status](https://coveralls.io/repos/chriscannon/highlander/badge.svg)](https://coveralls.io/r/chriscannon/highlander) 4 | 5 | About 6 | ===== 7 | Highlander is a decorator to help developers ensure that their python 8 | process is only running once. This is helpful when you have 9 | a python program running on a set schedule (i.e., a cron) and you do 10 | not want one run of the program to overlap with another run. Highlander 11 | accomplishes this by creating a directory containing a file 12 | on disk that contains the current process identifier (PID) and 13 | creation time. If Highlander detects that the PID directory currently 14 | exists it reads the PID file inside it for the PID and creation time 15 | and checks to see if that process exists. If it does exist, it ends the program 16 | and logs that the program was already running. If it does not exist, 17 | Highlander removes the old process information directory and file, creates new ones, and 18 | executes the function associated with the decorator. 19 | 20 | 21 | Installation 22 | ============ 23 | pip install highlander-one 24 | 25 | 26 | Examples 27 | ======== 28 | An example using the default directory (i.e., current working directory): 29 | 30 | from highlander import one 31 | 32 | @one() 33 | def run(): 34 | ... 35 | 36 | if __name__ == '__main__': 37 | run() 38 | 39 | An example using a user-specified directory: 40 | 41 | from highlander import one 42 | 43 | @one('/tmp/my_app/.pid') 44 | def run(): 45 | ... 46 | 47 | if __name__ == '__main__': 48 | run() 49 | 50 | F.A.Q. 51 | ====== 52 | **Why not use flock? Stop reinventing Unix dumb dumb!** 53 | 54 | There are three reasons I did not use flock: 55 | 56 | 1. I knew there was no way that `fcntl.flock` would work the same on all operating systems 57 | and I found a lot of articles on the web stating just that. 58 | What I use in its stead is directory creation, which I believe is a much 59 | more reliable way to do locking across all operating systems and still 60 | only dependent on the file system to ensure it's atomic. 61 | 62 | 2. If a process is killed abruptly (e.g., kill -9) with flock the file 63 | remains exclusively locked even though the process is not running. This 64 | occurs because the process did not have enough time to clean up the exclusive handle on the lock file. What 65 | this means is that when you attempt to run your program it will be unable to 66 | acquire the lock and your program will not run until you manually intervene and 67 | delete the lock file. My solution does not have this problem. 68 | 69 | 3. If you are using the flock Unix tool in conjunction with your program its 70 | default behavior is to hang until the lock file is free and then execute the command. 71 | In my opinion, this is not ideal default behaviour because you could have a ton of 72 | processes build up over time and waste resources. What Highlander does is essentially skip 73 | that run of the program by returning immediately. 74 | -------------------------------------------------------------------------------- /highlander/__init__.py: -------------------------------------------------------------------------------- 1 | from .highlander import one 2 | from .exceptions import InvalidPidFileError, PidFileExistsError 3 | 4 | __all__ = [ 5 | 'one', 6 | 7 | 'InvalidPidFileError', 'PidFileExistsError' 8 | ] 9 | -------------------------------------------------------------------------------- /highlander/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidPidFileError(Exception): 2 | """ An exception when an invalid PID file is read.""" 3 | 4 | class PidFileExistsError(Exception): 5 | """ An exception when a PID file already exists.""" 6 | -------------------------------------------------------------------------------- /highlander/highlander.py: -------------------------------------------------------------------------------- 1 | from errno import EEXIST 2 | from logging import getLogger 3 | from os import getcwd, mkdir 4 | from os.path import join, realpath, isfile, isdir 5 | from shutil import rmtree 6 | 7 | from psutil import Process, NoSuchProcess 8 | from funcy import decorator 9 | 10 | from .exceptions import InvalidPidFileError, PidFileExistsError 11 | 12 | logger = getLogger(__name__) 13 | default_location = realpath(join(getcwd(), '.pid')) 14 | 15 | 16 | @decorator 17 | def one(call, pid_directory=default_location): 18 | """ Check if the call is already running. If so, bail out. If not, create a 19 | file that contains the process identifier (PID) and creation time. After call 20 | has completed, remove the process information file. 21 | :param call: The original function using the @one decorator. 22 | :param pid_directory: The name of the directory where the process information will be written. 23 | :return: The output of the original function. 24 | """ 25 | if _is_running(pid_directory): 26 | logger.info('The process is already running.') 27 | return 28 | 29 | _set_running(pid_directory) 30 | try: 31 | result = call() 32 | finally: 33 | _delete(pid_directory) 34 | return result 35 | 36 | 37 | def _is_running(directory): 38 | """ Determine whether or not the process is currently running. 39 | :param directory: The PID directory containing the process information. 40 | :return: True if there is another process running, False if there is not. 41 | """ 42 | if not isdir(str(directory)): 43 | return _is_locked(directory) 44 | 45 | try: 46 | pid, create_time = _read_pid_file(_get_pid_filename(directory)) 47 | except InvalidPidFileError: 48 | return _is_locked(directory, True) 49 | 50 | try: 51 | current = Process(pid) 52 | except NoSuchProcess: 53 | return _is_locked(directory, True) 54 | 55 | if current.create_time() != create_time: 56 | return _is_locked(directory, True) 57 | 58 | return True 59 | 60 | 61 | def _is_locked(directory, remove_directory=False): 62 | """ Attempt to acquire the lock through directory creation. 63 | :param directory: The PID directory containing the process information. 64 | :param remove_directory: Remove the directory before attempting to acquire 65 | the lock because we know something went wrong and that the directory exists. 66 | :return: True is the lock was acquired, False if it was not. 67 | """ 68 | if remove_directory: 69 | _delete(directory) 70 | 71 | try: 72 | mkdir(str(directory)) 73 | except OSError as e: 74 | if e.errno == EEXIST: 75 | return True 76 | else: 77 | raise e 78 | return False 79 | 80 | 81 | def _read_pid_file(filename): 82 | """Read the current process information from disk. 83 | :param filename: The name of the file containing the process information. 84 | :return: The PID and creation time of the current running process. 85 | """ 86 | try: 87 | with open(str(filename), 'r') as f: 88 | pid, create_time = f.read().split() 89 | return int(pid), float(create_time) 90 | except (IOError, ValueError): 91 | raise InvalidPidFileError 92 | 93 | 94 | def _set_running(directory): 95 | """Write the current process information to disk. 96 | :param directory: The name of the directory where the process information will be written. 97 | """ 98 | filename = _get_pid_filename(directory) 99 | if isfile(str(filename)): 100 | raise PidFileExistsError 101 | 102 | p = Process() 103 | with open(filename, 'w') as f: 104 | f.write('{0} {1:.6f}'.format(p.pid, p.create_time())) 105 | 106 | 107 | def _delete(directory): 108 | """Delete the process information directory on disk. 109 | :param directory: The name of the directory to be deleted. 110 | """ 111 | rmtree(str(directory), ignore_errors=True) 112 | 113 | 114 | def _get_pid_filename(directory): 115 | """Return the name of the process information file. 116 | :param directory: The name of the directory where the process information file 117 | is created. 118 | """ 119 | return realpath(join(str(directory), 'INFO')) 120 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | funcy==1.4 2 | psutil==5.6.6 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | 4 | __version__ = '0.2.2' 5 | 6 | setup( 7 | name='highlander-one', 8 | version=__version__, 9 | author='Christopher T. Cannon', 10 | author_email='christophertcannon@gmail.com', 11 | description='A simple decorator to ensure that your ' 12 | 'program is only running once on a system.', 13 | url='https://github.com/chriscannon/highlander', 14 | install_requires=[ 15 | 'funcy>=1.4', 16 | 'psutil>=2.2.1' 17 | ], 18 | license='MIT', 19 | classifiers=[ 20 | 'Development Status :: 4 - Beta', 21 | 'Intended Audience :: Developers', 22 | 'License :: OSI Approved :: MIT License', 23 | 'Programming Language :: Python :: 2', 24 | 'Programming Language :: Python :: 2.6', 25 | 'Programming Language :: Python :: 2.7', 26 | 'Programming Language :: Python :: 3', 27 | 'Programming Language :: Python :: 3.3', 28 | 'Programming Language :: Python :: 3.4', 29 | ], 30 | download_url='https://github.com/chriscannon/highlander/tarball/{0}'.format(__version__), 31 | packages=['highlander'], 32 | test_suite='tests.highlander_tests.get_suite', 33 | ) 34 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chriscannon/highlander/245c34118688ecf45bdd13aa9d1a2bea4270f673/tests/__init__.py -------------------------------------------------------------------------------- /tests/highlander_tests.py: -------------------------------------------------------------------------------- 1 | from os import unlink, mkdir 2 | from tempfile import mkstemp, mkdtemp 3 | from unittest import TestCase, TestLoader, TestSuite, TextTestRunner 4 | from os.path import isfile, realpath, join, isdir 5 | from shutil import rmtree 6 | 7 | from psutil import Process 8 | 9 | from highlander import InvalidPidFileError, PidFileExistsError 10 | from highlander.highlander import _read_pid_file, _delete, _set_running, _is_running, _get_pid_filename, _is_locked 11 | from highlander import one 12 | 13 | 14 | class HighlanderTestCase(TestCase): 15 | def test_read_pid_no_file(self): 16 | self.assertRaises(InvalidPidFileError, _read_pid_file, None) 17 | 18 | def test_read_valid_pid_file(self): 19 | _, filename = mkstemp() 20 | try: 21 | with open(filename, 'w') as f: 22 | f.write('123 123.123456') 23 | pid, create_time = _read_pid_file(filename) 24 | self.assertEquals(123, pid) 25 | self.assertEquals(123.123456, create_time) 26 | finally: 27 | unlink(filename) 28 | 29 | def test_read_invalid_pid_file(self): 30 | _, filename = mkstemp() 31 | try: 32 | with open(filename, 'w') as f: 33 | f.write('abc def') 34 | self.assertRaises(InvalidPidFileError, _read_pid_file, filename) 35 | finally: 36 | unlink(filename) 37 | 38 | def test_read_empty_pid_file(self): 39 | _, f = mkstemp() 40 | try: 41 | self.assertRaises(InvalidPidFileError, _read_pid_file, f) 42 | finally: 43 | unlink(f) 44 | 45 | def test_decorator(self): 46 | d = mkdtemp() 47 | pid_directory = realpath(join(d, '.pid')) 48 | try: 49 | @one(pid_directory) 50 | def f(): 51 | return True 52 | 53 | self.assertTrue(f()) 54 | finally: 55 | rmtree(d) 56 | 57 | def test_delete_valid_directory(self): 58 | d = mkdtemp() 59 | try: 60 | _delete(d) 61 | self.assertFalse(isdir(d)) 62 | finally: 63 | if isdir(d): 64 | rmtree(d) 65 | 66 | def test_running_file_exists(self): 67 | d = mkdtemp() 68 | f = open(join(d, 'INFO'), 'w') 69 | f.close() 70 | 71 | try: 72 | self.assertRaises(PidFileExistsError, _set_running, d) 73 | finally: 74 | rmtree(d) 75 | 76 | def test_running_valid_file(self): 77 | temp_d = mkdtemp() 78 | d = realpath(join(temp_d, '.pid')) 79 | mkdir(d) 80 | 81 | try: 82 | _set_running(d) 83 | with open(join(d, 'INFO'), 'r') as pid_file: 84 | process_info = pid_file.read().split() 85 | p = Process() 86 | self.assertEquals(p.pid, int(process_info[0])) 87 | self.assertEquals(p.create_time(), float(process_info[1])) 88 | finally: 89 | rmtree(temp_d) 90 | 91 | def test_no_process_is_running(self): 92 | d = mkdtemp() 93 | f = _get_pid_filename(d) 94 | try: 95 | with open(f, 'w') as pid_file: 96 | pid_file.write('99999999999 1.1') 97 | self.assertFalse(_is_running(d)) 98 | finally: 99 | rmtree(d) 100 | 101 | def test_valid_is_running(self): 102 | p = Process() 103 | d = mkdtemp() 104 | f = _get_pid_filename(d) 105 | try: 106 | with open(f, 'w') as pid_file: 107 | pid_file.write('{0} {1:6f}'.format(p.pid, p.create_time())) 108 | self.assertTrue(_is_running(d)) 109 | finally: 110 | rmtree(d) 111 | 112 | def test_create_time_mismatch_is_running(self): 113 | p = Process() 114 | d = mkdtemp() 115 | f = _get_pid_filename(d) 116 | try: 117 | with open(f, 'w') as pid_file: 118 | pid_file.write('{0} 1.1'.format(p.pid)) 119 | self.assertFalse(_is_running(d)) 120 | self.assertFalse(isfile(f)) 121 | self.assertTrue(isdir(d)) 122 | finally: 123 | if isdir(d): 124 | rmtree(d) 125 | 126 | def test_process_is_running(self): 127 | d = mkdtemp() 128 | pid_directory = realpath(join(d, '.pid')) 129 | try: 130 | @one(pid_directory) 131 | def f1(): 132 | return f2() 133 | 134 | @one(pid_directory) 135 | def f2(): 136 | return True 137 | 138 | self.assertEquals(f1(), None) 139 | finally: 140 | rmtree(d) 141 | 142 | def test_get_pid_filename(self): 143 | self.assertEquals('/test/INFO', _get_pid_filename('/test')) 144 | 145 | def test_other_os_error(self): 146 | self.assertRaises(OSError, _is_running, 'a' * 1000) 147 | 148 | def test_directory_exists(self): 149 | self.assertTrue(_is_locked(mkdtemp())) 150 | 151 | def test_cleanup_invalid_pid_file(self): 152 | d = mkdtemp() 153 | f = _get_pid_filename(d) 154 | try: 155 | with open(f, 'w') as pid_file: 156 | pid_file.write("#@$!") 157 | self.assertFalse(_is_running(d)) 158 | self.assertFalse(isfile(f)) 159 | self.assertTrue(isdir(d)) 160 | finally: 161 | rmtree(d) 162 | 163 | 164 | def get_suite(): 165 | loader = TestLoader() 166 | suite = TestSuite() 167 | suite.addTest(loader.loadTestsFromTestCase(HighlanderTestCase)) 168 | return suite 169 | 170 | 171 | if __name__ == '__main__': 172 | TextTestRunner(verbosity=2).run(get_suite()) 173 | --------------------------------------------------------------------------------