diff --git a/.gitignore b/.gitignore index fbb294d4..236f02ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,48 @@ -data/* -*.pyc -*.egg/ -*.egg-info/ +*.py[cod] + +# vi(m) swap files: +*.sw? + +# C extensions +*.so + +# Packages .cache/ +*.egg +*.eggs +*.egg-info +dist +build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 + +# Installer logs +pip-log.txt + +# Unit test / coverage reports .coverage -.eggs/ -build/ +.tox +nosetests.xml coverage.xml +htmlcov junit.xml +features/output +dummy + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + pgpass scm-source.json diff --git a/mkbinary.sh b/mkbinary.sh new file mode 100755 index 00000000..86cf8215 --- /dev/null +++ b/mkbinary.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -e + +pip install --ignore-installed setuptools==19.2 pyinstaller +pyinstaller --clean --onefile patroni.spec diff --git a/patroni.spec b/patroni.spec new file mode 100644 index 00000000..2afe8eac --- /dev/null +++ b/patroni.spec @@ -0,0 +1,39 @@ +# -*- mode: python -*- + +block_cipher = None + + +def hiddenimports(): + import sys + sys.path.insert(0, '.') + try: + import patroni.dcs + return patroni.dcs.dcs_modules() + finally: + sys.path.pop(0) + + +a = Analysis(['patroni/__main__.py'], + pathex=[], + binaries=None, + datas=None, + hiddenimports=hiddenimports(), + hookspath=[], + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE(pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + name='patroni', + debug=False, + strip=False, + upx=True, + console=True) diff --git a/patroni/config.py b/patroni/config.py index 2268a59d..aa80cf40 100644 --- a/patroni/config.py +++ b/patroni/config.py @@ -65,7 +65,7 @@ class Config(object): print('Usage: {0} config.yml'.format(sys.argv[0])) print('\tPatroni may also read the configuration from the {0} environment variable'. format(self.PATRONI_CONFIG_VARIABLE)) - exit(1) + sys.exit(1) self.__effective_configuration = self._build_effective_configuration({}, self._local_configuration) self._data_dir = self.__effective_configuration['postgresql']['data_dir'] diff --git a/patroni/dcs/__init__.py b/patroni/dcs/__init__.py index d7a69997..733efbb0 100644 --- a/patroni/dcs/__init__.py +++ b/patroni/dcs/__init__.py @@ -6,6 +6,7 @@ import json import os import pkgutil import six +import sys from collections import namedtuple from patroni.exceptions import PatroniException @@ -31,22 +32,37 @@ def parse_connection_string(value): return conn_url, api_url +def dcs_modules(): + """Get names of DCS modules, depending on execution environment. If being packaged with PyInstaller, + modules aren't discoverable dynamically by scanning source directory because `FrozenImporter` doesn't + implement `iter_modules` method. But it is still possible to find all potential DCS modules by + iterating through `toc`, which contains list of all "frozen" resources.""" + + dcs_dirname = os.path.dirname(__file__) + module_prefix = __package__ + '.' + + if getattr(sys, 'frozen', False): + importer = pkgutil.get_importer(dcs_dirname) + return [module for module in list(importer.toc) if module.startswith(module_prefix) and module.count('.') == 2] + else: + return [module_prefix + name for _, name, is_pkg in pkgutil.iter_modules([dcs_dirname]) if not is_pkg] + + def get_dcs(config): available_implementations = set() - for _, module_name, is_pkg in pkgutil.iter_modules([os.path.dirname(__file__)]): - if not is_pkg: - module = importlib.import_module(__package__ + '.' + module_name) - for name in filter(lambda name: not name.startswith('__'), dir(module)): # iterate through module content - value = getattr(module, name) - name = name.lower() - # try to find implementation of AbstractDCS interface, class name must match with module_name - if inspect.isclass(value) and issubclass(value, AbstractDCS) and name == module_name: - available_implementations.add(name) - if name in config: # which has configuration section in the config file - # propagate some parameters - config[name].update({p: config[p] for p in ('namespace', 'name', 'scope', - 'loop_wait', 'ttl', 'retry_timeout') if p in config}) - return value(config[name]) + for module_name in dcs_modules(): + module = importlib.import_module(module_name) + for name in filter(lambda name: not name.startswith('__'), dir(module)): # iterate through module content + value = getattr(module, name) + name = name.lower() + # try to find implementation of AbstractDCS interface, class name must match with module_name + if inspect.isclass(value) and issubclass(value, AbstractDCS) and __package__ + '.' + name == module_name: + available_implementations.add(name) + if name in config: # which has configuration section in the config file + # propagate some parameters + config[name].update({p: config[p] for p in ('namespace', 'name', 'scope', + 'loop_wait', 'ttl', 'retry_timeout') if p in config}) + return value(config[name]) raise PatroniException("""Can not find suitable configuration of distributed configuration store Available implementations: """ + ', '.join(available_implementations)) diff --git a/tests/test_patroni.py b/tests/test_patroni.py index f9f135cc..d9c0e93a 100644 --- a/tests/test_patroni.py +++ b/tests/test_patroni.py @@ -14,6 +14,11 @@ from test_etcd import SleepException, etcd_read, etcd_write from test_postgresql import Postgresql, psycopg2_connect +class MockFrozenImporter(object): + + toc = set(['patroni.dcs.etcd']) + + @patch('time.sleep', Mock()) @patch('subprocess.call', Mock(return_value=0)) @patch('psycopg2.connect', psycopg2_connect) @@ -27,6 +32,8 @@ from test_postgresql import Postgresql, psycopg2_connect @patch.object(etcd.Client, 'read', etcd_read) class TestPatroni(unittest.TestCase): + @patch('pkgutil.get_importer', Mock(return_value=MockFrozenImporter())) + @patch('sys.frozen', Mock(return_value=True), create=True) @patch.object(etcd.Client, 'read', etcd_read) def setUp(self): RestApiServer._BaseServer__is_shut_down = Mock()