diff --git a/.gitignore b/.gitignore index b40dfdcc..8bacef32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,13 @@ +[Tt]est[Rr]esult* +/cache +/logs +.project +.pydevproject + +# coverage generated: +/cover-html/ +.coverage +.coveralls.yml # Compiled source # ################### @@ -61,9 +71,4 @@ Thumbs.db obj/ [Rr]elease*/ _ReSharper*/ -[Tt]est[Rr]esult* -/cache -/logs -.project -.pydevproject -.vscode \ No newline at end of file +.vscode diff --git a/.travis.yml b/.travis.yml index e6bb2338..165f65e3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,14 +13,25 @@ cache: # http://about.travis-ci.org/docs/user/ci-environment/#Python-VM-images python: - "2.6" - - "2.7" +matrix: + include: + - python: "2.7" + env: SENDCOVERAGE=1 + # pylint 1.4 does not run under python 2.6 install: - - pip install pyOpenSSL - - pip install pylint==1.3.1 - - pip install pyflakes - - pip install pep8 + - pip install pyOpenSSL + - pip install pylint==1.3.1 + - pip install pyflakes + - pip install pep8 + # coverage stuff: + - pip install coveralls + - pip install coverage script: - pep8 headphones - pyflakes headphones - - nosetests headphones + - nosetests + +after_success: + # coverage stuff: + - if [ $SENDCOVERAGE ]; then coveralls; fi diff --git a/Headphones.py b/Headphones.py index 0d31534e..dc956a43 100755 --- a/Headphones.py +++ b/Headphones.py @@ -152,7 +152,10 @@ def main(): headphones.DB_FILE = os.path.join(headphones.DATA_DIR, 'headphones.db') # Read config and start logging - headphones.initialize(config_file) + try: + headphones.initialize(config_file) + except headphones.exceptions.SoftChrootError as e: + raise SystemExit('FATAL ERROR') if headphones.DAEMON: headphones.daemonize() diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index a1313d58..05109089 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -1095,7 +1095,7 @@
- Plex Token (for use with Plex Home) + Plex Token (for use with Plex Home)
diff --git a/headphones/__init__.py b/headphones/__init__.py index eb48476d..92b274a9 100644 --- a/headphones/__init__.py +++ b/headphones/__init__.py @@ -29,7 +29,8 @@ from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.interval import IntervalTrigger from headphones import versioncheck, logger import headphones.config - +from headphones.softchroot import SoftChroot +import headphones.exceptions # (append new extras to the end) POSSIBLE_EXTRAS = [ @@ -74,6 +75,7 @@ started = False DATA_DIR = None CONFIG = None +SOFT_CHROOT = None DB_FILE = None @@ -92,11 +94,11 @@ MIRRORLIST = ["musicbrainz.org", "headphones", "custom"] UMASK = None - def initialize(config_file): with INIT_LOCK: global CONFIG + global SOFT_CHROOT global _INITIALIZED global CURRENT_VERSION global LATEST_VERSION @@ -136,6 +138,14 @@ def initialize(config_file): logger.initLogger(console=not QUIET, log_dir=CONFIG.LOG_DIR, verbose=VERBOSE) + try: + SOFT_CHROOT = SoftChroot(str(CONFIG.SOFT_CHROOT)) + if SOFT_CHROOT.isEnabled(): + logger.info("Soft-chroot enabled for dir: %s", str(CONFIG.SOFT_CHROOT)) + except exceptions.SoftChrootError as e: + logger.error("SoftChroot error: %s", e) + raise e + if not CONFIG.CACHE_DIR: # Put the cache dir in the data dir for now CONFIG.CACHE_DIR = os.path.join(DATA_DIR, 'cache') diff --git a/headphones/albumart_test.py b/headphones/albumart_test.py new file mode 100644 index 00000000..f18b11e3 --- /dev/null +++ b/headphones/albumart_test.py @@ -0,0 +1,13 @@ +#import unittest +#import mock +from headphones.unittestcompat import TestCase + +import headphones.albumart + +# no tests... +class AlbumArtTest(TestCase): + def test_nothing(self): + x = 100 - 2 * 50 + if x: + headphones.albumart.getAlbumArt('asdf') + self.assertTrue(True) diff --git a/headphones/config.py b/headphones/config.py index 8362b934..97675305 100644 --- a/headphones/config.py +++ b/headphones/config.py @@ -15,6 +15,19 @@ def bool_int(value): value = 0 return int(bool(value)) +class path(str): + """Internal 'marker' type for paths in config.""" + + @staticmethod + def __call__(val): + return path(val) + + def __new__(cls, *args, **kw): + hstr = str.__new__(cls, *args, **kw) + return hstr + + def __repr__(self): + return 'headphones.config.path(%s)' % self _CONFIG_DEFINITIONS = { 'ADD_ALBUM_ART': (int, 'General', 0), @@ -31,11 +44,11 @@ _CONFIG_DEFINITIONS = { 'AUTO_ADD_ARTISTS': (int, 'General', 1), 'BITRATE': (int, 'General', 192), 'BLACKHOLE': (int, 'General', 0), - 'BLACKHOLE_DIR': (str, 'General', ''), + 'BLACKHOLE_DIR': (path, 'General', ''), 'BOXCAR_ENABLED': (int, 'Boxcar', 0), 'BOXCAR_ONSNATCH': (int, 'Boxcar', 0), 'BOXCAR_TOKEN': (str, 'Boxcar', ''), - 'CACHE_DIR': (str, 'General', ''), + 'CACHE_DIR': (path, 'General', ''), 'CACHE_SIZEMB': (int, 'Advanced', 32), 'CHECK_GITHUB': (int, 'General', 1), 'CHECK_GITHUB_INTERVAL': (int, 'General', 360), @@ -44,8 +57,8 @@ _CONFIG_DEFINITIONS = { 'CONFIG_VERSION': (str, 'General', '0'), 'CORRECT_METADATA': (int, 'General', 0), 'CUE_SPLIT': (int, 'General', 1), - 'CUE_SPLIT_FLAC_PATH': (str, 'General', ''), - 'CUE_SPLIT_SHNTOOL_PATH': (str, 'General', ''), + 'CUE_SPLIT_FLAC_PATH': (path, 'General', ''), + 'CUE_SPLIT_SHNTOOL_PATH': (path, 'General', ''), 'CUSTOMAUTH': (int, 'General', 0), 'CUSTOMHOST': (str, 'General', 'localhost'), 'CUSTOMPASS': (str, 'General', ''), @@ -53,12 +66,12 @@ _CONFIG_DEFINITIONS = { 'CUSTOMSLEEP': (int, 'General', 1), 'CUSTOMUSER': (str, 'General', ''), 'DELETE_LOSSLESS_FILES': (int, 'General', 1), - 'DESTINATION_DIR': (str, 'General', ''), + 'DESTINATION_DIR': (path, 'General', ''), 'DETECT_BITRATE': (int, 'General', 0), 'DO_NOT_PROCESS_UNMATCHED': (int, 'General', 0), - 'DOWNLOAD_DIR': (str, 'General', ''), + 'DOWNLOAD_DIR': (path, 'General', ''), 'DOWNLOAD_SCAN_INTERVAL': (int, 'General', 5), - 'DOWNLOAD_TORRENT_DIR': (str, 'General', ''), + 'DOWNLOAD_TORRENT_DIR': (path, 'General', ''), 'DO_NOT_OVERRIDE_GIT_BRANCH': (int, 'General', 0), 'EMAIL_ENABLED': (int, 'Email', 0), 'EMAIL_FROM': (str, 'Email', ''), @@ -74,14 +87,14 @@ _CONFIG_DEFINITIONS = { 'EMBED_LYRICS': (int, 'General', 0), 'ENABLE_HTTPS': (int, 'General', 0), 'ENCODER': (str, 'General', 'ffmpeg'), - 'ENCODERFOLDER': (str, 'General', ''), + 'ENCODERFOLDER': (path, 'General', ''), 'ENCODERLOSSLESS': (int, 'General', 1), 'ENCODEROUTPUTFORMAT': (str, 'General', 'mp3'), 'ENCODERQUALITY': (int, 'General', 2), 'ENCODERVBRCBR': (str, 'General', 'cbr'), 'ENCODER_MULTICORE': (int, 'General', 0), 'ENCODER_MULTICORE_COUNT': (int, 'General', 0), - 'ENCODER_PATH': (str, 'General', ''), + 'ENCODER_PATH': (path, 'General', ''), 'EXTRAS': (str, 'General', ''), 'EXTRA_NEWZNABS': (list, 'Newznab', ''), 'EXTRA_TORZNABS': (list, 'Torznab', ''), @@ -94,7 +107,7 @@ _CONFIG_DEFINITIONS = { 'FOLDER_PERMISSIONS': (str, 'General', '0755'), 'FREEZE_DB': (int, 'General', 0), 'GIT_BRANCH': (str, 'General', 'master'), - 'GIT_PATH': (str, 'General', ''), + 'GIT_PATH': (path, 'General', ''), 'GIT_USER': (str, 'General', 'rembo10'), 'GROWL_ENABLED': (int, 'Growl', 0), 'GROWL_HOST': (str, 'Growl', ''), @@ -103,8 +116,8 @@ _CONFIG_DEFINITIONS = { 'HEADPHONES_INDEXER': (bool_int, 'General', False), 'HPPASS': (str, 'General', ''), 'HPUSER': (str, 'General', ''), - 'HTTPS_CERT': (str, 'General', ''), - 'HTTPS_KEY': (str, 'General', ''), + 'HTTPS_CERT': (path, 'General', ''), + 'HTTPS_KEY': (path, 'General', ''), 'HTTP_HOST': (str, 'General', 'localhost'), 'HTTP_PASSWORD': (str, 'General', ''), 'HTTP_PORT': (int, 'General', 8181), @@ -114,8 +127,8 @@ _CONFIG_DEFINITIONS = { 'IDTAG': (int, 'Beets', 0), 'IGNORE_CLEAN_RELEASES': (int, 'General', 0), 'IGNORED_WORDS': (str, 'General', ''), - 'IGNORED_FOLDERS': (list, 'Advanced', []), - 'IGNORED_FILES': (list, 'Advanced', []), + 'IGNORED_FOLDERS': (list, 'Advanced', []), # path + 'IGNORED_FILES': (list, 'Advanced', []), # path 'INCLUDE_EXTRAS': (int, 'General', 0), 'INTERFACE': (str, 'General', 'default'), 'JOURNAL_MODE': (str, 'Advanced', 'wal'), @@ -130,17 +143,17 @@ _CONFIG_DEFINITIONS = { 'LIBRARYSCAN_INTERVAL': (int, 'General', 300), 'LMS_ENABLED': (int, 'LMS', 0), 'LMS_HOST': (str, 'LMS', ''), - 'LOG_DIR': (str, 'General', ''), + 'LOG_DIR': (path, 'General', ''), 'LOSSLESS_BITRATE_FROM': (int, 'General', 0), 'LOSSLESS_BITRATE_TO': (int, 'General', 0), - 'LOSSLESS_DESTINATION_DIR': (str, 'General', ''), + 'LOSSLESS_DESTINATION_DIR': (path, 'General', ''), 'MB_IGNORE_AGE': (int, 'General', 365), 'MININOVA': (int, 'Mininova', 0), 'MININOVA_RATIO': (str, 'Mininova', ''), 'MIRROR': (str, 'General', 'musicbrainz.org'), 'MOVE_FILES': (int, 'General', 0), 'MPC_ENABLED': (bool_int, 'MPC', False), - 'MUSIC_DIR': (str, 'General', ''), + 'MUSIC_DIR': (path, 'General', ''), 'MUSIC_ENCODER': (int, 'General', 0), 'NEWZNAB': (int, 'Newznab', 0), 'NEWZNAB_APIKEY': (str, 'Newznab', ''), @@ -223,6 +236,7 @@ _CONFIG_DEFINITIONS = { 'SAB_USERNAME': (str, 'SABnzbd', ''), 'SAMPLINGFREQUENCY': (int, 'General', 44100), 'SEARCH_INTERVAL': (int, 'General', 1440), + 'SOFT_CHROOT': (path, 'General', ''), 'SONGKICK_APIKEY': (str, 'Songkick', 'nd1We7dFW2RqxPw8'), 'SONGKICK_ENABLED': (int, 'Songkick', 1), 'SONGKICK_FILTER_ENABLED': (int, 'Songkick', 0), @@ -234,7 +248,7 @@ _CONFIG_DEFINITIONS = { 'SUBSONIC_PASSWORD': (str, 'Subsonic', ''), 'SUBSONIC_USERNAME': (str, 'Subsonic', ''), 'SYNOINDEX_ENABLED': (int, 'Synoindex', 0), - 'TORRENTBLACKHOLE_DIR': (str, 'General', ''), + 'TORRENTBLACKHOLE_DIR': (path, 'General', ''), 'TORRENT_DOWNLOADER': (int, 'General', 0), 'TORRENT_REMOVAL_INTERVAL': (int, 'General', 720), 'TORZNAB': (int, 'Torznab', 0), @@ -295,7 +309,7 @@ class Config(object): definition = _CONFIG_DEFINITIONS[key] if len(definition) == 3: definition_type, section, default = definition - else: + elif len(definition) == 4: definition_type, section, _, default = definition return key, definition_type, section, ini_key, default diff --git a/headphones/exceptions.py b/headphones/exceptions.py index 5d0ddf52..107535c5 100644 --- a/headphones/exceptions.py +++ b/headphones/exceptions.py @@ -24,3 +24,9 @@ class NewzbinAPIThrottled(HeadphonesException): """ Newzbin has throttled us, deal with it """ + +class SoftChrootError(HeadphonesException): + """ + Fatal errors in SoftChroot module + """ + pass diff --git a/headphones/softchroot.py b/headphones/softchroot.py new file mode 100644 index 00000000..80878548 --- /dev/null +++ b/headphones/softchroot.py @@ -0,0 +1,70 @@ +import os +from headphones.exceptions import SoftChrootError + +class SoftChroot(object): + """ SoftChroot provides SOFT chrooting for UI + + IMPORTANT: call methods of this class just in modules, which generates data for client UI. Try to avoid unnecessary usage. + """ + + enabled = False + chroot = None + + def __init__(self, path): + if not path: + #disabled + return + + path = path.strip() + if not path: + return + + if (not os.path.exists(path) or + not os.path.isdir(path)): + raise SoftChrootError('No such directory: %s' % path) + + path = path.rstrip(os.path.sep) + os.path.sep + + self.enabled = True + self.chroot = path + + def isEnabled(self): + return self.enabled + + def getRoot(self): + return self.chroot + + def apply(self, path): + if not self.enabled: + return path + + if not path: + return path + + p = path.strip() + if not p: + return path + + if path.startswith(self.chroot): + p = os.path.sep + path[len(self.chroot):] + else: + p = os.path.sep + + return p + + def revoke(self, path): + if not self.enabled: + return path + + if not path: + return path + + p = path.strip() + if not p: + return path + + if os.path.sep == p[0]: + p = p[1:] + + p = self.chroot + p + return p diff --git a/headphones/softchroot_test.py b/headphones/softchroot_test.py new file mode 100644 index 00000000..baa56703 --- /dev/null +++ b/headphones/softchroot_test.py @@ -0,0 +1,121 @@ +import os +import mock +from headphones.unittestcompat import TestCase, TestArgs +#from mock import MagicMock + +from headphones.softchroot import SoftChroot +from headphones.exceptions import SoftChrootError + +class SoftChrootTest(TestCase): + def test_create(self): + """ create headphones.SoftChroot """ + + cf = SoftChroot('/tmp/') + self.assertIsInstance(cf, SoftChroot) + self.assertTrue(cf.isEnabled()) + self.assertEqual(cf.getRoot(), '/tmp/') + + @TestArgs( + (None), + (''), + (' '), + ) + def test_create_disabled(self, empty_path): + """ create DISABLED SoftChroot """ + + cf = SoftChroot(empty_path) + self.assertIsInstance(cf, SoftChroot) + self.assertFalse(cf.isEnabled()) + self.assertIsNone(cf.getRoot()) + + def test_create_on_not_exists_dir(self): + """ create SoftChroot on non existent dir """ + + path = os.path.join('/tmp', 'notexist', 'asdf', '11', '12', 'np', 'itsssss') + + cf = None + with self.assertRaises(SoftChrootError) as exc: + cf = SoftChroot(path) + self.assertIsNone(cf) + + self.assertRegexpMatches(str(exc.exception), r'No such directory') + self.assertRegexpMatches(str(exc.exception), path) + + @mock.patch('headphones.softchroot.os', wrap=os, name='OsMock') + def test_create_on_file(self, os_mock): + """ create SoftChroot on file, not a directory """ + + path = os.path.join('/tmp', 'notexist', 'asdf', '11', '12', 'np', 'itsssss') + + os_mock.path.sep = os.path.sep + os_mock.path.isdir.side_effect = lambda x: x != path + + cf = None + with self.assertRaises(SoftChrootError) as exc: + cf = SoftChroot(path) + self.assertIsNone(cf) + + self.assertTrue(os_mock.path.isdir.called) + + self.assertRegexpMatches(str(exc.exception), r'No such directory') + self.assertRegexpMatches(str(exc.exception), path) + + @TestArgs( + (None, None), + ('', ''), + (' ', ' '), + ('/tmp/', '/'), + ('/tmp/asdf', '/asdf'), + ) + def test_apply(self, p, e): + """ apply SoftChroot """ + sc = SoftChroot('/tmp/') + a = sc.apply(p) + self.assertEqual(a, e) + + @TestArgs( + ('/'), + ('/nonch/path/asdf'), + ('tmp/asdf'), + ) + def test_apply_out_of_root(self, p): + """ apply SoftChroot to paths outside of the chroot """ + sc = SoftChroot('/tmp/') + a = sc.apply(p) + self.assertEqual(a, '/') + + @TestArgs( + (None, None), + ('', ''), + (' ', ' '), + ('/', '/tmp/'), + ('/asdf', '/tmp/asdf'), + ('/asdf/', '/tmp/asdf/'), + ('localdir/adf', '/tmp/localdir/adf'), + ('localdir/adf/', '/tmp/localdir/adf/'), + ) + def test_revoke(self, p, e): + """ revoke SoftChroot """ + sc = SoftChroot('/tmp/') + a = sc.revoke(p) + self.assertEqual(a, e) + + @TestArgs( + (None), + (''), + (' '), + ('/tmp'), + ('/tmp/'), + ('/tmp/asdf'), + ('/tmp/localdir/adf'), + ('localdir/adf'), + ('localdir/adf/'), + ) + def test_actions_on_disabled(self, p): + """ disabled SoftChroot should not change args on apply and revoke """ + sc = SoftChroot(None) + a = sc.apply(p) + self.assertEqual(a, p) + + r = sc.revoke(p) + self.assertEqual(r, p) diff --git a/headphones/unittestcompat.py b/headphones/unittestcompat.py new file mode 100644 index 00000000..8eb441f1 --- /dev/null +++ b/headphones/unittestcompat.py @@ -0,0 +1,90 @@ +import sys +from unittest import TestCase as TC + +def _is26(): + if sys.version_info[0] == 2 and sys.version_info[1] == 6: + return True + return False + +_dummy = _is26() + +def _d(f): + def decorate(self, *args, **kw): + if _dummy: + return self.assertTrue(True) + return f(self, *args, **kw) + return decorate + +class TestCase(TC): + """ + Wrapper for python 2.6 stubs + """ + + def assertIsInstance(self, obj, cls, msg=None): + if not _dummy: + return super(TestCase, self).assertIsInstance(obj, cls, msg) + tst = isinstance(obj, cls) + return self.assertTrue(tst, msg) + + @_d + def assertNotIsInstance(self, *args, **kw): + return super(TestCase, self).assertNotIsInstance(*args, **kw) + + @_d + def assertIn(self, *args, **kw): + return super(TestCase, self).assertIn(*args, **kw) + + @_d + def assertRegexpMatches(self, *args, **kw): + return super(TestCase, self).assertRegexpMatches(*args, **kw) + + def assertIsNone(self, val, msg=None): + if not _dummy: + return super(TestCase, self).assertIsNone(val, msg) + tst = val is None + return super(TestCase, self).assertTrue(tst, msg) + + def assertIsNotNone(self, val, msg=None): + if not _dummy: + return super(TestCase, self).assertIsNotNone(val, msg) + tst = val is not None + return super(TestCase, self).assertTrue(tst, msg) + + class _TestCaseRaiseStub: + def __init__(self, exc, tc): + self.exc = exc + self.tc = tc + + def __enter__(self): + return self + + def __exit__(self, tp, value, traceback): + tst = tp is self.exc + self.tc.assertTrue(tst) + self.exception = value + return True + + def assertRaises(self, exc, msg=None): + if not _dummy: + return super(TestCase, self).assertRaises(exc, msg) + return TestCase._TestCaseRaiseStub(exc, self) + +def TestArgs(*parameters): + def tuplify(x): + if not isinstance(x, tuple): + return (x,) + return x + + def decorator(method, parameters=parameters): + for parameter in (tuplify(x) for x in parameters): + + def method_for_parameter(self, method=method, parameter=parameter): + method(self, *parameter) + args_for_parameter = ",".join(repr(v) for v in parameter) + name_for_parameter = method.__name__ + "(" + args_for_parameter + ")" + frame = sys._getframe(1) # pylint: disable-msg=W0212 + frame.f_locals[name_for_parameter] = method_for_parameter + frame.f_locals[name_for_parameter].__doc__ = method.__doc__ + '(' + args_for_parameter + ')' + method_for_parameter.__name__ = name_for_parameter + '(' + args_for_parameter + ')' + return None + return decorator diff --git a/headphones/webserve.py b/headphones/webserve.py index 56d79ea1..ee1000de 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -1367,8 +1367,16 @@ class WebInterface(object): "idtag": checked(headphones.CONFIG.IDTAG) } + for k, v in config.iteritems(): + if isinstance(v, headphones.config.path): + # need to apply SoftChroot to paths: + nv = headphones.SOFT_CHROOT.apply(v) + if v != nv: + config[k] = headphones.config.path(nv) + # Need to convert EXTRAS to a dictionary we can pass to the config: # it'll come in as a string like 2,5,6,8 + extra_munges = { "dj-mix": "dj_mix", "mixtape/street": "mixtape_street" @@ -1435,6 +1443,17 @@ class WebInterface(object): kwargs[plain_config] = kwargs[use_config] del kwargs[use_config] + for k, v in kwargs.iteritems(): + # TODO : HUGE crutch. It is all because there is no way to deal with options... + _conf = headphones.CONFIG._define(k) + conftype = _conf[1] + + #print '===>', conftype + if conftype is headphones.config.path: + nv = headphones.SOFT_CHROOT.revoke(v) + if nv != v: + kwargs[k] = nv + # Check if encoderoutputformat is set multiple times if len(kwargs['encoderoutputformat'][-1]) > 1: kwargs['encoderoutputformat'] = kwargs['encoderoutputformat'][-1] diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..e47a22ee --- /dev/null +++ b/setup.cfg @@ -0,0 +1,12 @@ +[nosetests] +verbosity=2 +tests=headphones +#rednose=1 +#exclude-dir=lib + +with-coverage=1 +cover-branches=1 + +cover-html=1 +cover-html-dir=cover-html +cover-package=headphones